Mise en place de l'anglais comme deuxième langue pour l'application
This commit is contained in:
@@ -15,7 +15,7 @@
|
||||
<!-- Liste des modeles installes avec bouton supprimer -->
|
||||
@if (models.length > 0) {
|
||||
<div class="form-row">
|
||||
<label>Modeles installes</label>
|
||||
<label>{{ 'ollamaModelManager.installedModels' | translate }}</label>
|
||||
<ul class="installed-models">
|
||||
@for (m of models; track m) {
|
||||
<li>
|
||||
@@ -23,7 +23,7 @@
|
||||
<button type="button" class="btn-icon btn-danger"
|
||||
(click)="deleteModel(m)"
|
||||
[disabled]="deletingModel === m"
|
||||
[title]="'Supprimer ' + m">
|
||||
[title]="'ollamaModelManager.deleteModelTitle' | translate:{ name: m }">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
</li>
|
||||
@@ -37,27 +37,26 @@
|
||||
<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">
|
||||
<h3>{{ 'ollamaModelManager.pullDialogTitle' | translate }}</h3>
|
||||
<button type="button" class="btn-icon" (click)="closePullDialog()" [disabled]="pullInProgress" [title]="'common.close' | translate">
|
||||
<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>
|
||||
<label for="pull-name">{{ 'ollamaModelManager.modelNameLabel' | translate }}</label>
|
||||
<input id="pull-name" type="text" [(ngModel)]="pullModelName"
|
||||
placeholder="ex: gemma4:e4b" autocomplete="off"
|
||||
(keydown.enter)="startPull()">
|
||||
<p class="hint">Suggestions :</p>
|
||||
<p class="hint">{{ 'ollamaModelManager.suggestions' | translate }}</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 class="hint" style="margin-top: 0.75rem;" [innerHTML]="'ollamaModelManager.libraryHint' | translate">
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
@@ -76,7 +75,7 @@
|
||||
}
|
||||
@if (pullTotal === 0) {
|
||||
<div class="progress-text">
|
||||
Preparation...
|
||||
{{ 'ollamaModelManager.preparing' | translate }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -86,13 +85,13 @@
|
||||
@if (pullInProgress) {
|
||||
<button type="button" class="btn-secondary"
|
||||
(click)="cancelPull()">
|
||||
Annuler
|
||||
{{ 'common.cancel' | translate }}
|
||||
</button>
|
||||
}
|
||||
@if (!pullInProgress) {
|
||||
<button type="button" class="btn-secondary"
|
||||
(click)="closePullDialog()">
|
||||
Fermer
|
||||
{{ 'common.close' | translate }}
|
||||
</button>
|
||||
}
|
||||
@if (!pullInProgress) {
|
||||
@@ -100,7 +99,7 @@
|
||||
(click)="startPull()"
|
||||
[disabled]="pullInProgress || !pullModelName.trim()">
|
||||
<lucide-icon [img]="Download" [size]="14"></lucide-icon>
|
||||
<span>Telecharger</span>
|
||||
<span>{{ 'common.download' | translate }}</span>
|
||||
</button>
|
||||
}
|
||||
</footer>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Subscription } from 'rxjs';
|
||||
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { LucideAngularModule, Check, AlertCircle, Download, Trash2, X } from 'lucide-angular';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { SettingsService, OllamaPullEvent } from '../../services/settings.service';
|
||||
import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog.service';
|
||||
|
||||
@@ -19,7 +20,7 @@ import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-ollama-model-manager',
|
||||
imports: [FormsModule, LucideAngularModule],
|
||||
imports: [FormsModule, LucideAngularModule, TranslatePipe],
|
||||
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.
|
||||
@@ -76,7 +77,8 @@ export class OllamaModelManagerComponent implements OnDestroy {
|
||||
|
||||
constructor(
|
||||
private settingsService: SettingsService,
|
||||
private confirmDialog: ConfirmDialogService
|
||||
private confirmDialog: ConfirmDialogService,
|
||||
private translate: TranslateService
|
||||
) {}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
@@ -105,13 +107,13 @@ export class OllamaModelManagerComponent implements OnDestroy {
|
||||
if (!name || this.pullInProgress) return;
|
||||
this.resetPullState();
|
||||
this.pullInProgress = true;
|
||||
this.pullStatus = 'connexion...';
|
||||
this.pullStatus = this.translate.instant('ollamaModelManager.connecting');
|
||||
this.errorMessage = '';
|
||||
|
||||
this.pullSubscription = this.settingsService.pullOllamaModel(name).subscribe({
|
||||
next: (event: OllamaPullEvent) => {
|
||||
if (event.error) {
|
||||
this.errorMessage = `Echec : ${event.error}`;
|
||||
this.errorMessage = this.translate.instant('ollamaModelManager.pullEventError', { error: event.error });
|
||||
this.pullInProgress = false;
|
||||
return;
|
||||
}
|
||||
@@ -123,7 +125,7 @@ export class OllamaModelManagerComponent implements OnDestroy {
|
||||
if (event.status === 'success') this.pullSucceeded = true;
|
||||
},
|
||||
error: (err) => {
|
||||
this.errorMessage = this.extractError(err, `Echec du telechargement de ${name}.`);
|
||||
this.errorMessage = this.extractError(err, this.translate.instant('ollamaModelManager.pullFailed', { name }));
|
||||
this.pullInProgress = false;
|
||||
},
|
||||
complete: () => {
|
||||
@@ -132,11 +134,11 @@ export class OllamaModelManagerComponent implements OnDestroy {
|
||||
// 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.errorMessage = this.translate.instant('ollamaModelManager.pullInterrupted', { name });
|
||||
this.modelsChanged.emit();
|
||||
return;
|
||||
}
|
||||
this.successMessage = `Modele ${name} telecharge.`;
|
||||
this.successMessage = this.translate.instant('ollamaModelManager.pullDone', { name });
|
||||
this.modelsChanged.emit();
|
||||
this.modelPulled.emit(name);
|
||||
// Petite tempo avant de fermer pour que le user voie "success".
|
||||
@@ -151,7 +153,7 @@ export class OllamaModelManagerComponent implements OnDestroy {
|
||||
this.pullSubscription = null;
|
||||
}
|
||||
this.pullInProgress = false;
|
||||
this.pullStatus = 'annule';
|
||||
this.pullStatus = this.translate.instant('ollamaModelManager.cancelled');
|
||||
}
|
||||
|
||||
private resetPullState(): void {
|
||||
@@ -183,10 +185,10 @@ export class OllamaModelManagerComponent implements OnDestroy {
|
||||
|
||||
deleteModel(name: string): void {
|
||||
this.confirmDialog.confirm({
|
||||
title: 'Supprimer le modele',
|
||||
message: `Supprimer le modele '${name}' ?`,
|
||||
details: ['L\'espace disque sera libere.'],
|
||||
confirmLabel: 'Supprimer',
|
||||
title: this.translate.instant('ollamaModelManager.deleteTitle'),
|
||||
message: this.translate.instant('ollamaModelManager.deleteMessage', { name }),
|
||||
details: [this.translate.instant('ollamaModelManager.deleteDetail')],
|
||||
confirmLabel: this.translate.instant('common.delete'),
|
||||
variant: 'danger'
|
||||
}).then(ok => {
|
||||
if (!ok) return;
|
||||
@@ -195,13 +197,13 @@ export class OllamaModelManagerComponent implements OnDestroy {
|
||||
this.settingsService.deleteOllamaModel(name).subscribe({
|
||||
next: () => {
|
||||
this.deletingModel = null;
|
||||
this.successMessage = `Modele ${name} supprime.`;
|
||||
this.successMessage = this.translate.instant('ollamaModelManager.deleteDone', { name });
|
||||
this.modelsChanged.emit();
|
||||
this.modelDeleted.emit(name);
|
||||
},
|
||||
error: (err) => {
|
||||
this.deletingModel = null;
|
||||
this.errorMessage = this.extractError(err, `Echec de la suppression de ${name}.`);
|
||||
this.errorMessage = this.extractError(err, this.translate.instant('ollamaModelManager.deleteFailed', { name }));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,11 +3,19 @@
|
||||
<header class="page-header">
|
||||
<button class="btn-back" (click)="goBack()">
|
||||
<lucide-icon [img]="ArrowLeft" [size]="16"></lucide-icon>
|
||||
<span>Retour</span>
|
||||
<span>{{ 'common.back' | translate }}</span>
|
||||
</button>
|
||||
<h1>Parametres</h1>
|
||||
<h1>{{ 'settings.title' | translate }}</h1>
|
||||
</header>
|
||||
|
||||
<section class="card">
|
||||
<h2>{{ 'settings.language.title' | translate }}</h2>
|
||||
<p class="hint">{{ 'settings.language.hint' | translate }}</p>
|
||||
<div class="form-row">
|
||||
<app-language-switcher></app-language-switcher>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (errorMessage) {
|
||||
<div class="alert alert-error">
|
||||
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
|
||||
@@ -23,30 +31,30 @@
|
||||
|
||||
@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>
|
||||
<h2>{{ 'settings.ai.title' | translate }}</h2>
|
||||
<p class="hint">{{ 'settings.ai.hint' | translate }}</p>
|
||||
<div class="form-row">
|
||||
<label>Fournisseur</label>
|
||||
<label>{{ 'settings.ai.provider' | translate }}</label>
|
||||
<div class="radio-group">
|
||||
<label class="radio">
|
||||
<input type="radio" name="provider" value="ollama" [(ngModel)]="settings.llm_provider">
|
||||
<span>Ollama (local)</span>
|
||||
<span>{{ 'settings.ai.providerOllama' | translate }}</span>
|
||||
</label>
|
||||
<label class="radio">
|
||||
<input type="radio" name="provider" value="onemin" [(ngModel)]="settings.llm_provider">
|
||||
<span>1min.ai (cloud)</span>
|
||||
<span>{{ 'settings.ai.providerOnemin' | translate }}</span>
|
||||
</label>
|
||||
<label class="radio">
|
||||
<input type="radio" name="provider" value="openrouter" [(ngModel)]="settings.llm_provider">
|
||||
<span>OpenRouter (cloud)</span>
|
||||
<span>{{ 'settings.ai.providerOpenrouter' | translate }}</span>
|
||||
</label>
|
||||
<label class="radio">
|
||||
<input type="radio" name="provider" value="mistral" [(ngModel)]="settings.llm_provider">
|
||||
<span>Mistral (cloud)</span>
|
||||
<span>{{ 'settings.ai.providerMistral' | translate }}</span>
|
||||
</label>
|
||||
<label class="radio">
|
||||
<input type="radio" name="provider" value="gemini" [(ngModel)]="settings.llm_provider">
|
||||
<span>Gemini (cloud)</span>
|
||||
<span>{{ 'settings.ai.providerGemini' | translate }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -56,13 +64,13 @@
|
||||
<!-- Bloc Ollama -->
|
||||
@if (settings && settings.llm_provider === 'ollama') {
|
||||
<section class="card">
|
||||
<h2>Configuration Ollama</h2>
|
||||
<h2>{{ 'settings.ollama.title' | translate }}</h2>
|
||||
<div class="form-row">
|
||||
<label for="ollama-url">URL du serveur Ollama</label>
|
||||
<label for="ollama-url">{{ 'settings.ollama.url' | translate }}</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>
|
||||
<label for="ollama-model">{{ 'settings.ollama.model' | translate }}</label>
|
||||
<div class="inline-select">
|
||||
<select id="ollama-model" [(ngModel)]="settings.llm_model" (ngModelChange)="fetchOllamaModelInfo()">
|
||||
@if (ollamaModels.length === 0) {
|
||||
@@ -74,15 +82,15 @@
|
||||
</select>
|
||||
<button type="button" class="btn-secondary" (click)="refreshModels()" [disabled]="loadingModels">
|
||||
<lucide-icon [img]="RefreshCw" [size]="14"></lucide-icon>
|
||||
<span>{{ loadingModels ? 'Chargement...' : 'Actualiser' }}</span>
|
||||
<span>{{ (loadingModels ? 'common.loading' : 'common.refresh') | translate }}</span>
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="modelManager.openPullDialog()">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
<span>Telecharger</span>
|
||||
<span>{{ 'common.download' | translate }}</span>
|
||||
</button>
|
||||
</div>
|
||||
@if (ollamaModels.length === 0) {
|
||||
<p class="hint">Aucun modele detecte. Verifie que Ollama tourne et que l'URL est correcte.</p>
|
||||
<p class="hint">{{ 'settings.ollama.noModels' | translate }}</p>
|
||||
}
|
||||
</div>
|
||||
<!-- Gestion des modeles installes (liste + suppression + dialog de pull) -->
|
||||
@@ -99,23 +107,23 @@
|
||||
<!-- Bloc 1min.ai -->
|
||||
@if (settings && settings.llm_provider === 'onemin') {
|
||||
<section class="card">
|
||||
<h2>Configuration 1min.ai</h2>
|
||||
<h2>{{ 'settings.onemin.title' | translate }}</h2>
|
||||
<div class="form-row">
|
||||
<label for="onemin-key">Cle API</label>
|
||||
<label for="onemin-key">{{ 'settings.apiKey.label' | translate }}</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'">
|
||||
[placeholder]="(settings.onemin_api_key_set ? 'settings.apiKey.placeholderSet' : 'settings.apiKey.placeholderOnemin') | translate">
|
||||
@if (settings.onemin_api_key_set) {
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" [(ngModel)]="clearApiKey">
|
||||
<span>Effacer la cle enregistree</span>
|
||||
<span>{{ 'settings.apiKey.clear' | translate }}</span>
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="onemin-provider">Fournisseur</label>
|
||||
<label for="onemin-provider">{{ 'settings.provider' | translate }}</label>
|
||||
<select id="onemin-provider" [(ngModel)]="oneminProvider" (ngModelChange)="onProviderChange()">
|
||||
@for (g of oneminGroups; track g) {
|
||||
<option [value]="g.provider">{{ g.provider }}</option>
|
||||
@@ -123,7 +131,7 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="onemin-model">Modele</label>
|
||||
<label for="onemin-model">{{ 'settings.model' | translate }}</label>
|
||||
<select id="onemin-model" [(ngModel)]="settings.onemin_model">
|
||||
@for (m of currentProviderModels; track m) {
|
||||
<option [value]="m">{{ m }}</option>
|
||||
@@ -136,38 +144,33 @@
|
||||
<!-- Bloc OpenRouter -->
|
||||
@if (settings && settings.llm_provider === 'openrouter') {
|
||||
<section class="card">
|
||||
<h2>Configuration OpenRouter</h2>
|
||||
<h2>{{ 'settings.openrouter.title' | translate }}</h2>
|
||||
<div class="form-row">
|
||||
<label for="openrouter-key">Cle API</label>
|
||||
<label for="openrouter-key">{{ 'settings.apiKey.label' | translate }}</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-...)'">
|
||||
[placeholder]="(settings.openrouter_api_key_set ? 'settings.apiKey.placeholderSet' : 'settings.apiKey.placeholderOpenrouter') | translate">
|
||||
@if (settings.openrouter_api_key_set) {
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" [(ngModel)]="clearOpenrouterKey">
|
||||
<span>Effacer la cle enregistree</span>
|
||||
<span>{{ 'settings.apiKey.clear' | translate }}</span>
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="openrouter-model">Modele</label>
|
||||
<label for="openrouter-model">{{ 'settings.model' | translate }}</label>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" [(ngModel)]="openrouterFreeOnly">
|
||||
<span>Gratuits seulement</span>
|
||||
<span>{{ 'settings.openrouter.freeOnly' | translate }}</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>
|
||||
<p class="hint" [innerHTML]="'settings.openrouter.hint' | translate"></p>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
@@ -175,33 +178,29 @@
|
||||
<!-- Bloc Mistral -->
|
||||
@if (settings && settings.llm_provider === 'mistral') {
|
||||
<section class="card">
|
||||
<h2>Configuration Mistral</h2>
|
||||
<h2>{{ 'settings.mistral.title' | translate }}</h2>
|
||||
<div class="form-row">
|
||||
<label for="mistral-key">Cle API</label>
|
||||
<label for="mistral-key">{{ 'settings.apiKey.label' | translate }}</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'">
|
||||
[placeholder]="(settings.mistral_api_key_set ? 'settings.apiKey.placeholderSet' : 'settings.apiKey.placeholderMistral') | translate">
|
||||
@if (settings.mistral_api_key_set) {
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" [(ngModel)]="clearMistralKey">
|
||||
<span>Effacer la cle enregistree</span>
|
||||
<span>{{ 'settings.apiKey.clear' | translate }}</span>
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="mistral-model">Modele</label>
|
||||
<label for="mistral-model">{{ 'settings.model' | translate }}</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>
|
||||
<p class="hint" [innerHTML]="'settings.mistral.hint' | translate"></p>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
@@ -209,34 +208,29 @@
|
||||
<!-- Bloc Gemini -->
|
||||
@if (settings && settings.llm_provider === 'gemini') {
|
||||
<section class="card">
|
||||
<h2>Configuration Gemini</h2>
|
||||
<h2>{{ 'settings.gemini.title' | translate }}</h2>
|
||||
<div class="form-row">
|
||||
<label for="gemini-key">Cle API</label>
|
||||
<label for="gemini-key">{{ 'settings.apiKey.label' | translate }}</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'">
|
||||
[placeholder]="(settings.gemini_api_key_set ? 'settings.apiKey.placeholderSet' : 'settings.apiKey.placeholderGemini') | translate">
|
||||
@if (settings.gemini_api_key_set) {
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" [(ngModel)]="clearGeminiKey">
|
||||
<span>Effacer la cle enregistree</span>
|
||||
<span>{{ 'settings.apiKey.clear' | translate }}</span>
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="gemini-model">Modele</label>
|
||||
<label for="gemini-model">{{ 'settings.model' | translate }}</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>
|
||||
<p class="hint" [innerHTML]="'settings.gemini.hint' | translate"></p>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
@@ -244,21 +238,18 @@
|
||||
<!-- 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>
|
||||
<h2>{{ 'settings.embeddings.title' | translate }}</h2>
|
||||
<p class="hint" [innerHTML]="'settings.embeddings.hint' | translate"></p>
|
||||
<div class="form-row">
|
||||
<label>Fournisseur d'embeddings</label>
|
||||
<label>{{ 'settings.embeddings.provider' | translate }}</label>
|
||||
<div class="radio-group">
|
||||
<label class="radio">
|
||||
<input type="radio" name="embprovider" value="ollama" [(ngModel)]="settings.embedding_provider">
|
||||
<span>Ollama (local, gratuit)</span>
|
||||
<span>{{ 'settings.embeddings.providerOllama' | translate }}</span>
|
||||
</label>
|
||||
<label class="radio">
|
||||
<input type="radio" name="embprovider" value="mistral" [(ngModel)]="settings.embedding_provider">
|
||||
<span>Mistral (cloud, EU)</span>
|
||||
<span>{{ 'settings.embeddings.providerMistral' | translate }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -266,57 +257,46 @@
|
||||
@if (settings.embedding_provider === 'ollama') {
|
||||
<div>
|
||||
<div class="form-row">
|
||||
<label for="emb-ollama-model">Modèle d'embedding Ollama</label>
|
||||
<label for="emb-ollama-model">{{ 'settings.embeddings.ollamaModel' | translate }}</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>
|
||||
<p class="hint" [innerHTML]="'settings.embeddings.ollamaModelHint' | translate"></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>
|
||||
<span>{{ 'settings.embeddings.autoPull' | translate }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div class="form-row">
|
||||
<label for="rag-topk">Extraits récupérés par question (couverture RAG)</label>
|
||||
<label for="rag-topk">{{ 'settings.embeddings.topK' | translate }}</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>
|
||||
<p class="hint" [innerHTML]="'settings.embeddings.topKHint' | translate"></p>
|
||||
</div>
|
||||
<!-- Mistral embeddings -->
|
||||
@if (settings.embedding_provider === 'mistral') {
|
||||
<div>
|
||||
<div class="form-row">
|
||||
<label for="emb-mistral-key">
|
||||
Clé API Mistral
|
||||
{{ 'settings.embeddings.mistralKey' | translate }}
|
||||
@if (settings.mistral_api_key_set) {
|
||||
<span class="key-state">✓ configurée</span>
|
||||
<span class="key-state">{{ 'settings.apiKey.configured' | translate }}</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'">
|
||||
[placeholder]="(settings.mistral_api_key_set ? 'settings.apiKey.placeholderSet' : 'settings.apiKey.placeholderMistral') | translate">
|
||||
@if (settings.mistral_api_key_set) {
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" [(ngModel)]="clearMistralKey">
|
||||
<span>Effacer la clé enregistrée</span>
|
||||
<span>{{ 'settings.apiKey.clear' | translate }}</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>
|
||||
<p class="hint" [innerHTML]="'settings.embeddings.mistralKeyHint' | translate"></p>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="emb-mistral-model">Modèle d'embedding Mistral</label>
|
||||
<label for="emb-mistral-model">{{ 'settings.embeddings.mistralModel' | translate }}</label>
|
||||
<input id="emb-mistral-model" type="text" [(ngModel)]="settings.mistral_embedding_model"
|
||||
placeholder="mistral-embed">
|
||||
</div>
|
||||
@@ -330,16 +310,16 @@
|
||||
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>
|
||||
<h2>{{ 'settings.context.title' | translate }}</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
|
||||
{{ 'settings.context.tokens' | translate }}
|
||||
<span class="ctx-value">{{ settings.llm_num_ctx | number }}</span>
|
||||
@if (ollamaModelMaxContext > 0) {
|
||||
<span class="ctx-max">
|
||||
/ {{ ollamaModelMaxContext | number }} max
|
||||
{{ 'settings.context.max' | translate:{ max: (ollamaModelMaxContext | number) } }}
|
||||
</span>
|
||||
}
|
||||
</label>
|
||||
@@ -352,17 +332,10 @@
|
||||
[(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>
|
||||
<p class="hint" [innerHTML]="'settings.context.hintKnown' | translate:{ model: settings.llm_model, max: (ollamaModelMaxContext | number) }"></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>
|
||||
<p class="hint" [innerHTML]="'settings.context.hintUnknown' | translate:{ max: (CTX_FALLBACK_MAX | number) }"></p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -372,39 +345,26 @@
|
||||
<!-- Bloc Import de PDF -->
|
||||
@if (settings) {
|
||||
<section class="card">
|
||||
<h2>Import de PDF</h2>
|
||||
<h2>{{ 'settings.pdf.title' | translate }}</h2>
|
||||
<div class="form-row">
|
||||
<label for="import-chunk-tokens">Taille des morceaux a l'import (tokens)</label>
|
||||
<label for="import-chunk-tokens">{{ 'settings.pdf.chunkTokens' | translate }}</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>
|
||||
<p class="hint" [innerHTML]="'settings.pdf.chunkTokensHint' | translate"></p>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="llm-timeout">Timeout des appels IA (secondes)</label>
|
||||
<label for="llm-timeout">{{ 'settings.pdf.timeout' | translate }}</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>
|
||||
<p class="hint" [innerHTML]="'settings.pdf.timeoutHint' | translate"></p>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
@@ -416,7 +376,7 @@
|
||||
<div class="actions">
|
||||
<button class="btn-primary" (click)="save()" [disabled]="saving">
|
||||
<lucide-icon [img]="Save" [size]="16"></lucide-icon>
|
||||
<span>{{ saving ? 'Sauvegarde...' : 'Sauvegarder' }}</span>
|
||||
<span>{{ (saving ? 'common.saving' : 'common.save') | translate }}</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -3,10 +3,12 @@ import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { LucideAngularModule, ArrowLeft, RefreshCw, Save, Check, AlertCircle, Plus } from 'lucide-angular';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { SettingsService, AppSettings, AppSettingsUpdate, OneMinModelGroup, OpenRouterModel, MistralModel, GeminiModel } from '../services/settings.service';
|
||||
import { LayoutService } from '../services/layout.service';
|
||||
import { UpdatesSectionComponent } from './updates-section/updates-section.component';
|
||||
import { OllamaModelManagerComponent } from './ollama-model-manager/ollama-model-manager.component';
|
||||
import { LanguageSwitcherComponent } from '../shared/language-switcher/language-switcher.component';
|
||||
|
||||
/**
|
||||
* Ecran de parametrage du LLM utilise par le Brain.
|
||||
@@ -24,7 +26,7 @@ import { OllamaModelManagerComponent } from './ollama-model-manager/ollama-model
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-settings',
|
||||
imports: [CommonModule, FormsModule, LucideAngularModule, UpdatesSectionComponent, OllamaModelManagerComponent],
|
||||
imports: [CommonModule, FormsModule, LucideAngularModule, TranslatePipe, UpdatesSectionComponent, OllamaModelManagerComponent, LanguageSwitcherComponent],
|
||||
templateUrl: './settings.component.html',
|
||||
styleUrls: ['./settings.component.scss']
|
||||
})
|
||||
@@ -93,7 +95,8 @@ export class SettingsComponent implements OnInit {
|
||||
constructor(
|
||||
private settingsService: SettingsService,
|
||||
private router: Router,
|
||||
private layoutService: LayoutService
|
||||
private layoutService: LayoutService,
|
||||
private translate: TranslateService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -110,7 +113,7 @@ export class SettingsComponent implements OnInit {
|
||||
this.refreshModels();
|
||||
this.fetchOllamaModelInfo();
|
||||
},
|
||||
error: (err) => this.errorMessage = this.extractError(err, 'Impossible de charger les parametres.')
|
||||
error: (err) => this.errorMessage = this.extractError(err, this.translate.instant('settings.messages.loadError'))
|
||||
});
|
||||
}
|
||||
|
||||
@@ -315,11 +318,11 @@ export class SettingsComponent implements OnInit {
|
||||
this.clearMistralKey = false;
|
||||
this.geminiApiKeyInput = '';
|
||||
this.clearGeminiKey = false;
|
||||
this.successMessage = 'Parametres sauvegardes.';
|
||||
this.successMessage = this.translate.instant('settings.messages.saveSuccess');
|
||||
this.saving = false;
|
||||
},
|
||||
error: (err) => {
|
||||
this.errorMessage = this.extractError(err, 'Echec de la sauvegarde.');
|
||||
this.errorMessage = this.extractError(err, this.translate.instant('settings.messages.saveError'));
|
||||
this.saving = false;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
<!-- Bloc Mises a jour (canal stable + canal beta Patreon fusionnes) -->
|
||||
@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>
|
||||
<h2>{{ 'updatesSection.title' | translate }}</h2>
|
||||
<p class="hint">{{ 'updatesSection.hint' | translate }}</p>
|
||||
@if (licenseSuccess) {
|
||||
<div class="alert alert-success">
|
||||
<lucide-icon [img]="Check" [size]="16"></lucide-icon>
|
||||
@@ -16,16 +14,16 @@
|
||||
<!-- ====================================================== -->
|
||||
@if (config.updateCheckEnabled) {
|
||||
<div class="channel-block">
|
||||
<h3 class="channel-title">Canal stable</h3>
|
||||
<h3 class="channel-title">{{ 'updatesSection.stableChannel' | translate }}</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>
|
||||
<span>{{ (updateChecking ? 'updatesSection.checking' : 'updatesSection.checkNow') | translate }}</span>
|
||||
</button>
|
||||
</div>
|
||||
@if (updateStatus && !updateStatus.enabled) {
|
||||
<div class="hint">
|
||||
Feature non configuree (WATCHTOWER_TOKEN absent).
|
||||
{{ 'updatesSection.featureNotConfigured' | translate }}
|
||||
</div>
|
||||
}
|
||||
@if (updateStatus?.enabled) {
|
||||
@@ -33,25 +31,25 @@
|
||||
@if (updateStatus?.updateAvailable) {
|
||||
<div class="alert alert-success">
|
||||
<lucide-icon [img]="Download" [size]="16"></lucide-icon>
|
||||
<span>Une mise a jour est disponible.</span>
|
||||
<span>{{ 'updatesSection.updateAvailable' | translate }}</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>
|
||||
<span>{{ 'updatesSection.checkImpossible' | translate }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (!updateStatus?.updateAvailable && !updateStatus?.anyUnknown) {
|
||||
<div class="hint">
|
||||
Tout est a jour (verifie le {{ updateStatus?.checkedAt | date:'short' }}).
|
||||
{{ 'updatesSection.upToDate' | translate:{ date: (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>
|
||||
<span>{{ (updateApplying ? 'updatesSection.applying' : 'updatesSection.applyNow') | translate }}</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@@ -72,26 +70,20 @@
|
||||
<div class="channel-block">
|
||||
<h3 class="channel-title">
|
||||
<lucide-icon [img]="Heart" [size]="16"></lucide-icon>
|
||||
Canal beta — reserve aux patrons
|
||||
{{ 'updatesSection.betaChannelTitle' | translate }}
|
||||
</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>
|
||||
<p class="hint" [innerHTML]="'updatesSection.betaChannelIntro' | translate"></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>
|
||||
<span>{{ 'updatesSection.connectPatreon' | translate }}</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>
|
||||
<p class="hint">{{ 'updatesSection.connectInstructions' | translate }}</p>
|
||||
<div class="form-row">
|
||||
<label for="license-jwt">Token Patreon</label>
|
||||
<label for="license-jwt">{{ 'updatesSection.patreonToken' | translate }}</label>
|
||||
<input
|
||||
id="license-jwt"
|
||||
type="text"
|
||||
@@ -103,7 +95,7 @@
|
||||
<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>
|
||||
<span>{{ 'updatesSection.activateLicense' | translate }}</span>
|
||||
</button>
|
||||
</div>
|
||||
@if (licenseError) {
|
||||
@@ -118,58 +110,49 @@
|
||||
@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>
|
||||
<span>{{ 'updatesSection.licenseValid' | translate:{ tier: tierLabel(licenseStatus.tierId) } }}</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>
|
||||
<span>{{ 'updatesSection.licenseGrace' | translate }}</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>
|
||||
<span>{{ 'updatesSection.licenseExpired' | translate }}</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>
|
||||
<span>{{ 'updatesSection.licenseUnverifiable' | translate }}</span>
|
||||
</div>
|
||||
}
|
||||
<ul class="license-info">
|
||||
@if (licenseStatus.tierId) {
|
||||
<li><strong>Tier :</strong> {{ tierLabel(licenseStatus.tierId) }}</li>
|
||||
<li [innerHTML]="'updatesSection.infoTier' | translate:{ tier: tierLabel(licenseStatus.tierId) }"></li>
|
||||
}
|
||||
@if (licenseStatus.expiresAt) {
|
||||
<li>
|
||||
<strong>Validite :</strong>
|
||||
jusqu'au {{ formatDate(licenseStatus.expiresAt) }}
|
||||
<span [innerHTML]="'updatesSection.infoValidity' | translate:{ date: formatDate(licenseStatus.expiresAt) }"></span>
|
||||
@if (daysUntilExpiry !== null && daysUntilExpiry > 0) {
|
||||
<span>
|
||||
(renouvellement dans {{ daysUntilExpiry }} jour@if (daysUntilExpiry > 1) {
|
||||
<span>s</span>
|
||||
})
|
||||
{{ (daysUntilExpiry > 1 ? 'updatesSection.renewalDays' : 'updatesSection.renewalDay') | translate:{ n: daysUntilExpiry } }}
|
||||
</span>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
@if (licenseStatus.lastRefreshAttemptAt) {
|
||||
<li>
|
||||
<strong>Dernier refresh :</strong>
|
||||
{{ formatDate(licenseStatus.lastRefreshAttemptAt) }}
|
||||
<span [innerHTML]="'updatesSection.infoLastRefresh' | translate:{ date: formatDate(licenseStatus.lastRefreshAttemptAt) }"></span>
|
||||
@if (licenseStatus.lastRefreshSucceeded === true) {
|
||||
<span class="badge-ok">OK</span>
|
||||
<span class="badge-ok">{{ 'updatesSection.refreshOk' | translate }}</span>
|
||||
}
|
||||
@if (licenseStatus.lastRefreshSucceeded === false) {
|
||||
<span class="badge-warn">echec</span>
|
||||
<span class="badge-warn">{{ 'updatesSection.refreshFailed' | translate }}</span>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
@@ -182,28 +165,28 @@
|
||||
(change)="toggleBetaChannel(!licenseStatus.betaChannelEnabled)"
|
||||
[disabled]="licenseStatus.status !== 'VALID' && licenseStatus.status !== 'GRACE'"
|
||||
>
|
||||
<span>Activer le canal beta</span>
|
||||
<span>{{ 'updatesSection.enableBetaChannel' | translate }}</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>
|
||||
<span>{{ (licenseLoading ? 'updatesSection.checking' : 'updatesSection.checkNow') | translate }}</span>
|
||||
</button>
|
||||
<button type="button" class="btn-secondary btn-danger" (click)="disconnectPatreon()">
|
||||
<lucide-icon [img]="Unlink" [size]="14"></lucide-icon>
|
||||
<span>Deconnecter Patreon</span>
|
||||
<span>{{ 'updatesSection.disconnectPatreon' | translate }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Etat du canal beta -->
|
||||
@if (licenseStatus.betaChannelEnabled) {
|
||||
<div class="beta-status">
|
||||
@if (betaChecking) {
|
||||
<div class="hint">Verification des images beta...</div>
|
||||
<div class="hint">{{ 'updatesSection.checkingBetaImages' | translate }}</div>
|
||||
}
|
||||
@if (!betaChecking && betaStatus && !betaStatus.enabled) {
|
||||
<div class="hint">
|
||||
Indisponible : {{ betaStatus.disabledReason }}
|
||||
{{ 'updatesSection.betaUnavailable' | translate:{ reason: betaStatus.disabledReason } }}
|
||||
</div>
|
||||
}
|
||||
@if (!betaChecking && betaStatus?.enabled) {
|
||||
@@ -211,7 +194,7 @@
|
||||
@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>
|
||||
<span>{{ 'updatesSection.betaCheckImpossible' | translate }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -222,11 +205,11 @@
|
||||
@if (channelStatus) {
|
||||
<div class="channel-switch">
|
||||
<div class="channel-current">
|
||||
<span class="channel-label">Canal actuel :</span>
|
||||
<span class="channel-label">{{ 'updatesSection.currentChannel' | translate }}</span>
|
||||
<span class="channel-badge"
|
||||
[class.channel-stable]="channelStatus.currentChannel === 'stable'"
|
||||
[class.channel-beta]="channelStatus.currentChannel === 'beta'">
|
||||
{{ channelStatus.currentChannel === 'beta' ? 'Bêta' : 'Stable' }}
|
||||
{{ (channelStatus.currentChannel === 'beta' ? 'updatesSection.channelBeta' : 'updatesSection.channelStable') | translate }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Sidecar dispo : boutons d'action -->
|
||||
@@ -238,7 +221,7 @@
|
||||
[disabled]="switchInFlight"
|
||||
(click)="requestChannelSwitch('beta')">
|
||||
<lucide-icon [img]="Download" [size]="14"></lucide-icon>
|
||||
<span>{{ switchInFlight ? 'Bascule en cours...' : 'Passer sur le canal beta' }}</span>
|
||||
<span>{{ (switchInFlight ? 'updatesSection.switching' : 'updatesSection.switchToBeta') | translate }}</span>
|
||||
</button>
|
||||
}
|
||||
<!-- On beta -> proposer retour stable -->
|
||||
@@ -248,14 +231,14 @@
|
||||
[disabled]="switchInFlight"
|
||||
(click)="requestChannelSwitch('stable')">
|
||||
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
|
||||
<span>{{ switchInFlight ? 'Bascule en cours...' : 'Repasser sur le canal stable' }}</span>
|
||||
<span>{{ (switchInFlight ? 'updatesSection.switching' : 'updatesSection.switchToStable') | translate }}</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>
|
||||
<span>{{ 'updatesSection.switchInProgressNotice' | translate }}</span>
|
||||
</div>
|
||||
}
|
||||
<!-- Erreur eventuelle remontee par le sidecar -->
|
||||
@@ -270,14 +253,7 @@
|
||||
@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 && 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>
|
||||
<span [innerHTML]="'updatesSection.switcherUnavailable' | translate"></span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { UpdatesService, UpdateStatus } from '../../services/updates.service';
|
||||
import { ConfigService } from '../../services/config.service';
|
||||
import { LicenseService, LicenseStatusDTO, BetaStatusDTO, ChannelStatusDTO, ChannelName } from '../../services/license.service';
|
||||
@@ -22,7 +23,7 @@ import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-settings-updates-section',
|
||||
imports: [CommonModule, FormsModule, LucideAngularModule],
|
||||
imports: [CommonModule, FormsModule, LucideAngularModule, TranslatePipe],
|
||||
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, …).
|
||||
@@ -72,7 +73,8 @@ export class UpdatesSectionComponent implements OnInit, OnDestroy {
|
||||
private updatesService: UpdatesService,
|
||||
public config: ConfigService,
|
||||
private licenseService: LicenseService,
|
||||
private confirmDialog: ConfirmDialogService
|
||||
private confirmDialog: ConfirmDialogService,
|
||||
private translate: TranslateService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -112,7 +114,7 @@ export class UpdatesSectionComponent implements OnInit, OnDestroy {
|
||||
this.licenseService.getConnectUrl().subscribe({
|
||||
next: (r) => {
|
||||
if (!r?.url) {
|
||||
this.licenseError = 'Impossible de generer l\'URL de connexion. Verifie ta config.';
|
||||
this.licenseError = this.translate.instant('updatesSection.connectUrlError');
|
||||
return;
|
||||
}
|
||||
window.open(r.url, '_blank', 'noopener');
|
||||
@@ -123,7 +125,7 @@ export class UpdatesSectionComponent implements OnInit, OnDestroy {
|
||||
installLicense(): void {
|
||||
const jwt = this.licenseJwtInput.trim();
|
||||
if (!jwt) {
|
||||
this.licenseError = 'Colle d\'abord le token recu apres connexion Patreon.';
|
||||
this.licenseError = this.translate.instant('updatesSection.pasteTokenFirst');
|
||||
return;
|
||||
}
|
||||
this.licenseError = '';
|
||||
@@ -134,7 +136,7 @@ export class UpdatesSectionComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
this.licenseStatus = res as LicenseStatusDTO;
|
||||
this.licenseJwtInput = '';
|
||||
this.licenseSuccess = 'Compte Patreon connecte. L\'acces beta est actif.';
|
||||
this.licenseSuccess = this.translate.instant('updatesSection.patreonConnectedSuccess');
|
||||
if (this.licenseStatus.betaChannelEnabled) {
|
||||
this.checkBeta();
|
||||
}
|
||||
@@ -154,17 +156,17 @@ export class UpdatesSectionComponent implements OnInit, OnDestroy {
|
||||
|
||||
disconnectPatreon(): void {
|
||||
this.confirmDialog.confirm({
|
||||
title: 'Deconnecter Patreon',
|
||||
message: 'Deconnecter ton compte Patreon ?',
|
||||
details: ['Tu perdras l\'acces au canal beta.'],
|
||||
confirmLabel: 'Deconnecter',
|
||||
title: this.translate.instant('updatesSection.disconnectTitle'),
|
||||
message: this.translate.instant('updatesSection.disconnectMessage'),
|
||||
details: [this.translate.instant('updatesSection.disconnectDetail')],
|
||||
confirmLabel: this.translate.instant('updatesSection.disconnectConfirm'),
|
||||
variant: 'warning'
|
||||
}).then(ok => {
|
||||
if (!ok) return;
|
||||
this.licenseService.disconnect().subscribe(() => {
|
||||
this.licenseStatus = null;
|
||||
this.betaStatus = null;
|
||||
this.licenseSuccess = 'Compte Patreon deconnecte.';
|
||||
this.licenseSuccess = this.translate.instant('updatesSection.patreonDisconnected');
|
||||
this.loadLicense();
|
||||
});
|
||||
});
|
||||
@@ -216,17 +218,21 @@ export class UpdatesSectionComponent implements OnInit, OnDestroy {
|
||||
*/
|
||||
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.translate.instant('updatesSection.switchToBetaMessage')
|
||||
: this.translate.instant('updatesSection.switchToStableMessage');
|
||||
|
||||
this.confirmDialog.confirm({
|
||||
title: target === 'beta' ? 'Passer en beta ?' : 'Repasser en stable ?',
|
||||
title: target === 'beta'
|
||||
? this.translate.instant('updatesSection.switchToBetaTitle')
|
||||
: this.translate.instant('updatesSection.switchToStableTitle'),
|
||||
message: confirmMessage,
|
||||
details: [
|
||||
'Les donnees (DB, images) sont preservees.',
|
||||
'Tu pourras refaire le chemin inverse a tout moment depuis cet ecran.'
|
||||
this.translate.instant('updatesSection.switchDetailData'),
|
||||
this.translate.instant('updatesSection.switchDetailReversible')
|
||||
],
|
||||
confirmLabel: target === 'beta' ? 'Passer en beta' : 'Repasser en stable',
|
||||
confirmLabel: target === 'beta'
|
||||
? this.translate.instant('updatesSection.switchToBetaConfirm')
|
||||
: this.translate.instant('updatesSection.switchToStableConfirm'),
|
||||
variant: 'warning'
|
||||
}).then(ok => {
|
||||
if (!ok) return;
|
||||
@@ -269,7 +275,7 @@ export class UpdatesSectionComponent implements OnInit, OnDestroy {
|
||||
this.stopSwitchPolling();
|
||||
this.switchInFlight = false;
|
||||
} else if (last.status === 'ERROR') {
|
||||
this.switchError = last.message || 'Echec du switch';
|
||||
this.switchError = last.message || this.translate.instant('updatesSection.switchFailed');
|
||||
this.stopSwitchPolling();
|
||||
this.switchInFlight = false;
|
||||
}
|
||||
@@ -336,10 +342,10 @@ export class UpdatesSectionComponent implements OnInit, OnDestroy {
|
||||
|
||||
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',
|
||||
title: this.translate.instant('updatesSection.applyTitle'),
|
||||
message: this.translate.instant('updatesSection.applyMessage'),
|
||||
details: [this.translate.instant('updatesSection.applyDetail')],
|
||||
confirmLabel: this.translate.instant('updatesSection.applyConfirm'),
|
||||
variant: 'warning'
|
||||
}).then(ok => {
|
||||
if (!ok) return;
|
||||
@@ -351,11 +357,11 @@ export class UpdatesSectionComponent implements OnInit, OnDestroy {
|
||||
// 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.';
|
||||
?? this.translate.instant('updatesSection.applyTriggered');
|
||||
},
|
||||
error: () => {
|
||||
this.updateApplying = false;
|
||||
this.updateMessage = 'Mise a jour declenchee. Rechargez la page dans 30s.';
|
||||
this.updateMessage = this.translate.instant('updatesSection.applyTriggered');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user