From addf78f01d4e7892b6f73f3af93716bfd56e5540 Mon Sep 17 00:00:00 2001 From: "IETM_FIXE\\ietm6" Date: Sun, 26 Apr 2026 01:11:04 +0200 Subject: [PATCH] =?UTF-8?q?Mise=20en=20place=20v0.6.8=20Am=C3=A9lioration?= =?UTF-8?q?=20de=20l'installation=20automatique=20Ajout=20de=20la=20possib?= =?UTF-8?q?ilit=C3=A9=20de=20t=C3=A9l=C3=A9charger=20le=20llm=20que=20l'on?= =?UTF-8?q?=20veut=20=C3=A0=20l'interieur=20de=20l'application=20en=20comm?= =?UTF-8?q?unicant=20avec=20ollama?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- brain/app/main.py | 70 ++++++++ core/pom.xml | 2 +- .../web/controller/SettingsController.java | 82 +++++++++ installers/install.ps1 | 85 ++++++++- installers/install.sh | 37 +++- web/package-lock.json | 4 +- web/package.json | 2 +- .../session-expired.interceptor.ts | 17 +- web/src/app/services/settings.service.ts | 80 +++++++++ web/src/app/settings/settings.component.html | 80 +++++++++ web/src/app/settings/settings.component.scss | 167 ++++++++++++++++++ web/src/app/settings/settings.component.ts | 146 ++++++++++++++- 12 files changed, 754 insertions(+), 18 deletions(-) diff --git a/brain/app/main.py b/brain/app/main.py index 10e88b0..2558d0f 100644 --- a/brain/app/main.py +++ b/brain/app/main.py @@ -689,6 +689,76 @@ async def get_ollama_model_info( return OllamaModelInfoDTO(context_length=0) +@app.post("/models/ollama/pull") +async def pull_ollama_model( + body: dict[str, str], + settings: Annotated[Settings, Depends(get_settings)], +) -> StreamingResponse: + """Telecharge un modele depuis Ollama et streame la progression. + + Proxifie l'endpoint `/api/pull` d'Ollama qui renvoie du JSON ligne par + ligne (NDJSON) avec le statut de chaque etape : manifest, layers, + digest, success. On reemet ce flux tel quel au client (le front + parsera les lignes et affichera une barre de progression). + + Le timeout est intentionnellement tres long (60 min) car certains + modeles font 30+ Go. + """ + name = (body.get("name") or "").strip() + if not name: + raise HTTPException(status_code=400, detail="name requis") + url = f"{settings.ollama_base_url}/api/pull" + + async def stream() -> AsyncIterator[bytes]: + # On utilise un timeout long pour la lecture (60 min) mais court pour + # la connexion (10s) — si Ollama n'est pas joignable, on echoue vite. + timeout = httpx.Timeout(connect=10, read=3600, write=10, pool=10) + try: + async with httpx.AsyncClient(timeout=timeout) as client: + async with client.stream("POST", url, json={"model": name, "stream": True}) as r: + if r.status_code != 200: + # Ollama renvoie un message JSON d'erreur. On le passe + # tel quel au client en preservant le code HTTP. + body_text = await r.aread() + yield body_text + return + async for chunk in r.aiter_bytes(): + yield chunk + except httpx.HTTPError as e: + # Erreur reseau : on emet une ligne JSON d'erreur compatible + # avec le format NDJSON d'Ollama. + err = json.dumps({"error": f"Connexion a Ollama impossible : {e}"}) + "\n" + yield err.encode("utf-8") + + # application/x-ndjson : un objet JSON par ligne, pas de wrapping SSE. + # C'est le format natif d'Ollama, le front le parsera ligne par ligne. + return StreamingResponse(stream(), media_type="application/x-ndjson") + + +@app.delete("/models/ollama/{name:path}") +async def delete_ollama_model( + name: str, + settings: Annotated[Settings, Depends(get_settings)], +) -> dict[str, str]: + """Supprime un modele du serveur Ollama. + + Le `:path` dans le pattern autorise les `:` du nom (ex: `gemma4:e4b`) + sans avoir besoin de URL-encoder cote client. + """ + if not name.strip(): + raise HTTPException(status_code=400, detail="name requis") + url = f"{settings.ollama_base_url}/api/delete" + try: + async with httpx.AsyncClient(timeout=10) as client: + response = await client.request("DELETE", url, json={"model": name}) + if response.status_code == 404: + raise HTTPException(status_code=404, detail=f"Modele '{name}' introuvable") + response.raise_for_status() + except httpx.HTTPError as e: + raise HTTPException(status_code=502, detail=f"Ollama injoignable : {e}") + return {"status": "deleted", "name": name} + + @app.get("/models/onemin") def list_onemin_models() -> dict[str, list[dict[str, object]]]: """Catalogue statique des modeles 1min.ai, groupes par fournisseur. diff --git a/core/pom.xml b/core/pom.xml index 3ed2dd1..b599cf4 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -14,7 +14,7 @@ com.loremind loremind-core - 0.6.6 + 0.6.8 LoreMind Core Backend Core - Architecture Hexagonale diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/SettingsController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/SettingsController.java index 501b02a..c0ecfe0 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/controller/SettingsController.java +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/SettingsController.java @@ -7,7 +7,9 @@ import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -15,7 +17,17 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.client.RestTemplate; import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; import java.util.Map; /** @@ -66,11 +78,81 @@ public class SettingsController { return forward(HttpMethod.POST, "/models/ollama/info", body); } + /** + * Telecharge un modele Ollama et streame la progression au client. + *

+ * 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 pullOllamaModel(@RequestBody Map 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 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> deleteOllamaModel(@PathVariable("name") String name) { + guardDemoMode(); + return forward(HttpMethod.DELETE, "/models/ollama/" + name, null); + } + @GetMapping("/models/onemin") public ResponseEntity> listOneMinModels() { return forward(HttpMethod.GET, "/models/onemin", null); } + /** + * Serialiseur JSON minimal pour eviter d'instancier ObjectMapper a chaque + * appel. Suffisant pour notre cas d'usage : Map avec des + * String/Number/Boolean en valeur. + */ + private static String toJson(Map m) { + StringBuilder sb = new StringBuilder("{"); + boolean first = true; + for (Map.Entry e : m.entrySet()) { + if (!first) sb.append(","); + sb.append("\"").append(escape(e.getKey())).append("\":"); + Object v = e.getValue(); + if (v == null) sb.append("null"); + else if (v instanceof Number || v instanceof Boolean) sb.append(v); + else sb.append("\"").append(escape(v.toString())).append("\""); + first = false; + } + return sb.append("}").toString(); + } + + private static String escape(String s) { + return s.replace("\\", "\\\\").replace("\"", "\\\"") + .replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t"); + } + private void guardDemoMode() { if (demoMode) { throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Settings disabled in demo mode"); diff --git a/installers/install.ps1 b/installers/install.ps1 index 06b0e73..208231f 100644 --- a/installers/install.ps1 +++ b/installers/install.ps1 @@ -40,7 +40,7 @@ Auteur : ietm64 Licence : AGPL-3.0 Projet : LoreMindMJ - assistant pour Maitres de Jeu de JDR - Version : 0.6.6 + Version : 0.6.8 .LINK https://git.igmlcreation.fr/ietm64/loremind @@ -91,17 +91,36 @@ function Test-Docker { return ($LASTEXITCODE -eq 0) } -function Wait-Docker([int]$TimeoutSec = 180) { +function Wait-Docker([int]$TimeoutSec = 600) { + # Attend que Docker reponde. Tolere les erreurs "command not found" pendant + # les premieres iterations le temps que le PATH soit rafraichi. Write-Step "Attente du demarrage de Docker Desktop (max ${TimeoutSec}s)..." + Write-Host " Si Docker Desktop affiche un contrat de licence, acceptez-le." $deadline = (Get-Date).AddSeconds($TimeoutSec) + $reportedFound = $false while ((Get-Date) -lt $deadline) { - docker info *>$null - if ($LASTEXITCODE -eq 0) { Write-Ok "Docker repond"; return $true } - Start-Sleep -Seconds 3 + if (Get-Command docker -ErrorAction SilentlyContinue) { + if (-not $reportedFound) { + Write-Ok "Commande 'docker' detectee, attente du daemon..." + $reportedFound = $true + } + docker info *>$null + if ($LASTEXITCODE -eq 0) { Write-Ok "Docker repond"; return $true } + } + Start-Sleep -Seconds 5 } return $false } +function Update-PathFromRegistry { + # winget install ne propage pas les modifs de PATH a la session courante. + # On relit la valeur PATH depuis le registre (Machine + User) et on + # l'applique a $env:PATH pour rendre 'docker.exe' immediatement utilisable. + $machinePath = [Environment]::GetEnvironmentVariable('Path','Machine') + $userPath = [Environment]::GetEnvironmentVariable('Path','User') + $env:PATH = ($machinePath, $userPath -join ';').TrimEnd(';') +} + # --------------------------------------------------------------------------- # 0. Verification des droits administrateur # --------------------------------------------------------------------------- @@ -159,12 +178,25 @@ if (Test-Docker) { winget install --id Docker.DockerDesktop -e --accept-package-agreements --accept-source-agreements if ($LASTEXITCODE -ne 0) { Write-Err "Echec de l'installation Docker Desktop via winget"; exit 1 } + # winget a modifie le PATH systeme mais pas celui de la session courante. + # On le rafraichit pour que la commande 'docker' soit immediatement trouvable. + Update-PathFromRegistry + Write-Step "Lancement de Docker Desktop..." $dd = "$env:ProgramFiles\Docker\Docker\Docker Desktop.exe" if (Test-Path $dd) { Start-Process $dd } - if (-not (Wait-Docker 240)) { - Write-Err "Docker n'a pas demarre. Lancez-le manuellement puis relancez ce script." + Write-Host "" + Write-Host " Docker Desktop demarre pour la premiere fois." -ForegroundColor Yellow + Write-Host " Au premier lancement, il affiche un contrat de licence (Subscription Service Agreement)." + Write-Host " Cliquez 'Accept' pour continuer." + Write-Host "" + Read-Host " Appuyez sur Entree une fois que Docker Desktop affiche 'Engine running' (icone baleine verte)" + + if (-not (Wait-Docker 600)) { + Write-Err "Docker ne repond toujours pas apres 10 minutes." + Write-Err "Verifiez que Docker Desktop est lance et que vous avez accepte le contrat," + Write-Err "puis relancez install.bat." exit 1 } } @@ -270,7 +302,7 @@ if ($llmProvider -eq 'ollama') { } } -$llmModel = 'gemma4:26b' +$llmModel = 'gemma4:e4b' $autoUpdate = if ($NonInteractive) { $true } else { $r = Read-Host " Activer les mises a jour auto (chaque nuit a 4h) ? [O/n]" @@ -328,6 +360,43 @@ Write-Step "Demarrage de la stack" docker compose up -d if ($LASTEXITCODE -ne 0) { Write-Err "Echec docker compose up"; exit 1 } +# --------------------------------------------------------------------------- +# 5b. Telechargement du modele Ollama (mode embarque uniquement) +# --------------------------------------------------------------------------- +# En mode embarque, le conteneur Ollama est prêt mais ne contient aucun modele +# par defaut. On propose de pull le modele configure tout de suite pour que +# l'utilisateur ait quelque chose a utiliser des le premier lancement. +if ($ollamaMode -eq 'embedded' -and $llmProvider -eq 'ollama') { + $pullNow = if ($NonInteractive) { $true } else { + $r = Read-Host " Telecharger le modele '$llmModel' maintenant ? (peut prendre quelques minutes) [O/n]" + -not ($r -match '^(n|N|no|non)$') + } + if ($pullNow) { + # Petite attente pour laisser le conteneur ollama finir son init. + Write-Step "Attente de la disponibilite du conteneur Ollama..." + $ollamaReady = $false + for ($i = 0; $i -lt 30; $i++) { + docker exec loremind-ollama ollama list *>$null + if ($LASTEXITCODE -eq 0) { $ollamaReady = $true; break } + Start-Sleep -Seconds 2 + } + if (-not $ollamaReady) { + Write-Warn2 "Le conteneur Ollama ne repond pas encore. Vous pourrez pull le modele plus tard avec :" + Write-Warn2 " docker exec -it loremind-ollama ollama pull $llmModel" + } else { + Write-Step "Telechargement du modele $llmModel (peut prendre plusieurs minutes selon votre connexion)..." + docker exec loremind-ollama ollama pull $llmModel + if ($LASTEXITCODE -eq 0) { + Write-Ok "Modele $llmModel pret a l'emploi" + } else { + Write-Warn2 "Echec du pull. Reessayez manuellement : docker exec -it loremind-ollama ollama pull $llmModel" + } + } + } else { + Write-Host " Pour le telecharger plus tard : docker exec -it loremind-ollama ollama pull $llmModel" + } +} + # --------------------------------------------------------------------------- # 6. Recap # --------------------------------------------------------------------------- diff --git a/installers/install.sh b/installers/install.sh index 3a84c07..2b84702 100644 --- a/installers/install.sh +++ b/installers/install.sh @@ -129,7 +129,7 @@ fi # 3. none : aucune installation, configuration ulterieure via l'app OLLAMA_MODE="embedded" OLLAMA_BASE_URL_VAL="http://ollama:11434" -LLM_MODEL_VAL="gemma4:26b" +LLM_MODEL_VAL="gemma4:e4b" if [ "$LLM_PROVIDER" = "ollama" ]; then HOST_OLLAMA_REPLY="$(ask "Avez-vous deja Ollama installe sur cette machine ? [o/N]" "N")" case "$HOST_OLLAMA_REPLY" in @@ -228,6 +228,41 @@ docker compose pull step "Demarrage de la stack" docker compose up -d +# 5b. Telechargement du modele Ollama (mode embarque uniquement) +# ---------------------------------------------------------------------------- +# Le conteneur Ollama est pret mais sans modele. On propose le pull tout de +# suite pour que l'utilisateur ait quelque chose a utiliser au premier lancement. +if [ "$LLM_PROVIDER" = "ollama" ] && [ "$OLLAMA_MODE" = "embedded" ]; then + PULL_REPLY="$(ask "Telecharger le modele '${LLM_MODEL_VAL}' maintenant ? (peut prendre plusieurs minutes) [O/n]" "O")" + case "$PULL_REPLY" in + n|N|no|non|No|Non) + echo " Pour le telecharger plus tard : docker exec -it loremind-ollama ollama pull ${LLM_MODEL_VAL}" + ;; + *) + step "Attente de la disponibilite du conteneur Ollama..." + OLLAMA_READY=0 + for i in $(seq 1 30); do + if docker exec loremind-ollama ollama list >/dev/null 2>&1; then + OLLAMA_READY=1 + break + fi + sleep 2 + done + if [ "$OLLAMA_READY" = "0" ]; then + warn "Le conteneur Ollama ne repond pas encore. Vous pourrez pull plus tard :" + warn " docker exec -it loremind-ollama ollama pull ${LLM_MODEL_VAL}" + else + step "Telechargement du modele ${LLM_MODEL_VAL} (peut prendre plusieurs minutes selon votre connexion)..." + if docker exec loremind-ollama ollama pull "${LLM_MODEL_VAL}"; then + ok "Modele ${LLM_MODEL_VAL} pret a l'emploi" + else + warn "Echec du pull. Reessayez : docker exec -it loremind-ollama ollama pull ${LLM_MODEL_VAL}" + fi + fi + ;; + esac +fi + # 6. Recap URL="http://localhost:${WEB_PORT}" echo diff --git a/web/package-lock.json b/web/package-lock.json index 3e1c0e2..9097626 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "loremind-web", - "version": "0.6.6", + "version": "0.6.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "loremind-web", - "version": "0.6.6", + "version": "0.6.8", "dependencies": { "@angular/animations": "^17.0.0", "@angular/common": "^17.0.0", diff --git a/web/package.json b/web/package.json index 999e706..c929f00 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "loremind-web", - "version": "0.6.6", + "version": "0.6.8", "description": "LoreMind Frontend - Angular", "scripts": { "ng": "ng", diff --git a/web/src/app/interceptors/session-expired.interceptor.ts b/web/src/app/interceptors/session-expired.interceptor.ts index 05abcd0..c06caeb 100644 --- a/web/src/app/interceptors/session-expired.interceptor.ts +++ b/web/src/app/interceptors/session-expired.interceptor.ts @@ -1,5 +1,7 @@ import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http'; +import { inject } from '@angular/core'; import { catchError, throwError } from 'rxjs'; +import { ConfigService } from '../services/config.service'; /** * Detecte la perte de session demo (orchestrateur) via les codes 401/502 sur @@ -7,9 +9,10 @@ import { catchError, throwError } from 'rxjs'; * Le reload renvoie l'utilisateur sur la page "Preparation" pour creer une * nouvelle session sans qu'il ait a faire Ctrl+Shift+R. * - * Cet interceptor est inerte en mode normal (non-demo) : si le backend natif - * renvoie un 401 legitime, ca declenche aussi le reload, ce qui est sans - * consequence puisqu'aucun flux d'auth utilisateur n'existe encore cote app. + * Strictement inerte hors mode demo : sans cette garde, un 401 du backend + * natif (par ex. HTTP Basic sur /api/settings avant authentification) ou un + * 502 transitoire au boot (Brain pas encore pret) declencherait a tort + * l'overlay "session demo expiree" sur les installs self-hosted. */ // Module-level flag : evite de declencher overlay + reload plusieurs fois si @@ -17,8 +20,16 @@ import { catchError, throwError } from 'rxjs'; let alreadyTriggered = false; export const sessionExpiredInterceptor: HttpInterceptorFn = (req, next) => { + const config = inject(ConfigService); + return next(req).pipe( catchError((err) => { + // Garde stricte : l'overlay et le reload n'ont de sens qu'en mode demo, + // ou l'orchestrateur drop les sessions sans prevenir le client. + if (!config.demoMode) { + return throwError(() => err); + } + const isApiCall = req.url.includes('/api/'); const isSessionLoss = err instanceof HttpErrorResponse && (err.status === 401 || err.status === 502); diff --git a/web/src/app/services/settings.service.ts b/web/src/app/services/settings.service.ts index 472a062..49b9705 100644 --- a/web/src/app/services/settings.service.ts +++ b/web/src/app/services/settings.service.ts @@ -65,6 +65,86 @@ export class SettingsService { listOneMinModels(): Observable<{ groups: OneMinModelGroup[] }> { return this.http.get<{ groups: OneMinModelGroup[] }>(`${this.apiUrl}/models/onemin`, this.authOptions); } + + /** + * Telecharge un modele Ollama et streame la progression au client. + * + * Le backend renvoie du NDJSON (un objet JSON par ligne) avec le format + * Ollama natif : `{status, digest?, total?, completed?}`. On parse chaque + * ligne au fur et a mesure et on emet via un Observable que le composant + * peut consommer pour mettre a jour une barre de progression. + * + * On utilise `fetch` directement plutot que `HttpClient` car Angular + * bufferise les reponses XHR, ce qui empeche le streaming en temps reel. + */ + pullOllamaModel(name: string): Observable { + return new Observable((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, ...). */ diff --git a/web/src/app/settings/settings.component.html b/web/src/app/settings/settings.component.html index 7b0f466..1f53e79 100644 --- a/web/src/app/settings/settings.component.html +++ b/web/src/app/settings/settings.component.html @@ -56,11 +56,91 @@ {{ loadingModels ? 'Chargement...' : 'Actualiser' }} +

Aucun modele detecte. Verifie que Ollama tourne et que l'URL est correcte.

+ + +
+ + +
+ + +

Configuration 1min.ai

diff --git a/web/src/app/settings/settings.component.scss b/web/src/app/settings/settings.component.scss index d041b51..27b7cf2 100644 --- a/web/src/app/settings/settings.component.scss +++ b/web/src/app/settings/settings.component.scss @@ -5,6 +5,173 @@ color: var(--color-text, #e8e8e8); } +// --- Liste des modeles installes ----------------------------------------- +.installed-models { + list-style: none; + margin: 6px 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 4px; + + li { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 12px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 6px; + font-size: 0.9rem; + } + + .model-name { + font-family: ui-monospace, SFMono-Regular, monospace; + color: #c4b8e0; + } +} + +.btn-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + background: transparent; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 5px; + color: inherit; + cursor: pointer; + &:hover { background: rgba(255, 255, 255, 0.06); } + &:disabled { opacity: 0.5; cursor: not-allowed; } +} + +.btn-icon.btn-danger:hover { + background: rgba(239, 68, 68, 0.12); + border-color: rgba(239, 68, 68, 0.4); + color: #fca5a5; +} + +// --- Modal de pull ------------------------------------------------------- +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.modal-content { + background: #1f1a2e; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 10px; + width: min(520px, 92vw); + max-height: 90vh; + display: flex; + flex-direction: column; +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + + h3 { + margin: 0; + font-size: 1.05rem; + font-weight: 600; + } +} + +.modal-body { + padding: 20px; + overflow-y: auto; + + label { + display: block; + margin-bottom: 6px; + font-weight: 500; + font-size: 0.9rem; + } + + input[type="text"] { + width: 100%; + padding: 8px 12px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 6px; + color: inherit; + font-family: ui-monospace, SFMono-Regular, monospace; + box-sizing: border-box; + &:focus { outline: 2px solid #b794f4; outline-offset: 0; } + } +} + +.suggestions { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 4px; +} + +.suggestion-chip { + padding: 4px 10px; + background: rgba(183, 148, 244, 0.1); + border: 1px solid rgba(183, 148, 244, 0.25); + border-radius: 14px; + color: #d6c5f0; + font-family: ui-monospace, SFMono-Regular, monospace; + font-size: 0.82rem; + cursor: pointer; + &:hover { background: rgba(183, 148, 244, 0.2); } +} + +.modal-footer { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 14px 20px; + border-top: 1px solid rgba(255, 255, 255, 0.08); +} + +.pull-progress { + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px 0; +} + +.pull-status { + font-size: 0.92rem; + color: #d6c5f0; + font-family: ui-monospace, SFMono-Regular, monospace; +} + +.progress-bar { + width: 100%; + height: 8px; + background: rgba(255, 255, 255, 0.06); + border-radius: 4px; + overflow: hidden; +} + +.progress-fill { + height: 100%; + background: linear-gradient(90deg, #8b5cf6, #b794f4); + transition: width 0.3s ease; +} + +.progress-text { + font-size: 0.82rem; + color: #aaa0c5; + font-family: ui-monospace, SFMono-Regular, monospace; +} + .page-header { display: flex; align-items: center; diff --git a/web/src/app/settings/settings.component.ts b/web/src/app/settings/settings.component.ts index 6d2309a..fd5384c 100644 --- a/web/src/app/settings/settings.component.ts +++ b/web/src/app/settings/settings.component.ts @@ -2,8 +2,9 @@ import { Component, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { Router } from '@angular/router'; -import { LucideAngularModule, ArrowLeft, RefreshCw, Save, Check, AlertCircle, Download } from 'lucide-angular'; -import { SettingsService, AppSettings, AppSettingsUpdate, OneMinModelGroup } from '../services/settings.service'; +import { LucideAngularModule, ArrowLeft, RefreshCw, Save, Check, AlertCircle, Download, Trash2, Plus, X } from 'lucide-angular'; +import { SettingsService, AppSettings, AppSettingsUpdate, OneMinModelGroup, OllamaPullEvent } from '../services/settings.service'; +import { Subscription } from 'rxjs'; import { UpdatesService, UpdateStatus } from '../services/updates.service'; import { ConfigService } from '../services/config.service'; @@ -33,6 +34,34 @@ export class SettingsComponent implements OnInit { readonly Check = Check; readonly AlertCircle = AlertCircle; readonly Download = Download; + readonly Trash2 = Trash2; + readonly Plus = Plus; + readonly X = X; + + // --- Pull / delete de modeles Ollama --- + /** Dialog d'ajout de modele ouvert/ferme. */ + pullDialogOpen = false; + /** Nom saisi par l'utilisateur dans le dialog. */ + pullModelName = ''; + /** Suggestions courantes affichees dans le dialog. */ + readonly pullSuggestions = [ + 'gemma4:e4b', 'gemma3:4b', 'gemma3:12b', + 'llama3.2:3b', 'llama3.1:8b', + 'mistral:7b', 'qwen2.5:3b', 'qwen2.5:7b' + ]; + /** Pull en cours ; null si aucun. */ + pullInProgress = false; + /** Etape courante affichee a l'utilisateur (ex: "downloading", "verifying"). */ + pullStatus = ''; + /** Bytes telecharges sur le digest courant. */ + pullCompleted = 0; + /** Bytes totaux du digest courant. */ + pullTotal = 0; + /** Souscription au flux de pull pour pouvoir l'annuler. */ + private pullSubscription: Subscription | null = null; + + /** Modele en cours de suppression (nom) pour disabler son bouton. */ + deletingModel: string | null = null; // Mises a jour conteneurs updateStatus: UpdateStatus | null = null; @@ -229,6 +258,119 @@ export class SettingsComponent implements OnInit { }); } + // --- Gestion des modeles Ollama (pull / delete) ------------------------- + + openPullDialog(): void { + this.pullDialogOpen = true; + this.pullModelName = ''; + this.resetPullState(); + } + + closePullDialog(): void { + if (this.pullInProgress) return; // empêche fermeture pendant un pull + this.pullDialogOpen = false; + } + + selectSuggestion(name: string): void { + this.pullModelName = name; + } + + startPull(): void { + const name = this.pullModelName.trim(); + if (!name || this.pullInProgress) return; + this.resetPullState(); + this.pullInProgress = true; + this.pullStatus = 'connexion...'; + this.errorMessage = ''; + + this.pullSubscription = this.settingsService.pullOllamaModel(name).subscribe({ + next: (event: OllamaPullEvent) => { + if (event.error) { + this.errorMessage = `Echec : ${event.error}`; + this.pullInProgress = false; + return; + } + if (event.status) this.pullStatus = event.status; + if (event.completed != null) this.pullCompleted = event.completed; + if (event.total != null) this.pullTotal = event.total; + }, + error: (err) => { + this.errorMessage = this.extractError(err, `Echec du telechargement de ${name}.`); + this.pullInProgress = false; + }, + complete: () => { + this.pullInProgress = false; + this.successMessage = `Modele ${name} telecharge.`; + this.refreshModels(); + // Si l'utilisateur n'avait aucun modele, on selectionne celui-ci. + if (this.settings && !this.settings.llm_model) { + this.settings.llm_model = name; + this.fetchOllamaModelInfo(); + } + // Petite tempo avant de fermer pour que le user voie "success". + setTimeout(() => this.closePullDialog(), 1200); + } + }); + } + + cancelPull(): void { + if (this.pullSubscription) { + this.pullSubscription.unsubscribe(); + this.pullSubscription = null; + } + this.pullInProgress = false; + this.pullStatus = 'annule'; + } + + private resetPullState(): void { + this.pullStatus = ''; + this.pullCompleted = 0; + this.pullTotal = 0; + if (this.pullSubscription) { + this.pullSubscription.unsubscribe(); + this.pullSubscription = null; + } + } + + /** Pourcentage du digest courant pour la barre de progression. */ + get pullPercent(): number { + if (this.pullTotal <= 0) return 0; + return Math.min(100, Math.round((this.pullCompleted / this.pullTotal) * 100)); + } + + /** Affichage humain des octets ('1.2 GB' / '450 MB'). */ + formatBytes(b: number): string { + if (!b) return '0'; + const u = ['B', 'KB', 'MB', 'GB', 'TB']; + let i = 0; + let v = b; + while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; } + return `${v.toFixed(v < 10 && i > 0 ? 1 : 0)} ${u[i]}`; + } + + deleteModel(name: string): void { + if (!confirm(`Supprimer le modele '${name}' ? L'espace disque sera libere.`)) return; + this.deletingModel = name; + this.errorMessage = ''; + this.settingsService.deleteOllamaModel(name).subscribe({ + next: () => { + this.deletingModel = null; + this.successMessage = `Modele ${name} supprime.`; + // Si l'utilisateur supprime le modele actuellement selectionne, + // on bascule sur le premier disponible (ou vide). + this.refreshModels(); + if (this.settings && this.settings.llm_model === name) { + this.settings.llm_model = ''; + this.ollamaModelMaxContext = 0; + } + }, + error: (err) => { + this.deletingModel = null; + this.errorMessage = this.extractError(err, `Echec de la suppression de ${name}.`); + } + }); + } + goBack(): void { this.router.navigate(['/lore']); }