Migration Angular 20 -> 21 : fin de la migration securite (0 vulnerabilite npm)

- ng update @angular/core@21 @angular/cli@21 (corrige GHSA-jrmj-c5cx-3cw6, XSS SVG)
- Migration automatique des templates vers le control flow natif (@if/@for, 138 fichiers)
- Correction des 5 `track` invalides generes par la migration (trackBy a 1 argument
  -> track $index) + suppression des fonctions trackBy mortes
- TypeScript 5.9, zone.js 0.15 ; npm audit : 0 vulnerabilite

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 14:55:36 +02:00
parent 6acad41672
commit 23878f1c63
142 changed files with 7055 additions and 6292 deletions

View File

@@ -1,85 +1,109 @@
<!-- 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>
@if (errorMessage) {
<div class="alert alert-error">
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
<span>{{ errorMessage }}</span>
</div>
}
@if (successMessage) {
<div 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>
@if (models.length > 0) {
<div class="form-row">
<label>Modeles installes</label>
<ul class="installed-models">
@for (m of models; track m) {
<li>
<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>
@if (pullDialogOpen) {
<div class="modal-overlay" (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">
@if (!pullInProgress) {
<div>
<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">
@for (s of pullSuggestions; track s) {
<button type="button"
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>
}
@if (pullInProgress) {
<div class="pull-progress">
<div class="pull-status">{{ pullStatus }}</div>
@if (pullTotal > 0) {
<div class="progress-bar">
<div class="progress-fill" [style.width.%]="pullPercent"></div>
</div>
}
@if (pullTotal > 0) {
<div class="progress-text">
{{ formatBytes(pullCompleted) }} / {{ formatBytes(pullTotal) }} ({{ pullPercent }}%)
</div>
}
@if (pullTotal === 0) {
<div class="progress-text">
Preparation...
</div>
}
</div>
}
</div>
<footer class="modal-footer">
@if (pullInProgress) {
<button type="button" class="btn-secondary"
(click)="cancelPull()">
Annuler
</button>
}
@if (!pullInProgress) {
<button type="button" class="btn-secondary"
(click)="closePullDialog()">
Fermer
</button>
}
@if (!pullInProgress) {
<button type="button" class="btn-primary"
(click)="startPull()"
[disabled]="pullInProgress || !pullModelName.trim()">
<lucide-icon [img]="Download" [size]="14"></lucide-icon>
<span>Telecharger</span>
</button>
}
</footer>
</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>
}

View File

@@ -1,6 +1,6 @@
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';
@@ -19,7 +19,7 @@ import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog
*/
@Component({
selector: 'app-ollama-model-manager',
imports: [CommonModule, FormsModule, LucideAngularModule],
imports: [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.

View File

@@ -8,373 +8,417 @@
<h1>Parametres</h1>
</header>
<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>
<section class="card" *ngIf="settings">
<h2>Moteur IA</h2>
<p class="hint">Choix du fournisseur de modele de langage utilise par le chat et la generation de pages.</p>
<div class="form-row">
<label>Fournisseur</label>
<div class="radio-group">
<label class="radio">
<input type="radio" name="provider" value="ollama" [(ngModel)]="settings.llm_provider">
<span>Ollama (local)</span>
</label>
<label class="radio">
<input type="radio" name="provider" value="onemin" [(ngModel)]="settings.llm_provider">
<span>1min.ai (cloud)</span>
</label>
<label class="radio">
<input type="radio" name="provider" value="openrouter" [(ngModel)]="settings.llm_provider">
<span>OpenRouter (cloud)</span>
</label>
<label class="radio">
<input type="radio" name="provider" value="mistral" [(ngModel)]="settings.llm_provider">
<span>Mistral (cloud)</span>
</label>
<label class="radio">
<input type="radio" name="provider" value="gemini" [(ngModel)]="settings.llm_provider">
<span>Gemini (cloud)</span>
</label>
</div>
@if (errorMessage) {
<div class="alert alert-error">
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
<span>{{ errorMessage }}</span>
</div>
</section>
}
@if (successMessage) {
<div class="alert alert-success">
<lucide-icon [img]="Check" [size]="16"></lucide-icon>
<span>{{ successMessage }}</span>
</div>
}
@if (settings) {
<section class="card">
<h2>Moteur IA</h2>
<p class="hint">Choix du fournisseur de modele de langage utilise par le chat et la generation de pages.</p>
<div class="form-row">
<label>Fournisseur</label>
<div class="radio-group">
<label class="radio">
<input type="radio" name="provider" value="ollama" [(ngModel)]="settings.llm_provider">
<span>Ollama (local)</span>
</label>
<label class="radio">
<input type="radio" name="provider" value="onemin" [(ngModel)]="settings.llm_provider">
<span>1min.ai (cloud)</span>
</label>
<label class="radio">
<input type="radio" name="provider" value="openrouter" [(ngModel)]="settings.llm_provider">
<span>OpenRouter (cloud)</span>
</label>
<label class="radio">
<input type="radio" name="provider" value="mistral" [(ngModel)]="settings.llm_provider">
<span>Mistral (cloud)</span>
</label>
<label class="radio">
<input type="radio" name="provider" value="gemini" [(ngModel)]="settings.llm_provider">
<span>Gemini (cloud)</span>
</label>
</div>
</div>
</section>
}
<!-- Bloc Ollama -->
<section class="card" *ngIf="settings && settings.llm_provider === 'ollama'">
<h2>Configuration Ollama</h2>
<div class="form-row">
<label for="ollama-url">URL du serveur Ollama</label>
<input id="ollama-url" type="text" [(ngModel)]="settings.ollama_base_url" placeholder="http://localhost:11434">
</div>
<div class="form-row">
<label for="ollama-model">Modele</label>
<div class="inline-select">
<select id="ollama-model" [(ngModel)]="settings.llm_model" (ngModelChange)="fetchOllamaModelInfo()">
<option *ngIf="ollamaModels.length === 0" [value]="settings.llm_model">{{ settings.llm_model }}</option>
<option *ngFor="let m of ollamaModels" [value]="m">{{ m }}</option>
</select>
<button type="button" class="btn-secondary" (click)="refreshModels()" [disabled]="loadingModels">
<lucide-icon [img]="RefreshCw" [size]="14"></lucide-icon>
<span>{{ loadingModels ? 'Chargement...' : 'Actualiser' }}</span>
</button>
<button type="button" class="btn-secondary" (click)="modelManager.openPullDialog()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
<span>Telecharger</span>
</button>
@if (settings && settings.llm_provider === 'ollama') {
<section class="card">
<h2>Configuration Ollama</h2>
<div class="form-row">
<label for="ollama-url">URL du serveur Ollama</label>
<input id="ollama-url" type="text" [(ngModel)]="settings.ollama_base_url" placeholder="http://localhost:11434">
</div>
<p class="hint" *ngIf="ollamaModels.length === 0">Aucun modele detecte. Verifie que Ollama tourne et que l'URL est correcte.</p>
</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>
<div class="form-row">
<label for="ollama-model">Modele</label>
<div class="inline-select">
<select id="ollama-model" [(ngModel)]="settings.llm_model" (ngModelChange)="fetchOllamaModelInfo()">
@if (ollamaModels.length === 0) {
<option [value]="settings.llm_model">{{ settings.llm_model }}</option>
}
@for (m of ollamaModels; track m) {
<option [value]="m">{{ m }}</option>
}
</select>
<button type="button" class="btn-secondary" (click)="refreshModels()" [disabled]="loadingModels">
<lucide-icon [img]="RefreshCw" [size]="14"></lucide-icon>
<span>{{ loadingModels ? 'Chargement...' : 'Actualiser' }}</span>
</button>
<button type="button" class="btn-secondary" (click)="modelManager.openPullDialog()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
<span>Telecharger</span>
</button>
</div>
@if (ollamaModels.length === 0) {
<p class="hint">Aucun modele detecte. Verifie que Ollama tourne et que l'URL est correcte.</p>
}
</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>
}
<!-- Bloc 1min.ai -->
<section class="card" *ngIf="settings && settings.llm_provider === 'onemin'">
<h2>Configuration 1min.ai</h2>
<div class="form-row">
<label for="onemin-key">Cle API</label>
<input
id="onemin-key"
type="password"
[(ngModel)]="oneminApiKeyInput"
[placeholder]="settings.onemin_api_key_set ? 'Cle configuree (laisser vide pour ne pas changer)' : 'Saisir votre cle API'">
<label class="checkbox" *ngIf="settings.onemin_api_key_set">
<input type="checkbox" [(ngModel)]="clearApiKey">
<span>Effacer la cle enregistree</span>
</label>
</div>
<div class="form-row">
<label for="onemin-provider">Fournisseur</label>
<select id="onemin-provider" [(ngModel)]="oneminProvider" (ngModelChange)="onProviderChange()">
<option *ngFor="let g of oneminGroups" [value]="g.provider">{{ g.provider }}</option>
</select>
</div>
<div class="form-row">
<label for="onemin-model">Modele</label>
<select id="onemin-model" [(ngModel)]="settings.onemin_model">
<option *ngFor="let m of currentProviderModels" [value]="m">{{ m }}</option>
</select>
</div>
</section>
<!-- Bloc OpenRouter -->
<section class="card" *ngIf="settings && settings.llm_provider === 'openrouter'">
<h2>Configuration OpenRouter</h2>
<div class="form-row">
<label for="openrouter-key">Cle API</label>
<input
id="openrouter-key"
type="password"
[(ngModel)]="openrouterApiKeyInput"
[placeholder]="settings.openrouter_api_key_set ? 'Cle configuree (laisser vide pour ne pas changer)' : 'Saisir votre cle API (sk-or-...)'">
<label class="checkbox" *ngIf="settings.openrouter_api_key_set">
<input type="checkbox" [(ngModel)]="clearOpenrouterKey">
<span>Effacer la cle enregistree</span>
</label>
</div>
<div class="form-row">
<label for="openrouter-model">Modele</label>
<label class="checkbox">
<input type="checkbox" [(ngModel)]="openrouterFreeOnly">
<span>Gratuits seulement</span>
</label>
<select id="openrouter-model" [(ngModel)]="settings.openrouter_model">
<option *ngFor="let o of openrouterSelectOptions" [value]="o.id">{{ o.label }}</option>
</select>
<p class="hint">
Liste chargee automatiquement depuis OpenRouter. Pour rester <strong>gratuit</strong> :
un modele marque <code>· gratuit</code> ou le routeur <code>openrouter/free</code>
(choisit un modele gratuit tout seul). Pour les imports, prefere un gratuit a
<strong>grand contexte</strong> (valeur « ctx »).
</p>
</div>
</section>
<!-- Bloc Mistral -->
<section class="card" *ngIf="settings && settings.llm_provider === 'mistral'">
<h2>Configuration Mistral</h2>
<div class="form-row">
<label for="mistral-key">Cle API</label>
<input
id="mistral-key"
type="password"
[(ngModel)]="mistralApiKeyInput"
[placeholder]="settings.mistral_api_key_set ? 'Cle configuree (laisser vide pour ne pas changer)' : 'Saisir votre cle API Mistral'">
<label class="checkbox" *ngIf="settings.mistral_api_key_set">
<input type="checkbox" [(ngModel)]="clearMistralKey">
<span>Effacer la cle enregistree</span>
</label>
</div>
<div class="form-row">
<label for="mistral-model">Modele</label>
<select id="mistral-model" [(ngModel)]="settings.mistral_model">
<option *ngFor="let o of mistralSelectOptions" [value]="o.id">{{ o.label }}</option>
</select>
<p class="hint">
Cle gratuite sur <code>console.mistral.ai</code> (tier « Experiment », sans CB).
Pour les imports, prefere <code>mistral-large-latest</code> (128k contexte, fidele
et bon en francais) ou <code>mistral-small-latest</code> (plus rapide).
</p>
</div>
</section>
<!-- Bloc Gemini -->
<section class="card" *ngIf="settings && settings.llm_provider === 'gemini'">
<h2>Configuration Gemini</h2>
<div class="form-row">
<label for="gemini-key">Cle API</label>
<input
id="gemini-key"
type="password"
[(ngModel)]="geminiApiKeyInput"
[placeholder]="settings.gemini_api_key_set ? 'Cle configuree (laisser vide pour ne pas changer)' : 'Saisir votre cle API Gemini'">
<label class="checkbox" *ngIf="settings.gemini_api_key_set">
<input type="checkbox" [(ngModel)]="clearGeminiKey">
<span>Effacer la cle enregistree</span>
</label>
</div>
<div class="form-row">
<label for="gemini-model">Modele</label>
<select id="gemini-model" [(ngModel)]="settings.gemini_model">
<option *ngFor="let o of geminiSelectOptions" [value]="o.id">{{ o.label }}</option>
</select>
<p class="hint">
Cle gratuite sur <code>aistudio.google.com</code> (sans CB). Ideal pour les imports :
<code>gemini-2.0-flash</code> a un <strong>contexte ~1M</strong> → un livre entier tient
en 1-2 appels (plus de morceaux perdus). Pense a monter la <strong>taille des morceaux</strong>
d'import pour en profiter.
</p>
</div>
</section>
<!-- Bloc Embeddings (Atelier RAG) : indépendant du modèle de CHAT ci-dessus. -->
<section class="card" *ngIf="settings">
<h2>Embeddings (Atelier RAG)</h2>
<p class="hint">
Modèle utilisé pour <strong>indexer les sources PDF</strong> des ateliers et y faire
de la recherche. C'est un modèle <strong>séparé</strong> du modèle de chat ci-dessus.
</p>
<div class="form-row">
<label>Fournisseur d'embeddings</label>
<div class="radio-group">
<label class="radio">
<input type="radio" name="embprovider" value="ollama" [(ngModel)]="settings.embedding_provider">
<span>Ollama (local, gratuit)</span>
</label>
<label class="radio">
<input type="radio" name="embprovider" value="mistral" [(ngModel)]="settings.embedding_provider">
<span>Mistral (cloud, EU)</span>
</label>
</div>
</div>
<!-- Ollama embeddings -->
<div *ngIf="settings.embedding_provider === 'ollama'">
@if (settings && settings.llm_provider === 'onemin') {
<section class="card">
<h2>Configuration 1min.ai</h2>
<div class="form-row">
<label for="emb-ollama-model">Modèle d'embedding Ollama</label>
<input id="emb-ollama-model" type="text" [(ngModel)]="settings.ollama_embedding_model"
placeholder="nomic-embed-text">
<p class="hint">
Recommandé : <code>nomic-embed-text</code> (léger). Gratuit et illimité (local).
</p>
</div>
<div class="form-row">
<label class="checkbox">
<input type="checkbox" [(ngModel)]="settings.auto_pull_embedding_model">
<span>Installer automatiquement le modèle au démarrage s'il manque</span>
</label>
</div>
</div>
<label for="onemin-key">Cle API</label>
<input
id="onemin-key"
type="password"
[(ngModel)]="oneminApiKeyInput"
[placeholder]="settings.onemin_api_key_set ? 'Cle configuree (laisser vide pour ne pas changer)' : 'Saisir votre cle API'">
@if (settings.onemin_api_key_set) {
<label class="checkbox">
<input type="checkbox" [(ngModel)]="clearApiKey">
<span>Effacer la cle enregistree</span>
</label>
}
</div>
<div class="form-row">
<label for="onemin-provider">Fournisseur</label>
<select id="onemin-provider" [(ngModel)]="oneminProvider" (ngModelChange)="onProviderChange()">
@for (g of oneminGroups; track g) {
<option [value]="g.provider">{{ g.provider }}</option>
}
</select>
</div>
<div class="form-row">
<label for="onemin-model">Modele</label>
<select id="onemin-model" [(ngModel)]="settings.onemin_model">
@for (m of currentProviderModels; track m) {
<option [value]="m">{{ m }}</option>
}
</select>
</div>
</section>
}
<div class="form-row">
<label for="rag-topk">Extraits récupérés par question (couverture RAG)</label>
<input id="rag-topk" type="number" min="3" max="200" [(ngModel)]="settings.rag_top_k">
<p class="hint">
Plus haut = l'IA voit plus de passages par question (mieux pour les questions
larges « liste les… »), mais prompt plus long. Défaut 8. Avec un modèle
<strong>gros-contexte</strong> (Gemini), tu peux monter à <strong>50-150</strong> pour
une couverture quasi « document entier ». Pour les questions exhaustives, préfère
le bouton <strong>« Analyse approfondie »</strong> de l'atelier.
</p>
</div>
<!-- Bloc OpenRouter -->
@if (settings && settings.llm_provider === 'openrouter') {
<section class="card">
<h2>Configuration OpenRouter</h2>
<div class="form-row">
<label for="openrouter-key">Cle API</label>
<input
id="openrouter-key"
type="password"
[(ngModel)]="openrouterApiKeyInput"
[placeholder]="settings.openrouter_api_key_set ? 'Cle configuree (laisser vide pour ne pas changer)' : 'Saisir votre cle API (sk-or-...)'">
@if (settings.openrouter_api_key_set) {
<label class="checkbox">
<input type="checkbox" [(ngModel)]="clearOpenrouterKey">
<span>Effacer la cle enregistree</span>
</label>
}
</div>
<div class="form-row">
<label for="openrouter-model">Modele</label>
<label class="checkbox">
<input type="checkbox" [(ngModel)]="openrouterFreeOnly">
<span>Gratuits seulement</span>
</label>
<select id="openrouter-model" [(ngModel)]="settings.openrouter_model">
@for (o of openrouterSelectOptions; track o) {
<option [value]="o.id">{{ o.label }}</option>
}
</select>
<p class="hint">
Liste chargee automatiquement depuis OpenRouter. Pour rester <strong>gratuit</strong> :
un modele marque <code>· gratuit</code> ou le routeur <code>openrouter/free</code>
(choisit un modele gratuit tout seul). Pour les imports, prefere un gratuit a
<strong>grand contexte</strong> (valeur « ctx »).
</p>
</div>
</section>
}
<!-- Mistral embeddings -->
<div *ngIf="settings.embedding_provider === 'mistral'">
<div class="form-row">
<label for="emb-mistral-key">
Clé API Mistral
<span class="key-state" *ngIf="settings.mistral_api_key_set">✓ configurée</span>
</label>
<input id="emb-mistral-key" type="password" [(ngModel)]="mistralApiKeyInput"
[placeholder]="settings.mistral_api_key_set ? 'Clé configurée (laisser vide pour ne pas changer)' : 'Saisir votre clé API Mistral'">
<label class="checkbox" *ngIf="settings.mistral_api_key_set">
<input type="checkbox" [(ngModel)]="clearMistralKey">
<span>Effacer la clé enregistrée</span>
</label>
<p class="hint">
C'est la <strong>même clé</strong> que pour le chat Mistral (une seule clé partagée).
Soumis au rate limit du tier gratuit.
</p>
</div>
<div class="form-row">
<label for="emb-mistral-model">Modèle d'embedding Mistral</label>
<input id="emb-mistral-model" type="text" [(ngModel)]="settings.mistral_embedding_model"
placeholder="mistral-embed">
</div>
</div>
</section>
<!-- Bloc Mistral -->
@if (settings && settings.llm_provider === 'mistral') {
<section class="card">
<h2>Configuration Mistral</h2>
<div class="form-row">
<label for="mistral-key">Cle API</label>
<input
id="mistral-key"
type="password"
[(ngModel)]="mistralApiKeyInput"
[placeholder]="settings.mistral_api_key_set ? 'Cle configuree (laisser vide pour ne pas changer)' : 'Saisir votre cle API Mistral'">
@if (settings.mistral_api_key_set) {
<label class="checkbox">
<input type="checkbox" [(ngModel)]="clearMistralKey">
<span>Effacer la cle enregistree</span>
</label>
}
</div>
<div class="form-row">
<label for="mistral-model">Modele</label>
<select id="mistral-model" [(ngModel)]="settings.mistral_model">
@for (o of mistralSelectOptions; track o) {
<option [value]="o.id">{{ o.label }}</option>
}
</select>
<p class="hint">
Cle gratuite sur <code>console.mistral.ai</code> (tier « Experiment », sans CB).
Pour les imports, prefere <code>mistral-large-latest</code> (128k contexte, fidele
et bon en francais) ou <code>mistral-small-latest</code> (plus rapide).
</p>
</div>
</section>
}
<!-- Bloc Fenetre de contexte : seulement pour Ollama (num_ctx est un vrai
parametre du modele local). Pour le cloud (1min/OpenRouter), on ne fixe rien :
on utilise ce que le modele accepte, et la jauge du chat s'affiche sans max. -->
<section class="card" *ngIf="settings && settings.llm_provider === 'ollama'">
<h2>Fenetre de contexte</h2>
<!-- Bloc Gemini -->
@if (settings && settings.llm_provider === 'gemini') {
<section class="card">
<h2>Configuration Gemini</h2>
<div class="form-row">
<label for="gemini-key">Cle API</label>
<input
id="gemini-key"
type="password"
[(ngModel)]="geminiApiKeyInput"
[placeholder]="settings.gemini_api_key_set ? 'Cle configuree (laisser vide pour ne pas changer)' : 'Saisir votre cle API Gemini'">
@if (settings.gemini_api_key_set) {
<label class="checkbox">
<input type="checkbox" [(ngModel)]="clearGeminiKey">
<span>Effacer la cle enregistree</span>
</label>
}
</div>
<div class="form-row">
<label for="gemini-model">Modele</label>
<select id="gemini-model" [(ngModel)]="settings.gemini_model">
@for (o of geminiSelectOptions; track o) {
<option [value]="o.id">{{ o.label }}</option>
}
</select>
<p class="hint">
Cle gratuite sur <code>aistudio.google.com</code> (sans CB). Ideal pour les imports :
<code>gemini-2.0-flash</code> a un <strong>contexte ~1M</strong> → un livre entier tient
en 1-2 appels (plus de morceaux perdus). Pense a monter la <strong>taille des morceaux</strong>
d'import pour en profiter.
</p>
</div>
</section>
}
<!-- Ollama : slider borne par le max du modele -->
<div class="form-row" *ngIf="settings.llm_provider === 'ollama'">
<label for="llm-num-ctx">
Tokens alloues au modele
<span class="ctx-value">{{ settings.llm_num_ctx | number }}</span>
<span class="ctx-max" *ngIf="ollamaModelMaxContext > 0">
/ {{ ollamaModelMaxContext | number }} max
</span>
</label>
<input
id="llm-num-ctx"
type="range"
[min]="CTX_MIN"
[max]="effectiveMaxContext"
step="1024"
[(ngModel)]="settings.llm_num_ctx"
class="ctx-slider">
<p class="hint" *ngIf="ollamaModelMaxContext > 0">
Le modele <strong>{{ settings.llm_model }}</strong> accepte jusqu'a
{{ ollamaModelMaxContext | number }} tokens. Plus la valeur est elevee, plus
l'IA peut tenir d'historique et de contexte — au prix de VRAM et de latence.
</p>
<p class="hint" *ngIf="ollamaModelMaxContext === 0">
Impossible de determiner la fenetre max du modele (Ollama injoignable ou modele
inconnu). Slider borne a {{ CTX_FALLBACK_MAX | number }} par securite.
</p>
</div>
<!-- Bloc Embeddings (Atelier RAG) : indépendant du modèle de CHAT ci-dessus. -->
@if (settings) {
<section class="card">
<h2>Embeddings (Atelier RAG)</h2>
<p class="hint">
Modèle utilisé pour <strong>indexer les sources PDF</strong> des ateliers et y faire
de la recherche. C'est un modèle <strong>séparé</strong> du modèle de chat ci-dessus.
</p>
<div class="form-row">
<label>Fournisseur d'embeddings</label>
<div class="radio-group">
<label class="radio">
<input type="radio" name="embprovider" value="ollama" [(ngModel)]="settings.embedding_provider">
<span>Ollama (local, gratuit)</span>
</label>
<label class="radio">
<input type="radio" name="embprovider" value="mistral" [(ngModel)]="settings.embedding_provider">
<span>Mistral (cloud, EU)</span>
</label>
</div>
</div>
<!-- Ollama embeddings -->
@if (settings.embedding_provider === 'ollama') {
<div>
<div class="form-row">
<label for="emb-ollama-model">Modèle d'embedding Ollama</label>
<input id="emb-ollama-model" type="text" [(ngModel)]="settings.ollama_embedding_model"
placeholder="nomic-embed-text">
<p class="hint">
Recommandé : <code>nomic-embed-text</code> (léger). Gratuit et illimité (local).
</p>
</div>
<div class="form-row">
<label class="checkbox">
<input type="checkbox" [(ngModel)]="settings.auto_pull_embedding_model">
<span>Installer automatiquement le modèle au démarrage s'il manque</span>
</label>
</div>
</div>
}
<div class="form-row">
<label for="rag-topk">Extraits récupérés par question (couverture RAG)</label>
<input id="rag-topk" type="number" min="3" max="200" [(ngModel)]="settings.rag_top_k">
<p class="hint">
Plus haut = l'IA voit plus de passages par question (mieux pour les questions
larges « liste les… »), mais prompt plus long. Défaut 8. Avec un modèle
<strong>gros-contexte</strong> (Gemini), tu peux monter à <strong>50-150</strong> pour
une couverture quasi « document entier ». Pour les questions exhaustives, préfère
le bouton <strong>« Analyse approfondie »</strong> de l'atelier.
</p>
</div>
<!-- Mistral embeddings -->
@if (settings.embedding_provider === 'mistral') {
<div>
<div class="form-row">
<label for="emb-mistral-key">
Clé API Mistral
@if (settings.mistral_api_key_set) {
<span class="key-state">✓ configurée</span>
}
</label>
<input id="emb-mistral-key" type="password" [(ngModel)]="mistralApiKeyInput"
[placeholder]="settings.mistral_api_key_set ? 'Clé configurée (laisser vide pour ne pas changer)' : 'Saisir votre clé API Mistral'">
@if (settings.mistral_api_key_set) {
<label class="checkbox">
<input type="checkbox" [(ngModel)]="clearMistralKey">
<span>Effacer la clé enregistrée</span>
</label>
}
<p class="hint">
C'est la <strong>même clé</strong> que pour le chat Mistral (une seule clé partagée).
Soumis au rate limit du tier gratuit.
</p>
</div>
<div class="form-row">
<label for="emb-mistral-model">Modèle d'embedding Mistral</label>
<input id="emb-mistral-model" type="text" [(ngModel)]="settings.mistral_embedding_model"
placeholder="mistral-embed">
</div>
</div>
}
</section>
}
</section>
<!-- Bloc Fenetre de contexte : seulement pour Ollama (num_ctx est un vrai
parametre du modele local). Pour le cloud (1min/OpenRouter), on ne fixe rien :
on utilise ce que le modele accepte, et la jauge du chat s'affiche sans max. -->
@if (settings && settings.llm_provider === 'ollama') {
<section class="card">
<h2>Fenetre de contexte</h2>
<!-- Ollama : slider borne par le max du modele -->
@if (settings.llm_provider === 'ollama') {
<div class="form-row">
<label for="llm-num-ctx">
Tokens alloues au modele
<span class="ctx-value">{{ settings.llm_num_ctx | number }}</span>
@if (ollamaModelMaxContext > 0) {
<span class="ctx-max">
/ {{ ollamaModelMaxContext | number }} max
</span>
}
</label>
<input
id="llm-num-ctx"
type="range"
[min]="CTX_MIN"
[max]="effectiveMaxContext"
step="1024"
[(ngModel)]="settings.llm_num_ctx"
class="ctx-slider">
@if (ollamaModelMaxContext > 0) {
<p class="hint">
Le modele <strong>{{ settings.llm_model }}</strong> accepte jusqu'a
{{ ollamaModelMaxContext | number }} tokens. Plus la valeur est elevee, plus
l'IA peut tenir d'historique et de contexte — au prix de VRAM et de latence.
</p>
}
@if (ollamaModelMaxContext === 0) {
<p class="hint">
Impossible de determiner la fenetre max du modele (Ollama injoignable ou modele
inconnu). Slider borne a {{ CTX_FALLBACK_MAX | number }} par securite.
</p>
}
</div>
}
</section>
}
<!-- Bloc Import de PDF -->
<section class="card" *ngIf="settings">
<h2>Import de PDF</h2>
<div class="form-row">
<label for="import-chunk-tokens">Taille des morceaux a l'import (tokens)</label>
<input
id="import-chunk-tokens"
type="number"
min="2000"
step="1000"
[(ngModel)]="settings.import_chunk_tokens">
<p class="hint">
Lors de l'import d'un PDF (regles ou campagne), le texte est decoupe en morceaux
de cette taille avant d'etre analyse par l'IA. <strong>Plus gros = moins de morceaux</strong>,
donc plus rapide et moins de fragmentation/doublons mais le morceau doit tenir dans
la fenetre du modele.
<br />
Repere : ~10 000 pour Ollama (16k de contexte) ; jusqu'a ~100 000 pour un modele a
grand contexte (ex : GPT-5 mini, 400k) afin de traiter un livre entier en une seule passe.
Attention : un gros morceau = generation plus longue (voir le timeout ci-dessous).
</p>
</div>
<!-- Bloc Import de PDF -->
@if (settings) {
<section class="card">
<h2>Import de PDF</h2>
<div class="form-row">
<label for="import-chunk-tokens">Taille des morceaux a l'import (tokens)</label>
<input
id="import-chunk-tokens"
type="number"
min="2000"
step="1000"
[(ngModel)]="settings.import_chunk_tokens">
<p class="hint">
Lors de l'import d'un PDF (regles ou campagne), le texte est decoupe en morceaux
de cette taille avant d'etre analyse par l'IA. <strong>Plus gros = moins de morceaux</strong>,
donc plus rapide et moins de fragmentation/doublons — mais le morceau doit tenir dans
la fenetre du modele.
<br />
Repere : ~10 000 pour Ollama (16k de contexte) ; jusqu'a ~100 000 pour un modele a
grand contexte (ex : GPT-5 mini, 400k) afin de traiter un livre entier en une seule passe.
Attention : un gros morceau = generation plus longue (voir le timeout ci-dessous).
</p>
</div>
<div class="form-row">
<label for="llm-timeout">Timeout des appels IA (secondes)</label>
<input
id="llm-timeout"
type="number"
min="30"
step="30"
[(ngModel)]="settings.llm_timeout_seconds">
<p class="hint">
Delai max d'attente d'une reponse du modele. Si un import lourd echoue avec
« delai depasse » (timeout), <strong>augmentez cette valeur</strong> (ex : 600)
ou reduisez la taille des morceaux ci-dessus. Defaut : 300 s.
</p>
</div>
</section>
}
<div class="form-row">
<label for="llm-timeout">Timeout des appels IA (secondes)</label>
<input
id="llm-timeout"
type="number"
min="30"
step="30"
[(ngModel)]="settings.llm_timeout_seconds">
<p class="hint">
Delai max d'attente d'une reponse du modele. Si un import lourd echoue avec
« delai depasse » (timeout), <strong>augmentez cette valeur</strong> (ex : 600)
ou reduisez la taille des morceaux ci-dessus. Defaut : 300 s.
</p>
</div>
</section>
<!-- Mises a jour conteneurs + licence Patreon + canal beta (sous-composant) -->
<app-settings-updates-section></app-settings-updates-section>
<!-- Mises a jour conteneurs + licence Patreon + canal beta (sous-composant) -->
<app-settings-updates-section></app-settings-updates-section>
@if (settings) {
<div class="actions">
<button class="btn-primary" (click)="save()" [disabled]="saving">
<lucide-icon [img]="Save" [size]="16"></lucide-icon>
<span>{{ saving ? 'Sauvegarde...' : 'Sauvegarder' }}</span>
</button>
</div>
}
<div class="actions" *ngIf="settings">
<button class="btn-primary" (click)="save()" [disabled]="saving">
<lucide-icon [img]="Save" [size]="16"></lucide-icon>
<span>{{ saving ? 'Sauvegarde...' : 'Sauvegarder' }}</span>
</button>
</div>
</div>
</div>

View File

@@ -1,241 +1,289 @@
<!-- 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
@if (config.updateCheckEnabled || licenseStatus?.enabled) {
<section class="card">
<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">
@if (licenseSuccess) {
<div class="alert alert-success">
<lucide-icon [img]="Check" [size]="16"></lucide-icon>
<span>{{ updateMessage }}</span>
<span>{{ licenseSuccess }}</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 &mdash; 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&euro;/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>
}
<!-- ====================================================== -->
<!-- Sous-section : canal stable -->
<!-- ====================================================== -->
@if (config.updateCheckEnabled) {
<div class="channel-block">
<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>
<!-- 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 &amp;&amp; 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>
@if (updateStatus && !updateStatus.enabled) {
<div class="hint">
Feature non configuree (WATCHTOWER_TOKEN absent).
</div>
}
@if (updateStatus?.enabled) {
<div>
@if (updateStatus?.updateAvailable) {
<div class="alert alert-success">
<lucide-icon [img]="Download" [size]="16"></lucide-icon>
<span>Une mise a jour est disponible.</span>
</div>
}
@if (updateStatus?.anyUnknown && !updateStatus?.updateAvailable) {
<div class="alert alert-warn">
<lucide-icon [img]="Download" [size]="16"></lucide-icon>
<span>Verification impossible (baseline absente ou registry injoignable).</span>
</div>
}
@if (!updateStatus?.updateAvailable && !updateStatus?.anyUnknown) {
<div class="hint">
Tout est a jour (verifie le {{ updateStatus?.checkedAt | date:'short' }}).
</div>
}
@if (updateStatus?.updateAvailable) {
<div class="form-row">
<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>
}
@if (updateMessage) {
<div class="alert alert-success">
<lucide-icon [img]="Check" [size]="16"></lucide-icon>
<span>{{ updateMessage }}</span>
</div>
}
</div>
}
</div>
</ng-container>
</div>
</section>
}
<!-- ====================================================== -->
<!-- Sous-section : canal beta (Patreon) -->
<!-- ====================================================== -->
@if (licenseStatus?.enabled) {
<div class="channel-block">
<h3 class="channel-title">
<lucide-icon [img]="Heart" [size]="16"></lucide-icon>
Canal beta &mdash; 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&euro;/mois) ou superieur debloque ce canal.
</p>
<!-- Pas de licence installee -->
@if (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>
@if (licenseError) {
<div class="alert alert-error">
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
<span>{{ licenseError }}</span>
</div>
}
}
<!-- Licence installee (VALID / GRACE / EXPIRED / UNVERIFIABLE) -->
@if (licenseStatus && licenseStatus.status !== 'NONE') {
@if (licenseStatus.status === 'VALID') {
<div class="alert alert-success">
<lucide-icon [img]="Check" [size]="16"></lucide-icon>
<span>Compte Patreon connecte. Tier {{ tierLabel(licenseStatus.tierId) }} actif.</span>
</div>
}
@if (licenseStatus.status === 'GRACE') {
<div 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>
}
@if (licenseStatus.status === 'EXPIRED') {
<div 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>
}
@if (licenseStatus.status === 'UNVERIFIABLE') {
<div 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">
@if (licenseStatus.tierId) {
<li><strong>Tier :</strong> {{ tierLabel(licenseStatus.tierId) }}</li>
}
@if (licenseStatus.expiresAt) {
<li>
<strong>Validite :</strong>
jusqu'au {{ formatDate(licenseStatus.expiresAt) }}
@if (daysUntilExpiry !== null && daysUntilExpiry > 0) {
<span>
(renouvellement dans {{ daysUntilExpiry }} jour@if (daysUntilExpiry > 1) {
<span>s</span>
})
</span>
}
</li>
}
@if (licenseStatus.lastRefreshAttemptAt) {
<li>
<strong>Dernier refresh :</strong>
{{ formatDate(licenseStatus.lastRefreshAttemptAt) }}
@if (licenseStatus.lastRefreshSucceeded === true) {
<span class="badge-ok">OK</span>
}
@if (licenseStatus.lastRefreshSucceeded === false) {
<span 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 -->
@if (licenseStatus.betaChannelEnabled) {
<div class="beta-status">
@if (betaChecking) {
<div class="hint">Verification des images beta...</div>
}
@if (!betaChecking && betaStatus && !betaStatus.enabled) {
<div class="hint">
Indisponible : {{ betaStatus.disabledReason }}
</div>
}
@if (!betaChecking && betaStatus?.enabled) {
<div>
@if (betaStatus?.anyUnknown) {
<div 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 -->
@if (channelStatus) {
<div class="channel-switch">
<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 -->
@if (channelStatus.switcherAvailable) {
<!-- On stable -> proposer passage beta (uniquement si licence active) -->
@if (channelStatus.currentChannel === 'stable') {
<button
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 -->
@if (channelStatus.currentChannel === 'beta') {
<button
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 -->
@if (switchInFlight) {
<div 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 -->
@if (switchError && !switchInFlight) {
<div class="alert alert-error">
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
<span>{{ switchError }}</span>
</div>
}
}
<!-- Sidecar PAS dispo : fallback instructions manuelles (vieilles installs) -->
@if (!channelStatus.switcherAvailable) {
<div 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 &amp;&amp; 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>
}
}
</div>
}
</section>
}