Mise en place v0.6.8
Amélioration de l'installation automatique Ajout de la possibilité de télécharger le llm que l'on veut à l'interieur de l'application en communicant avec ollama
This commit is contained in:
@@ -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.8</version>
|
||||||
<name>LoreMind Core</name>
|
<name>LoreMind Core</name>
|
||||||
<description>Backend Core - Architecture Hexagonale</description>
|
<description>Backend Core - Architecture Hexagonale</description>
|
||||||
|
|
||||||
|
|||||||
@@ -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,17 @@ 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.io.OutputStream;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Duration;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -66,11 +78,81 @@ 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 -> {
|
||||||
|
HttpClient http = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(10))
|
||||||
|
.build();
|
||||||
|
HttpRequest req = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(brainBaseUrl + "/models/ollama/pull"))
|
||||||
|
.timeout(Duration.ofMinutes(60))
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(toJson(body)))
|
||||||
|
.build();
|
||||||
|
try {
|
||||||
|
HttpResponse<InputStream> resp = http.send(req, HttpResponse.BodyHandlers.ofInputStream());
|
||||||
|
try (InputStream in = resp.body()) {
|
||||||
|
byte[] buf = new byte[4096];
|
||||||
|
int n;
|
||||||
|
while ((n = in.read(buf)) != -1) {
|
||||||
|
output.write(buf, 0, n);
|
||||||
|
output.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (InterruptedException ie) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new IOException("Pull interrompu", ie);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return ResponseEntity.ok().contentType(MediaType.parseMediaType("application/x-ndjson")).body(stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/models/ollama/{name}")
|
||||||
|
public ResponseEntity<Map<String, Object>> deleteOllamaModel(@PathVariable("name") String name) {
|
||||||
|
guardDemoMode();
|
||||||
|
return forward(HttpMethod.DELETE, "/models/ollama/" + name, null);
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/models/onemin")
|
@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");
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
Auteur : ietm64
|
Auteur : ietm64
|
||||||
Licence : AGPL-3.0
|
Licence : AGPL-3.0
|
||||||
Projet : LoreMindMJ - assistant pour Maitres de Jeu de JDR
|
Projet : LoreMindMJ - assistant pour Maitres de Jeu de JDR
|
||||||
Version : 0.6.6
|
Version : 0.6.8
|
||||||
|
|
||||||
.LINK
|
.LINK
|
||||||
https://git.igmlcreation.fr/ietm64/loremind
|
https://git.igmlcreation.fr/ietm64/loremind
|
||||||
@@ -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) {
|
||||||
docker info *>$null
|
if (Get-Command docker -ErrorAction SilentlyContinue) {
|
||||||
if ($LASTEXITCODE -eq 0) { Write-Ok "Docker repond"; return $true }
|
if (-not $reportedFound) {
|
||||||
Start-Sleep -Seconds 3
|
Write-Ok "Commande 'docker' detectee, attente du daemon..."
|
||||||
|
$reportedFound = $true
|
||||||
|
}
|
||||||
|
docker info *>$null
|
||||||
|
if ($LASTEXITCODE -eq 0) { Write-Ok "Docker repond"; return $true }
|
||||||
|
}
|
||||||
|
Start-Sleep -Seconds 5
|
||||||
}
|
}
|
||||||
return $false
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -270,7 +302,7 @@ if ($llmProvider -eq 'ollama') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$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]"
|
||||||
@@ -328,6 +360,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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ fi
|
|||||||
# 3. none : aucune installation, configuration ulterieure via l'app
|
# 3. none : aucune installation, configuration ulterieure via l'app
|
||||||
OLLAMA_MODE="embedded"
|
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
|
||||||
@@ -228,6 +228,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
|
||||||
|
|||||||
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.8",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.6.6",
|
"version": "0.6.8",
|
||||||
"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.8",
|
||||||
"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);
|
||||||
|
|||||||
@@ -65,6 +65,86 @@ 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> {
|
||||||
|
return new Observable<OllamaPullEvent>((subscriber) => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.apiUrl}/models/ollama/pull`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
if (!response.ok || !response.body) {
|
||||||
|
subscriber.error(new Error(`HTTP ${response.status}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
// Decoupage NDJSON : chaque ligne est un objet JSON complet.
|
||||||
|
let nl: number;
|
||||||
|
while ((nl = buffer.indexOf('\n')) >= 0) {
|
||||||
|
const line = buffer.slice(0, nl).trim();
|
||||||
|
buffer = buffer.slice(nl + 1);
|
||||||
|
if (!line) continue;
|
||||||
|
try {
|
||||||
|
subscriber.next(JSON.parse(line) as OllamaPullEvent);
|
||||||
|
} catch {
|
||||||
|
// Ligne non JSON (rare) : on l'ignore.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
subscriber.complete();
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as Error).name !== 'AbortError') subscriber.error(err);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => controller.abort();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteOllamaModel(name: string): Observable<{ status: string; name: string }> {
|
||||||
|
return this.http.delete<{ status: string; name: string }>(
|
||||||
|
`${this.apiUrl}/models/ollama/${encodeURIComponent(name)}`, this.authOptions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format des evenements emis par Ollama pendant un pull. Les champs sont
|
||||||
|
* optionnels car le serveur emet differents types de messages selon l'etape :
|
||||||
|
* - `{status: "pulling manifest"}`
|
||||||
|
* - `{status: "downloading", digest, total, completed}`
|
||||||
|
* - `{status: "verifying sha256 digest"}`
|
||||||
|
* - `{status: "writing manifest"}`
|
||||||
|
* - `{status: "removing any unused layers"}`
|
||||||
|
* - `{status: "success"}`
|
||||||
|
* - `{error: "..."}` en cas d'erreur (modele inexistant, reseau, etc.)
|
||||||
|
*/
|
||||||
|
export interface OllamaPullEvent {
|
||||||
|
status?: string;
|
||||||
|
digest?: string;
|
||||||
|
total?: number;
|
||||||
|
completed?: number;
|
||||||
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Un groupe de modeles 1min.ai regroupes par fournisseur (Anthropic, OpenAI, ...). */
|
/** 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>
|
||||||
|
|||||||
@@ -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,34 @@ 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;
|
||||||
|
|
||||||
|
/** 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 +258,119 @@ export class SettingsComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Gestion des modeles Ollama (pull / delete) -------------------------
|
||||||
|
|
||||||
|
openPullDialog(): void {
|
||||||
|
this.pullDialogOpen = true;
|
||||||
|
this.pullModelName = '';
|
||||||
|
this.resetPullState();
|
||||||
|
}
|
||||||
|
|
||||||
|
closePullDialog(): void {
|
||||||
|
if (this.pullInProgress) return; // empêche fermeture pendant un pull
|
||||||
|
this.pullDialogOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectSuggestion(name: string): void {
|
||||||
|
this.pullModelName = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
startPull(): void {
|
||||||
|
const name = this.pullModelName.trim();
|
||||||
|
if (!name || this.pullInProgress) return;
|
||||||
|
this.resetPullState();
|
||||||
|
this.pullInProgress = true;
|
||||||
|
this.pullStatus = 'connexion...';
|
||||||
|
this.errorMessage = '';
|
||||||
|
|
||||||
|
this.pullSubscription = this.settingsService.pullOllamaModel(name).subscribe({
|
||||||
|
next: (event: OllamaPullEvent) => {
|
||||||
|
if (event.error) {
|
||||||
|
this.errorMessage = `Echec : ${event.error}`;
|
||||||
|
this.pullInProgress = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.status) this.pullStatus = event.status;
|
||||||
|
if (event.completed != null) this.pullCompleted = event.completed;
|
||||||
|
if (event.total != null) this.pullTotal = event.total;
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.errorMessage = this.extractError(err, `Echec du telechargement de ${name}.`);
|
||||||
|
this.pullInProgress = false;
|
||||||
|
},
|
||||||
|
complete: () => {
|
||||||
|
this.pullInProgress = false;
|
||||||
|
this.successMessage = `Modele ${name} telecharge.`;
|
||||||
|
this.refreshModels();
|
||||||
|
// Si l'utilisateur n'avait aucun modele, on selectionne celui-ci.
|
||||||
|
if (this.settings && !this.settings.llm_model) {
|
||||||
|
this.settings.llm_model = name;
|
||||||
|
this.fetchOllamaModelInfo();
|
||||||
|
}
|
||||||
|
// Petite tempo avant de fermer pour que le user voie "success".
|
||||||
|
setTimeout(() => this.closePullDialog(), 1200);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelPull(): void {
|
||||||
|
if (this.pullSubscription) {
|
||||||
|
this.pullSubscription.unsubscribe();
|
||||||
|
this.pullSubscription = null;
|
||||||
|
}
|
||||||
|
this.pullInProgress = false;
|
||||||
|
this.pullStatus = 'annule';
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetPullState(): void {
|
||||||
|
this.pullStatus = '';
|
||||||
|
this.pullCompleted = 0;
|
||||||
|
this.pullTotal = 0;
|
||||||
|
if (this.pullSubscription) {
|
||||||
|
this.pullSubscription.unsubscribe();
|
||||||
|
this.pullSubscription = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pourcentage du digest courant pour la barre de progression. */
|
||||||
|
get pullPercent(): number {
|
||||||
|
if (this.pullTotal <= 0) return 0;
|
||||||
|
return Math.min(100, Math.round((this.pullCompleted / this.pullTotal) * 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Affichage humain des octets ('1.2 GB' / '450 MB'). */
|
||||||
|
formatBytes(b: number): string {
|
||||||
|
if (!b) return '0';
|
||||||
|
const u = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
let i = 0;
|
||||||
|
let v = b;
|
||||||
|
while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; }
|
||||||
|
return `${v.toFixed(v < 10 && i > 0 ? 1 : 0)} ${u[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteModel(name: string): void {
|
||||||
|
if (!confirm(`Supprimer le modele '${name}' ? L'espace disque sera libere.`)) return;
|
||||||
|
this.deletingModel = name;
|
||||||
|
this.errorMessage = '';
|
||||||
|
this.settingsService.deleteOllamaModel(name).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.deletingModel = null;
|
||||||
|
this.successMessage = `Modele ${name} supprime.`;
|
||||||
|
// Si l'utilisateur supprime le modele actuellement selectionne,
|
||||||
|
// on bascule sur le premier disponible (ou vide).
|
||||||
|
this.refreshModels();
|
||||||
|
if (this.settings && this.settings.llm_model === name) {
|
||||||
|
this.settings.llm_model = '';
|
||||||
|
this.ollamaModelMaxContext = 0;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.deletingModel = null;
|
||||||
|
this.errorMessage = this.extractError(err, `Echec de la suppression de ${name}.`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
goBack(): void {
|
goBack(): void {
|
||||||
this.router.navigate(['/lore']);
|
this.router.navigate(['/lore']);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user