Refactor SRP : decoupage de main.py (Brain) en routers et du SettingsComponent (web)
Brain : main.py (1496 l.) reduit a l'assemblage (~95 l.) ; un router par responsabilite (generation, chat, tables, imports, notebooks, settings, models), factories DI dans api/deps.py, DTOs chat + mapping anti-corruption separes, auto-pull embeddings deplace en infrastructure. Chemins HTTP inchanges. Web : SettingsComponent (729 l.) recentre sur le formulaire (~330 l.) ; sous-composants standalone updates-section (MAJ + licence Patreon + switch canal) et ollama-model-manager (liste/pull/suppression de modeles). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
<!-- Messages locaux a la gestion des modeles (pull / suppression) -->
|
||||
<div *ngIf="errorMessage" class="alert alert-error">
|
||||
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
|
||||
<span>{{ errorMessage }}</span>
|
||||
</div>
|
||||
<div *ngIf="successMessage" class="alert alert-success">
|
||||
<lucide-icon [img]="Check" [size]="16"></lucide-icon>
|
||||
<span>{{ successMessage }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Liste des modeles installes avec bouton supprimer -->
|
||||
<div class="form-row" *ngIf="models.length > 0">
|
||||
<label>Modeles installes</label>
|
||||
<ul class="installed-models">
|
||||
<li *ngFor="let m of models">
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
@@ -0,0 +1,216 @@
|
||||
import { Component, EventEmitter, Input, OnDestroy, Output } from '@angular/core';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { LucideAngularModule, Check, AlertCircle, Download, Trash2, X } from 'lucide-angular';
|
||||
import { SettingsService, OllamaPullEvent } from '../../services/settings.service';
|
||||
import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog.service';
|
||||
|
||||
/**
|
||||
* Gestion des modeles Ollama installes (composant standalone).
|
||||
*
|
||||
* Responsabilite unique : le CYCLE DE VIE des modeles sur le serveur Ollama —
|
||||
* liste des modeles installes + suppression, et dialog de telechargement (pull)
|
||||
* avec barre de progression streamee (NDJSON).
|
||||
*
|
||||
* Le parent (SettingsComponent) garde le CHOIX du modele actif (formulaire) ;
|
||||
* il ecoute `modelsChanged` / `modelPulled` / `modelDeleted` pour rafraichir sa
|
||||
* liste et corriger la selection courante si besoin.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-ollama-model-manager',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, LucideAngularModule],
|
||||
templateUrl: './ollama-model-manager.component.html',
|
||||
// Reutilise la feuille de style de l'ecran Parametres (modal, suggestions,
|
||||
// progress-bar, installed-models) pour un rendu strictement identique.
|
||||
styleUrls: ['../settings.component.scss']
|
||||
})
|
||||
export class OllamaModelManagerComponent implements OnDestroy {
|
||||
|
||||
readonly Check = Check;
|
||||
readonly AlertCircle = AlertCircle;
|
||||
readonly Download = Download;
|
||||
readonly Trash2 = Trash2;
|
||||
readonly X = X;
|
||||
|
||||
/** Modeles actuellement installes (charges par le parent). */
|
||||
@Input() models: string[] = [];
|
||||
|
||||
/** La liste des modeles a change (pull termine / suppression) → recharger. */
|
||||
@Output() modelsChanged = new EventEmitter<void>();
|
||||
/** Un modele vient d'etre telecharge avec succes (nom complet). */
|
||||
@Output() modelPulled = new EventEmitter<string>();
|
||||
/** Un modele vient d'etre supprime (nom complet). */
|
||||
@Output() modelDeleted = new EventEmitter<string>();
|
||||
|
||||
/** 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;
|
||||
|
||||
errorMessage = '';
|
||||
successMessage = '';
|
||||
|
||||
constructor(
|
||||
private settingsService: SettingsService,
|
||||
private confirmDialog: ConfirmDialogService
|
||||
) {}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (this.pullSubscription) {
|
||||
this.pullSubscription.unsubscribe();
|
||||
}
|
||||
}
|
||||
|
||||
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.modelsChanged.emit();
|
||||
return;
|
||||
}
|
||||
this.successMessage = `Modele ${name} telecharge.`;
|
||||
this.modelsChanged.emit();
|
||||
this.modelPulled.emit(name);
|
||||
// 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 {
|
||||
this.confirmDialog.confirm({
|
||||
title: 'Supprimer le modele',
|
||||
message: `Supprimer le modele '${name}' ?`,
|
||||
details: ['L\'espace disque sera libere.'],
|
||||
confirmLabel: 'Supprimer',
|
||||
variant: 'danger'
|
||||
}).then(ok => {
|
||||
if (!ok) return;
|
||||
this.deletingModel = name;
|
||||
this.errorMessage = '';
|
||||
this.settingsService.deleteOllamaModel(name).subscribe({
|
||||
next: () => {
|
||||
this.deletingModel = null;
|
||||
this.successMessage = `Modele ${name} supprime.`;
|
||||
this.modelsChanged.emit();
|
||||
this.modelDeleted.emit(name);
|
||||
},
|
||||
error: (err) => {
|
||||
this.deletingModel = null;
|
||||
this.errorMessage = this.extractError(err, `Echec de la suppression de ${name}.`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private extractError(err: any, fallback: string): string {
|
||||
if (err?.error?.detail) return String(err.error.detail);
|
||||
if (err?.message) return err.message;
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
@@ -68,7 +68,7 @@
|
||||
<lucide-icon [img]="RefreshCw" [size]="14"></lucide-icon>
|
||||
<span>{{ loadingModels ? 'Chargement...' : 'Actualiser' }}</span>
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="openPullDialog()">
|
||||
<button type="button" class="btn-secondary" (click)="modelManager.openPullDialog()">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
<span>Telecharger</span>
|
||||
</button>
|
||||
@@ -76,83 +76,16 @@
|
||||
<p class="hint" *ngIf="ollamaModels.length === 0">Aucun modele detecte. Verifie que Ollama tourne et que l'URL est correcte.</p>
|
||||
</div>
|
||||
|
||||
<!-- Liste des modeles installes avec bouton supprimer -->
|
||||
<div class="form-row" *ngIf="ollamaModels.length > 0">
|
||||
<label>Modeles installes</label>
|
||||
<ul class="installed-models">
|
||||
<li *ngFor="let m of ollamaModels">
|
||||
<span class="model-name">{{ m }}</span>
|
||||
<button type="button" class="btn-icon btn-danger"
|
||||
(click)="deleteModel(m)"
|
||||
[disabled]="deletingModel === m"
|
||||
[title]="'Supprimer ' + m">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<!-- Gestion des modeles installes (liste + suppression + dialog de pull) -->
|
||||
<app-ollama-model-manager
|
||||
#modelManager
|
||||
[models]="ollamaModels"
|
||||
(modelsChanged)="refreshModels()"
|
||||
(modelPulled)="onModelPulled($event)"
|
||||
(modelDeleted)="onModelDeleted($event)">
|
||||
</app-ollama-model-manager>
|
||||
</section>
|
||||
|
||||
<!-- Dialog de telechargement de modele -->
|
||||
<div class="modal-overlay" *ngIf="pullDialogOpen" (click)="closePullDialog()">
|
||||
<div class="modal-content" (click)="$event.stopPropagation()">
|
||||
<header class="modal-header">
|
||||
<h3>Telecharger un modele Ollama</h3>
|
||||
<button type="button" class="btn-icon" (click)="closePullDialog()" [disabled]="pullInProgress" title="Fermer">
|
||||
<lucide-icon [img]="X" [size]="18"></lucide-icon>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="modal-body">
|
||||
<div *ngIf="!pullInProgress">
|
||||
<label for="pull-name">Nom du modele</label>
|
||||
<input id="pull-name" type="text" [(ngModel)]="pullModelName"
|
||||
placeholder="ex: gemma4:e4b" autocomplete="off"
|
||||
(keydown.enter)="startPull()">
|
||||
|
||||
<p class="hint">Suggestions :</p>
|
||||
<div class="suggestions">
|
||||
<button type="button" *ngFor="let s of pullSuggestions"
|
||||
class="suggestion-chip" (click)="selectSuggestion(s)">{{ s }}</button>
|
||||
</div>
|
||||
<p class="hint" style="margin-top: 0.75rem;">
|
||||
La liste complete est sur <a href="https://ollama.com/library" target="_blank" rel="noopener">ollama.com/library</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div *ngIf="pullInProgress" class="pull-progress">
|
||||
<div class="pull-status">{{ pullStatus }}</div>
|
||||
<div class="progress-bar" *ngIf="pullTotal > 0">
|
||||
<div class="progress-fill" [style.width.%]="pullPercent"></div>
|
||||
</div>
|
||||
<div class="progress-text" *ngIf="pullTotal > 0">
|
||||
{{ formatBytes(pullCompleted) }} / {{ formatBytes(pullTotal) }} ({{ pullPercent }}%)
|
||||
</div>
|
||||
<div class="progress-text" *ngIf="pullTotal === 0">
|
||||
Preparation...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="modal-footer">
|
||||
<button type="button" class="btn-secondary"
|
||||
(click)="cancelPull()" *ngIf="pullInProgress">
|
||||
Annuler
|
||||
</button>
|
||||
<button type="button" class="btn-secondary"
|
||||
(click)="closePullDialog()" *ngIf="!pullInProgress">
|
||||
Fermer
|
||||
</button>
|
||||
<button type="button" class="btn-primary"
|
||||
(click)="startPull()"
|
||||
[disabled]="pullInProgress || !pullModelName.trim()" *ngIf="!pullInProgress">
|
||||
<lucide-icon [img]="Download" [size]="14"></lucide-icon>
|
||||
<span>Telecharger</span>
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bloc 1min.ai -->
|
||||
<section class="card" *ngIf="settings && settings.llm_provider === 'onemin'">
|
||||
<h2>Configuration 1min.ai</h2>
|
||||
@@ -434,242 +367,8 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Bloc Mises a jour (canal stable + canal beta Patreon fusionnes) -->
|
||||
<section class="card" *ngIf="config.updateCheckEnabled || licenseStatus?.enabled">
|
||||
<h2>Mises a jour</h2>
|
||||
<p class="hint">Verifie aupres du registry Docker si une nouvelle version
|
||||
des conteneurs (core, brain, web) est disponible. Postgres et MinIO sont
|
||||
exclus — ils sont mis a jour manuellement.</p>
|
||||
|
||||
<!-- ====================================================== -->
|
||||
<!-- Sous-section : canal stable -->
|
||||
<!-- ====================================================== -->
|
||||
<div class="channel-block" *ngIf="config.updateCheckEnabled">
|
||||
<h3 class="channel-title">Canal stable</h3>
|
||||
|
||||
<div class="form-row">
|
||||
<button type="button" class="btn-secondary" (click)="checkUpdates()" [disabled]="updateChecking">
|
||||
<lucide-icon [img]="RefreshCw" [size]="14"></lucide-icon>
|
||||
<span>{{ updateChecking ? 'Verification...' : 'Verifier maintenant' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div *ngIf="updateStatus && !updateStatus.enabled" class="hint">
|
||||
Feature non configuree (WATCHTOWER_TOKEN absent).
|
||||
</div>
|
||||
|
||||
<div *ngIf="updateStatus?.enabled">
|
||||
<div *ngIf="updateStatus?.updateAvailable" class="alert alert-success">
|
||||
<lucide-icon [img]="Download" [size]="16"></lucide-icon>
|
||||
<span>Une mise a jour est disponible.</span>
|
||||
</div>
|
||||
<div *ngIf="updateStatus?.anyUnknown && !updateStatus?.updateAvailable" class="alert alert-warn">
|
||||
<lucide-icon [img]="Download" [size]="16"></lucide-icon>
|
||||
<span>Verification impossible (baseline absente ou registry injoignable).</span>
|
||||
</div>
|
||||
<div *ngIf="!updateStatus?.updateAvailable && !updateStatus?.anyUnknown" class="hint">
|
||||
Tout est a jour (verifie le {{ updateStatus?.checkedAt | date:'short' }}).
|
||||
</div>
|
||||
|
||||
<div class="form-row" *ngIf="updateStatus?.updateAvailable">
|
||||
<button type="button" class="btn-primary" (click)="applyUpdate()" [disabled]="updateApplying">
|
||||
<lucide-icon [img]="Download" [size]="16"></lucide-icon>
|
||||
<span>{{ updateApplying ? 'Mise a jour en cours...' : 'Mettre a jour maintenant' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div *ngIf="updateMessage" class="alert alert-success">
|
||||
<lucide-icon [img]="Check" [size]="16"></lucide-icon>
|
||||
<span>{{ updateMessage }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ====================================================== -->
|
||||
<!-- Sous-section : canal beta (Patreon) -->
|
||||
<!-- ====================================================== -->
|
||||
<div class="channel-block" *ngIf="licenseStatus?.enabled">
|
||||
<h3 class="channel-title">
|
||||
<lucide-icon [img]="Heart" [size]="16"></lucide-icon>
|
||||
Canal beta — reserve aux patrons
|
||||
</h3>
|
||||
<p class="hint">
|
||||
Soutiens LoreMind sur Patreon pour acceder aux nouvelles features en avant-premiere.
|
||||
Le tier <strong>Compagnon</strong> (7€/mois) ou superieur debloque ce canal.
|
||||
</p>
|
||||
|
||||
<!-- Pas de licence installee -->
|
||||
<ng-container *ngIf="licenseStatus?.status === 'NONE'">
|
||||
<div class="form-row">
|
||||
<button type="button" class="btn-primary" (click)="connectPatreon()">
|
||||
<lucide-icon [img]="Link2" [size]="16"></lucide-icon>
|
||||
<span>Connecter mon compte Patreon</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="hint">
|
||||
Une nouvelle fenetre va s'ouvrir vers Patreon. Apres autorisation, copie le token affiche
|
||||
et colle-le ci-dessous.
|
||||
</p>
|
||||
<div class="form-row">
|
||||
<label for="license-jwt">Token Patreon</label>
|
||||
<input
|
||||
id="license-jwt"
|
||||
type="text"
|
||||
[(ngModel)]="licenseJwtInput"
|
||||
placeholder="eyJhbGciOiJFZERTQS..."
|
||||
autocomplete="off"
|
||||
>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<button type="button" class="btn-primary" (click)="installLicense()" [disabled]="!licenseJwtInput.trim()">
|
||||
<lucide-icon [img]="Check" [size]="16"></lucide-icon>
|
||||
<span>Activer la licence</span>
|
||||
</button>
|
||||
</div>
|
||||
<div *ngIf="licenseError" class="alert alert-error">
|
||||
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
|
||||
<span>{{ licenseError }}</span>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<!-- Licence installee (VALID / GRACE / EXPIRED / UNVERIFIABLE) -->
|
||||
<ng-container *ngIf="licenseStatus && licenseStatus.status !== 'NONE'">
|
||||
<div *ngIf="licenseStatus.status === 'VALID'" class="alert alert-success">
|
||||
<lucide-icon [img]="Check" [size]="16"></lucide-icon>
|
||||
<span>Compte Patreon connecte. Tier {{ tierLabel(licenseStatus.tierId) }} actif.</span>
|
||||
</div>
|
||||
<div *ngIf="licenseStatus.status === 'GRACE'" class="alert alert-warn">
|
||||
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
|
||||
<span>
|
||||
Connexion Patreon expiree, mais acces beta maintenu pendant la periode de tolerance.
|
||||
Verifie que ton abonnement Patreon est toujours actif et clique sur "Verifier maintenant".
|
||||
</span>
|
||||
</div>
|
||||
<div *ngIf="licenseStatus.status === 'EXPIRED'" class="alert alert-error">
|
||||
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
|
||||
<span>
|
||||
Connexion Patreon expiree depuis trop longtemps. Reconnecte-toi pour retrouver l'acces beta.
|
||||
</span>
|
||||
</div>
|
||||
<div *ngIf="licenseStatus.status === 'UNVERIFIABLE'" class="alert alert-error">
|
||||
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
|
||||
<span>Le token installe ne peut plus etre verifie. Reconnecte-toi.</span>
|
||||
</div>
|
||||
|
||||
<ul class="license-info">
|
||||
<li *ngIf="licenseStatus.tierId"><strong>Tier :</strong> {{ tierLabel(licenseStatus.tierId) }}</li>
|
||||
<li *ngIf="licenseStatus.expiresAt">
|
||||
<strong>Validite :</strong>
|
||||
jusqu'au {{ formatDate(licenseStatus.expiresAt) }}
|
||||
<span *ngIf="daysUntilExpiry !== null && daysUntilExpiry > 0">
|
||||
(renouvellement dans {{ daysUntilExpiry }} jour<span *ngIf="daysUntilExpiry > 1">s</span>)
|
||||
</span>
|
||||
</li>
|
||||
<li *ngIf="licenseStatus.lastRefreshAttemptAt">
|
||||
<strong>Dernier refresh :</strong>
|
||||
{{ formatDate(licenseStatus.lastRefreshAttemptAt) }}
|
||||
<span *ngIf="licenseStatus.lastRefreshSucceeded === true" class="badge-ok">OK</span>
|
||||
<span *ngIf="licenseStatus.lastRefreshSucceeded === false" class="badge-warn">echec</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="form-row form-row-inline">
|
||||
<label class="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="licenseStatus.betaChannelEnabled"
|
||||
(change)="toggleBetaChannel(!licenseStatus.betaChannelEnabled)"
|
||||
[disabled]="licenseStatus.status !== 'VALID' && licenseStatus.status !== 'GRACE'"
|
||||
>
|
||||
<span>Activer le canal beta</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-row form-row-actions">
|
||||
<button type="button" class="btn-secondary" (click)="refreshLicense()" [disabled]="licenseLoading">
|
||||
<lucide-icon [img]="RefreshCw" [size]="14"></lucide-icon>
|
||||
<span>{{ licenseLoading ? 'Verification...' : 'Verifier maintenant' }}</span>
|
||||
</button>
|
||||
<button type="button" class="btn-secondary btn-danger" (click)="disconnectPatreon()">
|
||||
<lucide-icon [img]="Unlink" [size]="14"></lucide-icon>
|
||||
<span>Deconnecter Patreon</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Etat du canal beta -->
|
||||
<div *ngIf="licenseStatus.betaChannelEnabled" class="beta-status">
|
||||
<div *ngIf="betaChecking" class="hint">Verification des images beta...</div>
|
||||
<div *ngIf="!betaChecking && betaStatus && !betaStatus.enabled" class="hint">
|
||||
Indisponible : {{ betaStatus.disabledReason }}
|
||||
</div>
|
||||
<div *ngIf="!betaChecking && betaStatus?.enabled">
|
||||
<div *ngIf="betaStatus?.anyUnknown" class="alert alert-warn">
|
||||
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
|
||||
<span>Verification beta impossible (registry beta injoignable ou baseline absente).</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bascule de canal (stable <-> beta) via sidecar switcher -->
|
||||
<div class="channel-switch" *ngIf="channelStatus">
|
||||
<div class="channel-current">
|
||||
<span class="channel-label">Canal actuel :</span>
|
||||
<span class="channel-badge"
|
||||
[class.channel-stable]="channelStatus.currentChannel === 'stable'"
|
||||
[class.channel-beta]="channelStatus.currentChannel === 'beta'">
|
||||
{{ channelStatus.currentChannel === 'beta' ? 'Bêta' : 'Stable' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Sidecar dispo : boutons d'action -->
|
||||
<ng-container *ngIf="channelStatus.switcherAvailable">
|
||||
<!-- On stable -> proposer passage beta (uniquement si licence active) -->
|
||||
<button *ngIf="channelStatus.currentChannel === 'stable'"
|
||||
type="button" class="btn-primary"
|
||||
[disabled]="switchInFlight"
|
||||
(click)="requestChannelSwitch('beta')">
|
||||
<lucide-icon [img]="Download" [size]="14"></lucide-icon>
|
||||
<span>{{ switchInFlight ? 'Bascule en cours...' : 'Passer sur le canal beta' }}</span>
|
||||
</button>
|
||||
|
||||
<!-- On beta -> proposer retour stable -->
|
||||
<button *ngIf="channelStatus.currentChannel === 'beta'"
|
||||
type="button" class="btn-secondary"
|
||||
[disabled]="switchInFlight"
|
||||
(click)="requestChannelSwitch('stable')">
|
||||
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
|
||||
<span>{{ switchInFlight ? 'Bascule en cours...' : 'Repasser sur le canal stable' }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Switch en cours : on prévient que la page va se rendre injoignable -->
|
||||
<div *ngIf="switchInFlight" class="alert alert-warn">
|
||||
<lucide-icon [img]="RefreshCw" [size]="16"></lucide-icon>
|
||||
<span>Bascule en cours. L'application va etre indisponible 10 a 30 secondes — la page se rechargera automatiquement quand le nouveau Core sera pret.</span>
|
||||
</div>
|
||||
|
||||
<!-- Erreur eventuelle remontee par le sidecar -->
|
||||
<div *ngIf="switchError && !switchInFlight" class="alert alert-error">
|
||||
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
|
||||
<span>{{ switchError }}</span>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<!-- Sidecar PAS dispo : fallback instructions manuelles (vieilles installs) -->
|
||||
<div *ngIf="!channelStatus.switcherAvailable" class="alert alert-warn">
|
||||
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
|
||||
<span>
|
||||
Le sidecar de bascule n'est pas installe. Pour beneficier du switch
|
||||
automatique, recupere le dernier <code>docker-compose.yml</code> du repo
|
||||
et fais <code>docker compose pull && docker compose up -d</code> une
|
||||
fois. Sinon, bascule manuellement en editant <code>IMAGE_NAMESPACE</code>
|
||||
dans ton <code>.env</code> (<code>igmlcreation/loremind-</code> pour stable,
|
||||
<code>igmlcreation/loremind-beta-</code> pour beta).
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Mises a jour conteneurs + licence Patreon + canal beta (sous-composant) -->
|
||||
<app-settings-updates-section></app-settings-updates-section>
|
||||
|
||||
<div class="actions" *ngIf="settings">
|
||||
<button class="btn-primary" (click)="save()" [disabled]="saving">
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { interval, switchMap, Subscription } from 'rxjs';
|
||||
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, Trash2, Plus, X, Heart, Link2, Unlink } from 'lucide-angular';
|
||||
import { SettingsService, AppSettings, AppSettingsUpdate, OneMinModelGroup, OpenRouterModel, MistralModel, GeminiModel, OllamaPullEvent } from '../services/settings.service';
|
||||
import { UpdatesService, UpdateStatus } from '../services/updates.service';
|
||||
import { ConfigService } from '../services/config.service';
|
||||
import { LucideAngularModule, ArrowLeft, RefreshCw, Save, Check, AlertCircle, Plus } from 'lucide-angular';
|
||||
import { SettingsService, AppSettings, AppSettingsUpdate, OneMinModelGroup, OpenRouterModel, MistralModel, GeminiModel } from '../services/settings.service';
|
||||
import { LayoutService } from '../services/layout.service';
|
||||
import { LicenseService, LicenseStatusDTO, BetaStatusDTO, ChannelStatusDTO, ChannelName } from '../services/license.service';
|
||||
import { ConfirmDialogService } from '../shared/confirm-dialog/confirm-dialog.service';
|
||||
import { UpdatesSectionComponent } from './updates-section/updates-section.component';
|
||||
import { OllamaModelManagerComponent } from './ollama-model-manager/ollama-model-manager.component';
|
||||
|
||||
/**
|
||||
* Ecran de parametrage du LLM utilise par le Brain.
|
||||
*
|
||||
* Deux providers au choix :
|
||||
* - Ollama (local) : on liste dynamiquement les modeles installes.
|
||||
* - 1min.ai (cloud) : on fournit une cle API + on choisit dans un catalogue fixe.
|
||||
* Responsabilite : le FORMULAIRE de configuration (fournisseur LLM, modeles,
|
||||
* cles API, embeddings RAG, fenetre de contexte, import PDF) + sa sauvegarde.
|
||||
*
|
||||
* Les responsabilites voisines sont des sous-composants standalone :
|
||||
* - UpdatesSectionComponent : mises a jour conteneurs + licence Patreon + canal beta ;
|
||||
* - OllamaModelManagerComponent : pull/suppression des modeles Ollama.
|
||||
*
|
||||
* Les modifications sont persistees cote Brain dans data/settings.json
|
||||
* (fichier local, usage mono-utilisateur) et appliquees a la prochaine
|
||||
@@ -25,80 +25,18 @@ import { ConfirmDialogService } from '../shared/confirm-dialog/confirm-dialog.se
|
||||
@Component({
|
||||
selector: 'app-settings',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, LucideAngularModule],
|
||||
imports: [CommonModule, FormsModule, LucideAngularModule, UpdatesSectionComponent, OllamaModelManagerComponent],
|
||||
templateUrl: './settings.component.html',
|
||||
styleUrls: ['./settings.component.scss']
|
||||
})
|
||||
export class SettingsComponent implements OnInit, OnDestroy {
|
||||
export class SettingsComponent implements OnInit {
|
||||
|
||||
readonly ArrowLeft = ArrowLeft;
|
||||
readonly RefreshCw = RefreshCw;
|
||||
readonly Save = Save;
|
||||
readonly Check = Check;
|
||||
readonly AlertCircle = AlertCircle;
|
||||
readonly Download = Download;
|
||||
readonly Trash2 = Trash2;
|
||||
readonly Plus = Plus;
|
||||
readonly X = X;
|
||||
readonly Heart = Heart;
|
||||
readonly Link2 = Link2;
|
||||
readonly Unlink = Unlink;
|
||||
|
||||
// --- Licence Patreon (canal beta) ---
|
||||
licenseStatus: LicenseStatusDTO | null = null;
|
||||
licenseLoading = false;
|
||||
licenseError = '';
|
||||
/** Token JWT colle par l'utilisateur apres OAuth. */
|
||||
licenseJwtInput = '';
|
||||
/** Etat du canal beta (digests des images privees). */
|
||||
betaStatus: BetaStatusDTO | null = null;
|
||||
betaChecking = false;
|
||||
|
||||
// --- Bascule de canal stable <-> beta via sidecar switcher ---
|
||||
channelStatus: ChannelStatusDTO | null = null;
|
||||
/** True pendant le polling apres clic. Bloque les boutons. */
|
||||
switchInFlight = false;
|
||||
/** ID de la commande de switch en cours, pour ignorer les vieux resultats. */
|
||||
private switchCommandId: string | null = null;
|
||||
/** Subscription du polling pour pouvoir l'arreter. */
|
||||
private switchPollSub: Subscription | null = null;
|
||||
/** Erreur affichee si le switch a echoue. */
|
||||
switchError = '';
|
||||
|
||||
// --- 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
|
||||
updateStatus: UpdateStatus | null = null;
|
||||
updateChecking = false;
|
||||
updateApplying = false;
|
||||
updateMessage = '';
|
||||
|
||||
settings: AppSettings | null = null;
|
||||
ollamaModels: string[] = [];
|
||||
@@ -156,10 +94,6 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
constructor(
|
||||
private settingsService: SettingsService,
|
||||
private router: Router,
|
||||
private updatesService: UpdatesService,
|
||||
public config: ConfigService,
|
||||
private licenseService: LicenseService,
|
||||
private confirmDialog: ConfirmDialogService,
|
||||
private layoutService: LayoutService
|
||||
) {}
|
||||
|
||||
@@ -168,290 +102,6 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
// section precedente (cf. fix CampaignsComponent / LoreComponent).
|
||||
this.layoutService.hide();
|
||||
this.loadSettings();
|
||||
if (this.config.updateCheckEnabled) {
|
||||
this.checkUpdates();
|
||||
}
|
||||
this.loadLicense();
|
||||
this.loadChannelStatus();
|
||||
}
|
||||
|
||||
// --- Licence Patreon ---------------------------------------------------
|
||||
|
||||
loadLicense(): void {
|
||||
this.licenseLoading = true;
|
||||
this.licenseService.getStatus().subscribe({
|
||||
next: (s) => {
|
||||
this.licenseStatus = s;
|
||||
this.licenseLoading = false;
|
||||
if (s?.enabled && (s.status === 'VALID' || s.status === 'GRACE') && s.betaChannelEnabled) {
|
||||
this.checkBeta();
|
||||
}
|
||||
},
|
||||
error: () => { this.licenseLoading = false; }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ouvre la page OAuth Patreon dans une nouvelle fenetre.
|
||||
* L'utilisateur copie ensuite le JWT et le colle dans l'input ci-dessous.
|
||||
*/
|
||||
connectPatreon(): void {
|
||||
this.licenseError = '';
|
||||
this.licenseService.getConnectUrl().subscribe({
|
||||
next: (r) => {
|
||||
if (!r?.url) {
|
||||
this.licenseError = 'Impossible de generer l\'URL de connexion. Verifie ta config.';
|
||||
return;
|
||||
}
|
||||
window.open(r.url, '_blank', 'noopener');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
installLicense(): void {
|
||||
const jwt = this.licenseJwtInput.trim();
|
||||
if (!jwt) {
|
||||
this.licenseError = 'Colle d\'abord le token recu apres connexion Patreon.';
|
||||
return;
|
||||
}
|
||||
this.licenseError = '';
|
||||
this.licenseService.install(jwt).subscribe((res) => {
|
||||
if ((res as any)?.error) {
|
||||
this.licenseError = (res as any).error;
|
||||
return;
|
||||
}
|
||||
this.licenseStatus = res as LicenseStatusDTO;
|
||||
this.licenseJwtInput = '';
|
||||
this.successMessage = 'Compte Patreon connecte. L\'acces beta est actif.';
|
||||
if (this.licenseStatus.betaChannelEnabled) {
|
||||
this.checkBeta();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
refreshLicense(): void {
|
||||
this.licenseLoading = true;
|
||||
this.licenseService.refresh().subscribe({
|
||||
next: (s) => {
|
||||
this.licenseStatus = s;
|
||||
this.licenseLoading = false;
|
||||
},
|
||||
error: () => { this.licenseLoading = false; }
|
||||
});
|
||||
}
|
||||
|
||||
disconnectPatreon(): void {
|
||||
this.confirmDialog.confirm({
|
||||
title: 'Deconnecter Patreon',
|
||||
message: 'Deconnecter ton compte Patreon ?',
|
||||
details: ['Tu perdras l\'acces au canal beta.'],
|
||||
confirmLabel: 'Deconnecter',
|
||||
variant: 'warning'
|
||||
}).then(ok => {
|
||||
if (!ok) return;
|
||||
this.licenseService.disconnect().subscribe(() => {
|
||||
this.licenseStatus = null;
|
||||
this.betaStatus = null;
|
||||
this.successMessage = 'Compte Patreon deconnecte.';
|
||||
this.loadLicense();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
toggleBetaChannel(enabled: boolean): void {
|
||||
this.licenseService.setBetaChannel(enabled).subscribe({
|
||||
next: (s) => {
|
||||
if (s) this.licenseStatus = s;
|
||||
if (enabled) this.checkBeta();
|
||||
else this.betaStatus = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
checkBeta(): void {
|
||||
this.betaChecking = true;
|
||||
this.licenseService.checkBeta().subscribe({
|
||||
next: (s) => {
|
||||
this.betaStatus = s;
|
||||
this.betaChecking = false;
|
||||
},
|
||||
error: () => { this.betaChecking = false; }
|
||||
});
|
||||
}
|
||||
|
||||
// --- Bascule de canal stable <-> beta --------------------------------------
|
||||
|
||||
loadChannelStatus(): void {
|
||||
this.licenseService.getChannelStatus().subscribe({
|
||||
next: (s) => {
|
||||
this.channelStatus = s;
|
||||
// Si on revient sur l'ecran apres un reload (post-switch reussi),
|
||||
// on affiche le dernier resultat eventuel jusqu'a interaction utilisateur.
|
||||
},
|
||||
error: () => { this.channelStatus = null; }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Declenche un switch de canal. La sequence cote UI :
|
||||
* 1. Confirm modal (action destructrice : recreate des containers)
|
||||
* 2. POST /api/license/channel/switch -> 202 avec l'ID de la commande
|
||||
* 3. Polling /api/license/channel toutes les 2s jusqu'a status != IN_PROGRESS
|
||||
* 4. Si SUCCESS : la page va se rendre injoignable (Core recree). On affiche
|
||||
* "Recharge la page dans quelques secondes" et on essaie de poll quand
|
||||
* meme — au retour de Core, on detectera SUCCESS et on rechargera auto.
|
||||
* 5. Si ERROR : on affiche le message d'erreur et on debloque les boutons.
|
||||
*/
|
||||
requestChannelSwitch(target: ChannelName): void {
|
||||
const confirmMessage = target === 'beta'
|
||||
? 'Basculer LoreMind sur le canal beta ? Les containers core/brain/web vont etre recrees avec les images beta. L\'application sera indisponible 10-30 secondes.'
|
||||
: 'Repasser LoreMind sur le canal stable ? Les containers core/brain/web vont etre recrees avec les images stables. L\'application sera indisponible 10-30 secondes.';
|
||||
|
||||
this.confirmDialog.confirm({
|
||||
title: target === 'beta' ? 'Passer en beta ?' : 'Repasser en stable ?',
|
||||
message: confirmMessage,
|
||||
details: [
|
||||
'Les donnees (DB, images) sont preservees.',
|
||||
'Tu pourras refaire le chemin inverse a tout moment depuis cet ecran.'
|
||||
],
|
||||
confirmLabel: target === 'beta' ? 'Passer en beta' : 'Repasser en stable',
|
||||
variant: 'warning'
|
||||
}).then(ok => {
|
||||
if (!ok) return;
|
||||
this.doChannelSwitch(target);
|
||||
});
|
||||
}
|
||||
|
||||
private doChannelSwitch(target: ChannelName): void {
|
||||
this.switchInFlight = true;
|
||||
this.switchError = '';
|
||||
this.licenseService.switchChannel(target).subscribe((res) => {
|
||||
if ('error' in res) {
|
||||
this.switchError = res.error;
|
||||
this.switchInFlight = false;
|
||||
return;
|
||||
}
|
||||
this.switchCommandId = res.id;
|
||||
this.startSwitchPolling();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll /api/license/channel toutes les 2s. S'arrete quand on detecte un
|
||||
* resultat avec un ID >= a celui qu'on a soumis (le sidecar le met a jour
|
||||
* a la fin de son traitement).
|
||||
*/
|
||||
private startSwitchPolling(): void {
|
||||
this.stopSwitchPolling();
|
||||
this.switchPollSub = interval(2000).pipe(
|
||||
switchMap(() => this.licenseService.getChannelStatus())
|
||||
).subscribe((status) => {
|
||||
if (!status) return;
|
||||
this.channelStatus = status;
|
||||
const last = status.lastSwitch;
|
||||
if (!last || last.id !== this.switchCommandId) return;
|
||||
if (last.status === 'SUCCESS') {
|
||||
// La page va se rafraichir auto via l'update-banner qui detecte le
|
||||
// restart de Core. On laisse switchInFlight a true pour bloquer
|
||||
// toute autre action en attendant.
|
||||
this.stopSwitchPolling();
|
||||
this.switchInFlight = false;
|
||||
} else if (last.status === 'ERROR') {
|
||||
this.switchError = last.message || 'Echec du switch';
|
||||
this.stopSwitchPolling();
|
||||
this.switchInFlight = false;
|
||||
}
|
||||
// IN_PROGRESS : on continue a poll.
|
||||
});
|
||||
}
|
||||
|
||||
private stopSwitchPolling(): void {
|
||||
if (this.switchPollSub) {
|
||||
this.switchPollSub.unsubscribe();
|
||||
this.switchPollSub = null;
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.stopSwitchPolling();
|
||||
if (this.pullSubscription) {
|
||||
this.pullSubscription.unsubscribe();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapping tier_id Patreon → nom lisible. Les IDs viennent du dashboard
|
||||
* Patreon de LoreMind (Settings -> Tiers). Sans entree dans la map, on
|
||||
* affiche l'ID brut pour rester debuggable.
|
||||
*
|
||||
* Si tu ajoutes un nouveau tier Patreon, complete cette map et redeploie.
|
||||
* (Pas besoin de toucher au backend — c'est juste un libelle d'UI.)
|
||||
*/
|
||||
private static readonly TIER_LABELS: Record<string, string> = {
|
||||
'28448887': 'Compagnon',
|
||||
// '0000000': 'Aventurier',
|
||||
// '0000000': 'Heros',
|
||||
};
|
||||
|
||||
/** Libelle lisible d'un tier Patreon, fallback sur l'ID brut. */
|
||||
tierLabel(tierId: string | null | undefined): string {
|
||||
if (!tierId) return '';
|
||||
return SettingsComponent.TIER_LABELS[tierId] ?? tierId;
|
||||
}
|
||||
|
||||
/** Format human-readable des dates renvoyees par le backend. */
|
||||
formatDate(iso: string | null | undefined): string {
|
||||
if (!iso) return '';
|
||||
try { return new Date(iso).toLocaleString(); } catch { return iso; }
|
||||
}
|
||||
|
||||
/** Nombre de jours restants avant expiration JWT (peut etre negatif). */
|
||||
get daysUntilExpiry(): number | null {
|
||||
if (!this.licenseStatus?.expiresAt) return null;
|
||||
const exp = new Date(this.licenseStatus.expiresAt).getTime();
|
||||
const now = Date.now();
|
||||
return Math.ceil((exp - now) / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
checkUpdates(): void {
|
||||
this.updateChecking = true;
|
||||
this.updateMessage = '';
|
||||
this.updatesService.checkNow().subscribe({
|
||||
next: (s) => {
|
||||
this.updateStatus = s;
|
||||
this.updateChecking = false;
|
||||
},
|
||||
error: () => {
|
||||
this.updateChecking = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
applyUpdate(): void {
|
||||
this.confirmDialog.confirm({
|
||||
title: 'Mettre a jour',
|
||||
message: 'Telecharger et redemarrer les conteneurs maintenant ?',
|
||||
details: ['L\'app sera indisponible quelques secondes.'],
|
||||
confirmLabel: 'Mettre à jour',
|
||||
variant: 'warning'
|
||||
}).then(ok => {
|
||||
if (!ok) return;
|
||||
this.updateApplying = true;
|
||||
this.updateMessage = '';
|
||||
this.updatesService.apply().subscribe({
|
||||
next: (r) => {
|
||||
this.updateApplying = false;
|
||||
// Le redemarrage de core peut couper la connexion avant la reponse —
|
||||
// dans ce cas r vaut null (gere par catchError dans le service).
|
||||
this.updateMessage = r?.message
|
||||
?? 'Mise a jour declenchee. Rechargez la page dans 30s.';
|
||||
},
|
||||
error: () => {
|
||||
this.updateApplying = false;
|
||||
this.updateMessage = 'Mise a jour declenchee. Rechargez la page dans 30s.';
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
loadSettings(): void {
|
||||
@@ -499,6 +149,24 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
// --- Evenements du gestionnaire de modeles Ollama (sous-composant) -------
|
||||
|
||||
/** Un modele vient d'etre telecharge : si aucun modele n'etait selectionne, on le prend. */
|
||||
onModelPulled(name: string): void {
|
||||
if (this.settings && !this.settings.llm_model) {
|
||||
this.settings.llm_model = name;
|
||||
this.fetchOllamaModelInfo();
|
||||
}
|
||||
}
|
||||
|
||||
/** Le modele selectionne a ete supprime : on vide la selection. */
|
||||
onModelDeleted(name: string): void {
|
||||
if (this.settings && this.settings.llm_model === name) {
|
||||
this.settings.llm_model = '';
|
||||
this.ollamaModelMaxContext = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** Options du select Mistral : liste + valeur courante garantie selectionnable. */
|
||||
get mistralSelectOptions(): { id: string; label: string }[] {
|
||||
const options: { id: string; label: string }[] =
|
||||
@@ -658,139 +326,6 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
// --- 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 {
|
||||
this.confirmDialog.confirm({
|
||||
title: 'Supprimer le modele',
|
||||
message: `Supprimer le modele '${name}' ?`,
|
||||
details: ['L\'espace disque sera libere.'],
|
||||
confirmLabel: 'Supprimer',
|
||||
variant: 'danger'
|
||||
}).then(ok => {
|
||||
if (!ok) 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']);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
<!-- Bloc Mises a jour (canal stable + canal beta Patreon fusionnes) -->
|
||||
<section class="card" *ngIf="config.updateCheckEnabled || licenseStatus?.enabled">
|
||||
<h2>Mises a jour</h2>
|
||||
<p class="hint">Verifie aupres du registry Docker si une nouvelle version
|
||||
des conteneurs (core, brain, web) est disponible. Postgres et MinIO sont
|
||||
exclus — ils sont mis a jour manuellement.</p>
|
||||
|
||||
<div *ngIf="licenseSuccess" class="alert alert-success">
|
||||
<lucide-icon [img]="Check" [size]="16"></lucide-icon>
|
||||
<span>{{ licenseSuccess }}</span>
|
||||
</div>
|
||||
|
||||
<!-- ====================================================== -->
|
||||
<!-- Sous-section : canal stable -->
|
||||
<!-- ====================================================== -->
|
||||
<div class="channel-block" *ngIf="config.updateCheckEnabled">
|
||||
<h3 class="channel-title">Canal stable</h3>
|
||||
|
||||
<div class="form-row">
|
||||
<button type="button" class="btn-secondary" (click)="checkUpdates()" [disabled]="updateChecking">
|
||||
<lucide-icon [img]="RefreshCw" [size]="14"></lucide-icon>
|
||||
<span>{{ updateChecking ? 'Verification...' : 'Verifier maintenant' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div *ngIf="updateStatus && !updateStatus.enabled" class="hint">
|
||||
Feature non configuree (WATCHTOWER_TOKEN absent).
|
||||
</div>
|
||||
|
||||
<div *ngIf="updateStatus?.enabled">
|
||||
<div *ngIf="updateStatus?.updateAvailable" class="alert alert-success">
|
||||
<lucide-icon [img]="Download" [size]="16"></lucide-icon>
|
||||
<span>Une mise a jour est disponible.</span>
|
||||
</div>
|
||||
<div *ngIf="updateStatus?.anyUnknown && !updateStatus?.updateAvailable" class="alert alert-warn">
|
||||
<lucide-icon [img]="Download" [size]="16"></lucide-icon>
|
||||
<span>Verification impossible (baseline absente ou registry injoignable).</span>
|
||||
</div>
|
||||
<div *ngIf="!updateStatus?.updateAvailable && !updateStatus?.anyUnknown" class="hint">
|
||||
Tout est a jour (verifie le {{ updateStatus?.checkedAt | date:'short' }}).
|
||||
</div>
|
||||
|
||||
<div class="form-row" *ngIf="updateStatus?.updateAvailable">
|
||||
<button type="button" class="btn-primary" (click)="applyUpdate()" [disabled]="updateApplying">
|
||||
<lucide-icon [img]="Download" [size]="16"></lucide-icon>
|
||||
<span>{{ updateApplying ? 'Mise a jour en cours...' : 'Mettre a jour maintenant' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div *ngIf="updateMessage" class="alert alert-success">
|
||||
<lucide-icon [img]="Check" [size]="16"></lucide-icon>
|
||||
<span>{{ updateMessage }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ====================================================== -->
|
||||
<!-- Sous-section : canal beta (Patreon) -->
|
||||
<!-- ====================================================== -->
|
||||
<div class="channel-block" *ngIf="licenseStatus?.enabled">
|
||||
<h3 class="channel-title">
|
||||
<lucide-icon [img]="Heart" [size]="16"></lucide-icon>
|
||||
Canal beta — reserve aux patrons
|
||||
</h3>
|
||||
<p class="hint">
|
||||
Soutiens LoreMind sur Patreon pour acceder aux nouvelles features en avant-premiere.
|
||||
Le tier <strong>Compagnon</strong> (7€/mois) ou superieur debloque ce canal.
|
||||
</p>
|
||||
|
||||
<!-- Pas de licence installee -->
|
||||
<ng-container *ngIf="licenseStatus?.status === 'NONE'">
|
||||
<div class="form-row">
|
||||
<button type="button" class="btn-primary" (click)="connectPatreon()">
|
||||
<lucide-icon [img]="Link2" [size]="16"></lucide-icon>
|
||||
<span>Connecter mon compte Patreon</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="hint">
|
||||
Une nouvelle fenetre va s'ouvrir vers Patreon. Apres autorisation, copie le token affiche
|
||||
et colle-le ci-dessous.
|
||||
</p>
|
||||
<div class="form-row">
|
||||
<label for="license-jwt">Token Patreon</label>
|
||||
<input
|
||||
id="license-jwt"
|
||||
type="text"
|
||||
[(ngModel)]="licenseJwtInput"
|
||||
placeholder="eyJhbGciOiJFZERTQS..."
|
||||
autocomplete="off"
|
||||
>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<button type="button" class="btn-primary" (click)="installLicense()" [disabled]="!licenseJwtInput.trim()">
|
||||
<lucide-icon [img]="Check" [size]="16"></lucide-icon>
|
||||
<span>Activer la licence</span>
|
||||
</button>
|
||||
</div>
|
||||
<div *ngIf="licenseError" class="alert alert-error">
|
||||
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
|
||||
<span>{{ licenseError }}</span>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<!-- Licence installee (VALID / GRACE / EXPIRED / UNVERIFIABLE) -->
|
||||
<ng-container *ngIf="licenseStatus && licenseStatus.status !== 'NONE'">
|
||||
<div *ngIf="licenseStatus.status === 'VALID'" class="alert alert-success">
|
||||
<lucide-icon [img]="Check" [size]="16"></lucide-icon>
|
||||
<span>Compte Patreon connecte. Tier {{ tierLabel(licenseStatus.tierId) }} actif.</span>
|
||||
</div>
|
||||
<div *ngIf="licenseStatus.status === 'GRACE'" class="alert alert-warn">
|
||||
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
|
||||
<span>
|
||||
Connexion Patreon expiree, mais acces beta maintenu pendant la periode de tolerance.
|
||||
Verifie que ton abonnement Patreon est toujours actif et clique sur "Verifier maintenant".
|
||||
</span>
|
||||
</div>
|
||||
<div *ngIf="licenseStatus.status === 'EXPIRED'" class="alert alert-error">
|
||||
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
|
||||
<span>
|
||||
Connexion Patreon expiree depuis trop longtemps. Reconnecte-toi pour retrouver l'acces beta.
|
||||
</span>
|
||||
</div>
|
||||
<div *ngIf="licenseStatus.status === 'UNVERIFIABLE'" class="alert alert-error">
|
||||
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
|
||||
<span>Le token installe ne peut plus etre verifie. Reconnecte-toi.</span>
|
||||
</div>
|
||||
|
||||
<ul class="license-info">
|
||||
<li *ngIf="licenseStatus.tierId"><strong>Tier :</strong> {{ tierLabel(licenseStatus.tierId) }}</li>
|
||||
<li *ngIf="licenseStatus.expiresAt">
|
||||
<strong>Validite :</strong>
|
||||
jusqu'au {{ formatDate(licenseStatus.expiresAt) }}
|
||||
<span *ngIf="daysUntilExpiry !== null && daysUntilExpiry > 0">
|
||||
(renouvellement dans {{ daysUntilExpiry }} jour<span *ngIf="daysUntilExpiry > 1">s</span>)
|
||||
</span>
|
||||
</li>
|
||||
<li *ngIf="licenseStatus.lastRefreshAttemptAt">
|
||||
<strong>Dernier refresh :</strong>
|
||||
{{ formatDate(licenseStatus.lastRefreshAttemptAt) }}
|
||||
<span *ngIf="licenseStatus.lastRefreshSucceeded === true" class="badge-ok">OK</span>
|
||||
<span *ngIf="licenseStatus.lastRefreshSucceeded === false" class="badge-warn">echec</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="form-row form-row-inline">
|
||||
<label class="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="licenseStatus.betaChannelEnabled"
|
||||
(change)="toggleBetaChannel(!licenseStatus.betaChannelEnabled)"
|
||||
[disabled]="licenseStatus.status !== 'VALID' && licenseStatus.status !== 'GRACE'"
|
||||
>
|
||||
<span>Activer le canal beta</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-row form-row-actions">
|
||||
<button type="button" class="btn-secondary" (click)="refreshLicense()" [disabled]="licenseLoading">
|
||||
<lucide-icon [img]="RefreshCw" [size]="14"></lucide-icon>
|
||||
<span>{{ licenseLoading ? 'Verification...' : 'Verifier maintenant' }}</span>
|
||||
</button>
|
||||
<button type="button" class="btn-secondary btn-danger" (click)="disconnectPatreon()">
|
||||
<lucide-icon [img]="Unlink" [size]="14"></lucide-icon>
|
||||
<span>Deconnecter Patreon</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Etat du canal beta -->
|
||||
<div *ngIf="licenseStatus.betaChannelEnabled" class="beta-status">
|
||||
<div *ngIf="betaChecking" class="hint">Verification des images beta...</div>
|
||||
<div *ngIf="!betaChecking && betaStatus && !betaStatus.enabled" class="hint">
|
||||
Indisponible : {{ betaStatus.disabledReason }}
|
||||
</div>
|
||||
<div *ngIf="!betaChecking && betaStatus?.enabled">
|
||||
<div *ngIf="betaStatus?.anyUnknown" class="alert alert-warn">
|
||||
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
|
||||
<span>Verification beta impossible (registry beta injoignable ou baseline absente).</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bascule de canal (stable <-> beta) via sidecar switcher -->
|
||||
<div class="channel-switch" *ngIf="channelStatus">
|
||||
<div class="channel-current">
|
||||
<span class="channel-label">Canal actuel :</span>
|
||||
<span class="channel-badge"
|
||||
[class.channel-stable]="channelStatus.currentChannel === 'stable'"
|
||||
[class.channel-beta]="channelStatus.currentChannel === 'beta'">
|
||||
{{ channelStatus.currentChannel === 'beta' ? 'Bêta' : 'Stable' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Sidecar dispo : boutons d'action -->
|
||||
<ng-container *ngIf="channelStatus.switcherAvailable">
|
||||
<!-- On stable -> proposer passage beta (uniquement si licence active) -->
|
||||
<button *ngIf="channelStatus.currentChannel === 'stable'"
|
||||
type="button" class="btn-primary"
|
||||
[disabled]="switchInFlight"
|
||||
(click)="requestChannelSwitch('beta')">
|
||||
<lucide-icon [img]="Download" [size]="14"></lucide-icon>
|
||||
<span>{{ switchInFlight ? 'Bascule en cours...' : 'Passer sur le canal beta' }}</span>
|
||||
</button>
|
||||
|
||||
<!-- On beta -> proposer retour stable -->
|
||||
<button *ngIf="channelStatus.currentChannel === 'beta'"
|
||||
type="button" class="btn-secondary"
|
||||
[disabled]="switchInFlight"
|
||||
(click)="requestChannelSwitch('stable')">
|
||||
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
|
||||
<span>{{ switchInFlight ? 'Bascule en cours...' : 'Repasser sur le canal stable' }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Switch en cours : on prévient que la page va se rendre injoignable -->
|
||||
<div *ngIf="switchInFlight" class="alert alert-warn">
|
||||
<lucide-icon [img]="RefreshCw" [size]="16"></lucide-icon>
|
||||
<span>Bascule en cours. L'application va etre indisponible 10 a 30 secondes — la page se rechargera automatiquement quand le nouveau Core sera pret.</span>
|
||||
</div>
|
||||
|
||||
<!-- Erreur eventuelle remontee par le sidecar -->
|
||||
<div *ngIf="switchError && !switchInFlight" class="alert alert-error">
|
||||
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
|
||||
<span>{{ switchError }}</span>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<!-- Sidecar PAS dispo : fallback instructions manuelles (vieilles installs) -->
|
||||
<div *ngIf="!channelStatus.switcherAvailable" class="alert alert-warn">
|
||||
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
|
||||
<span>
|
||||
Le sidecar de bascule n'est pas installe. Pour beneficier du switch
|
||||
automatique, recupere le dernier <code>docker-compose.yml</code> du repo
|
||||
et fais <code>docker compose pull && docker compose up -d</code> une
|
||||
fois. Sinon, bascule manuellement en editant <code>IMAGE_NAMESPACE</code>
|
||||
dans ton <code>.env</code> (<code>igmlcreation/loremind-</code> pour stable,
|
||||
<code>igmlcreation/loremind-beta-</code> pour beta).
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,364 @@
|
||||
import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { interval, switchMap, Subscription } from 'rxjs';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { LucideAngularModule, ArrowLeft, RefreshCw, Check, AlertCircle, Download, Heart, Link2, Unlink } from 'lucide-angular';
|
||||
import { UpdatesService, UpdateStatus } from '../../services/updates.service';
|
||||
import { ConfigService } from '../../services/config.service';
|
||||
import { LicenseService, LicenseStatusDTO, BetaStatusDTO, ChannelStatusDTO, ChannelName } from '../../services/license.service';
|
||||
import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog.service';
|
||||
|
||||
/**
|
||||
* Section « Mises a jour » de l'ecran Parametres (composant standalone).
|
||||
*
|
||||
* Responsabilite unique : tout ce qui touche au CYCLE DE VIE des conteneurs —
|
||||
* - canal stable : verification + application des mises a jour (Watchtower) ;
|
||||
* - licence Patreon : connexion OAuth, installation du JWT, refresh, deconnexion ;
|
||||
* - canal beta : activation + verification des images beta ;
|
||||
* - bascule de canal stable <-> beta via le sidecar switcher (avec polling).
|
||||
*
|
||||
* Autonome : injecte ses propres services, aucun @Input/@Output — le parent
|
||||
* (SettingsComponent) ne gere plus que le formulaire de configuration LLM.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-settings-updates-section',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, LucideAngularModule],
|
||||
templateUrl: './updates-section.component.html',
|
||||
// Reutilise la feuille de style de l'ecran Parametres : les blocs deplaces
|
||||
// gardent exactement le meme rendu (cards, alerts, channel-switch, …).
|
||||
styleUrls: ['../settings.component.scss']
|
||||
})
|
||||
export class UpdatesSectionComponent implements OnInit, OnDestroy {
|
||||
|
||||
readonly ArrowLeft = ArrowLeft;
|
||||
readonly RefreshCw = RefreshCw;
|
||||
readonly Check = Check;
|
||||
readonly AlertCircle = AlertCircle;
|
||||
readonly Download = Download;
|
||||
readonly Heart = Heart;
|
||||
readonly Link2 = Link2;
|
||||
readonly Unlink = Unlink;
|
||||
|
||||
// --- Licence Patreon (canal beta) ---
|
||||
licenseStatus: LicenseStatusDTO | null = null;
|
||||
licenseLoading = false;
|
||||
licenseError = '';
|
||||
/** Message de succes local a la section (connexion/deconnexion Patreon). */
|
||||
licenseSuccess = '';
|
||||
/** Token JWT colle par l'utilisateur apres OAuth. */
|
||||
licenseJwtInput = '';
|
||||
/** Etat du canal beta (digests des images privees). */
|
||||
betaStatus: BetaStatusDTO | null = null;
|
||||
betaChecking = false;
|
||||
|
||||
// --- Bascule de canal stable <-> beta via sidecar switcher ---
|
||||
channelStatus: ChannelStatusDTO | null = null;
|
||||
/** True pendant le polling apres clic. Bloque les boutons. */
|
||||
switchInFlight = false;
|
||||
/** ID de la commande de switch en cours, pour ignorer les vieux resultats. */
|
||||
private switchCommandId: string | null = null;
|
||||
/** Subscription du polling pour pouvoir l'arreter. */
|
||||
private switchPollSub: Subscription | null = null;
|
||||
/** Erreur affichee si le switch a echoue. */
|
||||
switchError = '';
|
||||
|
||||
// --- Mises a jour conteneurs (canal stable) ---
|
||||
updateStatus: UpdateStatus | null = null;
|
||||
updateChecking = false;
|
||||
updateApplying = false;
|
||||
updateMessage = '';
|
||||
|
||||
constructor(
|
||||
private updatesService: UpdatesService,
|
||||
public config: ConfigService,
|
||||
private licenseService: LicenseService,
|
||||
private confirmDialog: ConfirmDialogService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.config.updateCheckEnabled) {
|
||||
this.checkUpdates();
|
||||
}
|
||||
this.loadLicense();
|
||||
this.loadChannelStatus();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.stopSwitchPolling();
|
||||
}
|
||||
|
||||
// --- Licence Patreon ---------------------------------------------------
|
||||
|
||||
loadLicense(): void {
|
||||
this.licenseLoading = true;
|
||||
this.licenseService.getStatus().subscribe({
|
||||
next: (s) => {
|
||||
this.licenseStatus = s;
|
||||
this.licenseLoading = false;
|
||||
if (s?.enabled && (s.status === 'VALID' || s.status === 'GRACE') && s.betaChannelEnabled) {
|
||||
this.checkBeta();
|
||||
}
|
||||
},
|
||||
error: () => { this.licenseLoading = false; }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ouvre la page OAuth Patreon dans une nouvelle fenetre.
|
||||
* L'utilisateur copie ensuite le JWT et le colle dans l'input ci-dessous.
|
||||
*/
|
||||
connectPatreon(): void {
|
||||
this.licenseError = '';
|
||||
this.licenseService.getConnectUrl().subscribe({
|
||||
next: (r) => {
|
||||
if (!r?.url) {
|
||||
this.licenseError = 'Impossible de generer l\'URL de connexion. Verifie ta config.';
|
||||
return;
|
||||
}
|
||||
window.open(r.url, '_blank', 'noopener');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
installLicense(): void {
|
||||
const jwt = this.licenseJwtInput.trim();
|
||||
if (!jwt) {
|
||||
this.licenseError = 'Colle d\'abord le token recu apres connexion Patreon.';
|
||||
return;
|
||||
}
|
||||
this.licenseError = '';
|
||||
this.licenseService.install(jwt).subscribe((res) => {
|
||||
if ((res as any)?.error) {
|
||||
this.licenseError = (res as any).error;
|
||||
return;
|
||||
}
|
||||
this.licenseStatus = res as LicenseStatusDTO;
|
||||
this.licenseJwtInput = '';
|
||||
this.licenseSuccess = 'Compte Patreon connecte. L\'acces beta est actif.';
|
||||
if (this.licenseStatus.betaChannelEnabled) {
|
||||
this.checkBeta();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
refreshLicense(): void {
|
||||
this.licenseLoading = true;
|
||||
this.licenseService.refresh().subscribe({
|
||||
next: (s) => {
|
||||
this.licenseStatus = s;
|
||||
this.licenseLoading = false;
|
||||
},
|
||||
error: () => { this.licenseLoading = false; }
|
||||
});
|
||||
}
|
||||
|
||||
disconnectPatreon(): void {
|
||||
this.confirmDialog.confirm({
|
||||
title: 'Deconnecter Patreon',
|
||||
message: 'Deconnecter ton compte Patreon ?',
|
||||
details: ['Tu perdras l\'acces au canal beta.'],
|
||||
confirmLabel: 'Deconnecter',
|
||||
variant: 'warning'
|
||||
}).then(ok => {
|
||||
if (!ok) return;
|
||||
this.licenseService.disconnect().subscribe(() => {
|
||||
this.licenseStatus = null;
|
||||
this.betaStatus = null;
|
||||
this.licenseSuccess = 'Compte Patreon deconnecte.';
|
||||
this.loadLicense();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
toggleBetaChannel(enabled: boolean): void {
|
||||
this.licenseService.setBetaChannel(enabled).subscribe({
|
||||
next: (s) => {
|
||||
if (s) this.licenseStatus = s;
|
||||
if (enabled) this.checkBeta();
|
||||
else this.betaStatus = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
checkBeta(): void {
|
||||
this.betaChecking = true;
|
||||
this.licenseService.checkBeta().subscribe({
|
||||
next: (s) => {
|
||||
this.betaStatus = s;
|
||||
this.betaChecking = false;
|
||||
},
|
||||
error: () => { this.betaChecking = false; }
|
||||
});
|
||||
}
|
||||
|
||||
// --- Bascule de canal stable <-> beta --------------------------------------
|
||||
|
||||
loadChannelStatus(): void {
|
||||
this.licenseService.getChannelStatus().subscribe({
|
||||
next: (s) => {
|
||||
this.channelStatus = s;
|
||||
// Si on revient sur l'ecran apres un reload (post-switch reussi),
|
||||
// on affiche le dernier resultat eventuel jusqu'a interaction utilisateur.
|
||||
},
|
||||
error: () => { this.channelStatus = null; }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Declenche un switch de canal. La sequence cote UI :
|
||||
* 1. Confirm modal (action destructrice : recreate des containers)
|
||||
* 2. POST /api/license/channel/switch -> 202 avec l'ID de la commande
|
||||
* 3. Polling /api/license/channel toutes les 2s jusqu'a status != IN_PROGRESS
|
||||
* 4. Si SUCCESS : la page va se rendre injoignable (Core recree). On affiche
|
||||
* "Recharge la page dans quelques secondes" et on essaie de poll quand
|
||||
* meme — au retour de Core, on detectera SUCCESS et on rechargera auto.
|
||||
* 5. Si ERROR : on affiche le message d'erreur et on debloque les boutons.
|
||||
*/
|
||||
requestChannelSwitch(target: ChannelName): void {
|
||||
const confirmMessage = target === 'beta'
|
||||
? 'Basculer LoreMind sur le canal beta ? Les containers core/brain/web vont etre recrees avec les images beta. L\'application sera indisponible 10-30 secondes.'
|
||||
: 'Repasser LoreMind sur le canal stable ? Les containers core/brain/web vont etre recrees avec les images stables. L\'application sera indisponible 10-30 secondes.';
|
||||
|
||||
this.confirmDialog.confirm({
|
||||
title: target === 'beta' ? 'Passer en beta ?' : 'Repasser en stable ?',
|
||||
message: confirmMessage,
|
||||
details: [
|
||||
'Les donnees (DB, images) sont preservees.',
|
||||
'Tu pourras refaire le chemin inverse a tout moment depuis cet ecran.'
|
||||
],
|
||||
confirmLabel: target === 'beta' ? 'Passer en beta' : 'Repasser en stable',
|
||||
variant: 'warning'
|
||||
}).then(ok => {
|
||||
if (!ok) return;
|
||||
this.doChannelSwitch(target);
|
||||
});
|
||||
}
|
||||
|
||||
private doChannelSwitch(target: ChannelName): void {
|
||||
this.switchInFlight = true;
|
||||
this.switchError = '';
|
||||
this.licenseService.switchChannel(target).subscribe((res) => {
|
||||
if ('error' in res) {
|
||||
this.switchError = res.error;
|
||||
this.switchInFlight = false;
|
||||
return;
|
||||
}
|
||||
this.switchCommandId = res.id;
|
||||
this.startSwitchPolling();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll /api/license/channel toutes les 2s. S'arrete quand on detecte un
|
||||
* resultat avec un ID >= a celui qu'on a soumis (le sidecar le met a jour
|
||||
* a la fin de son traitement).
|
||||
*/
|
||||
private startSwitchPolling(): void {
|
||||
this.stopSwitchPolling();
|
||||
this.switchPollSub = interval(2000).pipe(
|
||||
switchMap(() => this.licenseService.getChannelStatus())
|
||||
).subscribe((status) => {
|
||||
if (!status) return;
|
||||
this.channelStatus = status;
|
||||
const last = status.lastSwitch;
|
||||
if (!last || last.id !== this.switchCommandId) return;
|
||||
if (last.status === 'SUCCESS') {
|
||||
// La page va se rafraichir auto via l'update-banner qui detecte le
|
||||
// restart de Core. On laisse switchInFlight a true pour bloquer
|
||||
// toute autre action en attendant.
|
||||
this.stopSwitchPolling();
|
||||
this.switchInFlight = false;
|
||||
} else if (last.status === 'ERROR') {
|
||||
this.switchError = last.message || 'Echec du switch';
|
||||
this.stopSwitchPolling();
|
||||
this.switchInFlight = false;
|
||||
}
|
||||
// IN_PROGRESS : on continue a poll.
|
||||
});
|
||||
}
|
||||
|
||||
private stopSwitchPolling(): void {
|
||||
if (this.switchPollSub) {
|
||||
this.switchPollSub.unsubscribe();
|
||||
this.switchPollSub = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapping tier_id Patreon → nom lisible. Les IDs viennent du dashboard
|
||||
* Patreon de LoreMind (Settings -> Tiers). Sans entree dans la map, on
|
||||
* affiche l'ID brut pour rester debuggable.
|
||||
*
|
||||
* Si tu ajoutes un nouveau tier Patreon, complete cette map et redeploie.
|
||||
* (Pas besoin de toucher au backend — c'est juste un libelle d'UI.)
|
||||
*/
|
||||
private static readonly TIER_LABELS: Record<string, string> = {
|
||||
'28448887': 'Compagnon',
|
||||
// '0000000': 'Aventurier',
|
||||
// '0000000': 'Heros',
|
||||
};
|
||||
|
||||
/** Libelle lisible d'un tier Patreon, fallback sur l'ID brut. */
|
||||
tierLabel(tierId: string | null | undefined): string {
|
||||
if (!tierId) return '';
|
||||
return UpdatesSectionComponent.TIER_LABELS[tierId] ?? tierId;
|
||||
}
|
||||
|
||||
/** Format human-readable des dates renvoyees par le backend. */
|
||||
formatDate(iso: string | null | undefined): string {
|
||||
if (!iso) return '';
|
||||
try { return new Date(iso).toLocaleString(); } catch { return iso; }
|
||||
}
|
||||
|
||||
/** Nombre de jours restants avant expiration JWT (peut etre negatif). */
|
||||
get daysUntilExpiry(): number | null {
|
||||
if (!this.licenseStatus?.expiresAt) return null;
|
||||
const exp = new Date(this.licenseStatus.expiresAt).getTime();
|
||||
const now = Date.now();
|
||||
return Math.ceil((exp - now) / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
// --- Mises a jour conteneurs (canal stable) -----------------------------
|
||||
|
||||
checkUpdates(): void {
|
||||
this.updateChecking = true;
|
||||
this.updateMessage = '';
|
||||
this.updatesService.checkNow().subscribe({
|
||||
next: (s) => {
|
||||
this.updateStatus = s;
|
||||
this.updateChecking = false;
|
||||
},
|
||||
error: () => {
|
||||
this.updateChecking = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
applyUpdate(): void {
|
||||
this.confirmDialog.confirm({
|
||||
title: 'Mettre a jour',
|
||||
message: 'Telecharger et redemarrer les conteneurs maintenant ?',
|
||||
details: ['L\'app sera indisponible quelques secondes.'],
|
||||
confirmLabel: 'Mettre à jour',
|
||||
variant: 'warning'
|
||||
}).then(ok => {
|
||||
if (!ok) return;
|
||||
this.updateApplying = true;
|
||||
this.updateMessage = '';
|
||||
this.updatesService.apply().subscribe({
|
||||
next: (r) => {
|
||||
this.updateApplying = false;
|
||||
// Le redemarrage de core peut couper la connexion avant la reponse —
|
||||
// dans ce cas r vaut null (gere par catchError dans le service).
|
||||
this.updateMessage = r?.message
|
||||
?? 'Mise a jour declenchee. Rechargez la page dans 30s.';
|
||||
},
|
||||
error: () => {
|
||||
this.updateApplying = false;
|
||||
this.updateMessage = 'Mise a jour declenchee. Rechargez la page dans 30s.';
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user