395 lines
14 KiB
TypeScript
395 lines
14 KiB
TypeScript
import { Component, OnInit } from '@angular/core';
|
|
import { CommonModule } from '@angular/common';
|
|
import { FormsModule } from '@angular/forms';
|
|
import { Router } from '@angular/router';
|
|
import { LucideAngularModule, ArrowLeft, RefreshCw, Save, Check, AlertCircle, Plus } from 'lucide-angular';
|
|
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
|
import { SettingsService, AppSettings, AppSettingsUpdate, OneMinModelGroup, OpenRouterModel, MistralModel, GeminiModel, ImportResult } 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.
|
|
*
|
|
* Responsabilite : le FORMULAIRE de configuration (fournisseur LLM, modeles,
|
|
* cles API, embeddings RAG, fenetre de contexte, import PDF) + sa sauvegarde.
|
|
*
|
|
* Les responsabilites voisines sont des sous-composants standalone :
|
|
* - UpdatesSectionComponent : mises a jour conteneurs + licence Patreon + canal beta ;
|
|
* - OllamaModelManagerComponent : pull/suppression des modeles Ollama.
|
|
*
|
|
* Les modifications sont persistees cote Brain dans data/settings.json
|
|
* (fichier local, usage mono-utilisateur) et appliquees a la prochaine
|
|
* requete chat / generate — pas besoin de redemarrer.
|
|
*/
|
|
@Component({
|
|
selector: 'app-settings',
|
|
imports: [CommonModule, FormsModule, LucideAngularModule, TranslatePipe, UpdatesSectionComponent, OllamaModelManagerComponent, LanguageSwitcherComponent],
|
|
templateUrl: './settings.component.html',
|
|
styleUrls: ['./settings.component.scss']
|
|
})
|
|
export class SettingsComponent implements OnInit {
|
|
|
|
readonly ArrowLeft = ArrowLeft;
|
|
readonly RefreshCw = RefreshCw;
|
|
readonly Save = Save;
|
|
readonly Check = Check;
|
|
readonly AlertCircle = AlertCircle;
|
|
readonly Plus = Plus;
|
|
|
|
settings: AppSettings | null = null;
|
|
ollamaModels: string[] = [];
|
|
oneminGroups: OneMinModelGroup[] = [];
|
|
|
|
/** Catalogue OpenRouter (chargé dynamiquement) + filtre "gratuits seulement". */
|
|
openrouterModels: OpenRouterModel[] = [];
|
|
openrouterFreeOnly = true;
|
|
|
|
/** Catalogue Mistral (dynamique si cle configuree, repli statique sinon). */
|
|
mistralModels: MistralModel[] = [];
|
|
|
|
/** Catalogue Gemini (dynamique si cle configuree, repli statique sinon). */
|
|
geminiModels: GeminiModel[] = [];
|
|
/** Fournisseur 1min.ai actuellement selectionne (filtre la liste des modeles). */
|
|
oneminProvider: string = '';
|
|
|
|
loadingModels = false;
|
|
saving = false;
|
|
errorMessage = '';
|
|
successMessage = '';
|
|
|
|
/**
|
|
* Fenetre de contexte max supportee par le modele Ollama actuellement
|
|
* selectionne (extraite des metadonnees GGUF via /api/show). 0 si inconnue
|
|
* — dans ce cas on laisse un fallback de 131072 cote UI.
|
|
*/
|
|
ollamaModelMaxContext = 0;
|
|
|
|
/** Minimum raisonnable pour num_ctx (defaut Ollama = 2048). */
|
|
readonly CTX_MIN = 2048;
|
|
/** Fallback si Ollama ne renvoie pas le context_length (modele exotique). */
|
|
readonly CTX_FALLBACK_MAX = 131072;
|
|
|
|
/** Cle 1min.ai saisie — vide = on ne touche pas a la cle persistee. */
|
|
oneminApiKeyInput = '';
|
|
/** True si l'utilisateur a coche "effacer la cle". */
|
|
clearApiKey = false;
|
|
|
|
/** Cle OpenRouter saisie — vide = on ne touche pas a la cle persistee. */
|
|
openrouterApiKeyInput = '';
|
|
/** True si l'utilisateur a coche "effacer la cle OpenRouter". */
|
|
clearOpenrouterKey = false;
|
|
|
|
/** Cle Mistral saisie — vide = on ne touche pas a la cle persistee. */
|
|
mistralApiKeyInput = '';
|
|
/** True si l'utilisateur a coche "effacer la cle Mistral". */
|
|
clearMistralKey = false;
|
|
|
|
/** Cle Gemini saisie — vide = on ne touche pas a la cle persistee. */
|
|
geminiApiKeyInput = '';
|
|
/** True si l'utilisateur a coche "effacer la cle Gemini". */
|
|
clearGeminiKey = false;
|
|
|
|
constructor(
|
|
private settingsService: SettingsService,
|
|
private router: Router,
|
|
private layoutService: LayoutService,
|
|
private translate: TranslateService
|
|
) {}
|
|
|
|
ngOnInit(): void {
|
|
// Page racine : on s'assure de ne pas heriter de la sidebar d'une
|
|
// section precedente (cf. fix CampaignsComponent / LoreComponent).
|
|
this.layoutService.hide();
|
|
this.loadSettings();
|
|
}
|
|
|
|
loadSettings(): void {
|
|
this.settingsService.getSettings().subscribe({
|
|
next: (s) => {
|
|
this.settings = { ...s };
|
|
this.refreshModels();
|
|
this.fetchOllamaModelInfo();
|
|
},
|
|
error: (err) => this.errorMessage = this.extractError(err, this.translate.instant('settings.messages.loadError'))
|
|
});
|
|
}
|
|
|
|
refreshModels(): void {
|
|
if (!this.settings) return;
|
|
this.loadingModels = true;
|
|
|
|
this.settingsService.listOllamaModels().subscribe({
|
|
next: (r) => this.ollamaModels = r.models,
|
|
error: () => this.ollamaModels = [],
|
|
complete: () => this.loadingModels = false
|
|
});
|
|
|
|
this.settingsService.listOneMinModels().subscribe({
|
|
next: (r) => {
|
|
this.oneminGroups = r.groups;
|
|
this.syncOneminProviderFromModel();
|
|
},
|
|
error: () => this.oneminGroups = []
|
|
});
|
|
|
|
this.settingsService.listOpenRouterModels().subscribe({
|
|
next: (r) => this.openrouterModels = r.models,
|
|
error: () => this.openrouterModels = []
|
|
});
|
|
|
|
this.settingsService.listMistralModels().subscribe({
|
|
next: (r) => this.mistralModels = r.models,
|
|
error: () => this.mistralModels = []
|
|
});
|
|
|
|
this.settingsService.listGeminiModels().subscribe({
|
|
next: (r) => this.geminiModels = r.models,
|
|
error: () => this.geminiModels = []
|
|
});
|
|
}
|
|
|
|
// --- Evenements du gestionnaire de modeles Ollama (sous-composant) -------
|
|
|
|
/** Un modele vient d'etre telecharge : si aucun modele n'etait selectionne, on le prend. */
|
|
onModelPulled(name: string): void {
|
|
if (this.settings && !this.settings.llm_model) {
|
|
this.settings.llm_model = name;
|
|
this.fetchOllamaModelInfo();
|
|
}
|
|
}
|
|
|
|
/** Le modele selectionne a ete supprime : on vide la selection. */
|
|
onModelDeleted(name: string): void {
|
|
if (this.settings && this.settings.llm_model === name) {
|
|
this.settings.llm_model = '';
|
|
this.ollamaModelMaxContext = 0;
|
|
}
|
|
}
|
|
|
|
/** Options du select Mistral : liste + valeur courante garantie selectionnable. */
|
|
get mistralSelectOptions(): { id: string; label: string }[] {
|
|
const options: { id: string; label: string }[] =
|
|
this.mistralModels.map(m => ({ id: m.id, label: m.id }));
|
|
const cur = this.settings?.mistral_model;
|
|
if (cur && !options.some(o => o.id === cur)) {
|
|
options.unshift({ id: cur, label: `${cur} (actuel)` });
|
|
}
|
|
return options;
|
|
}
|
|
|
|
/** Options du select Gemini : liste + valeur courante garantie selectionnable. */
|
|
get geminiSelectOptions(): { id: string; label: string }[] {
|
|
const options: { id: string; label: string }[] =
|
|
this.geminiModels.map(m => ({ id: m.id, label: m.id }));
|
|
const cur = this.settings?.gemini_model;
|
|
if (cur && !options.some(o => o.id === cur)) {
|
|
options.unshift({ id: cur, label: `${cur} (actuel)` });
|
|
}
|
|
return options;
|
|
}
|
|
|
|
/** Modeles OpenRouter, filtres (gratuits seulement par defaut). */
|
|
get filteredOpenrouterModels(): OpenRouterModel[] {
|
|
return this.openrouterFreeOnly
|
|
? this.openrouterModels.filter(m => m.free)
|
|
: this.openrouterModels;
|
|
}
|
|
|
|
/** Options du select OpenRouter : routeur auto + liste filtree + valeur courante. */
|
|
get openrouterSelectOptions(): { id: string; label: string }[] {
|
|
const options: { id: string; label: string }[] = [
|
|
{ id: 'openrouter/free', label: 'openrouter/free — routeur auto (gratuit)' }
|
|
];
|
|
for (const m of this.filteredOpenrouterModels) {
|
|
const ctx = m.context_length ? ` — ${m.context_length.toLocaleString()} ctx` : '';
|
|
options.push({ id: m.id, label: `${m.id}${ctx}${m.free ? ' · gratuit' : ''}` });
|
|
}
|
|
// Garantit que le modele actuellement configure reste selectionnable.
|
|
const cur = this.settings?.openrouter_model;
|
|
if (cur && !options.some(o => o.id === cur)) {
|
|
options.splice(1, 0, { id: cur, label: `${cur} (actuel)` });
|
|
}
|
|
return options;
|
|
}
|
|
|
|
/** Deduit le fournisseur a partir du modele actuellement configure. */
|
|
private syncOneminProviderFromModel(): void {
|
|
if (!this.settings) return;
|
|
const currentModel = this.settings.onemin_model;
|
|
const found = this.oneminGroups.find(g => g.models.includes(currentModel));
|
|
this.oneminProvider = found ? found.provider : (this.oneminGroups[0]?.provider ?? '');
|
|
}
|
|
|
|
/** Retourne la liste des modeles du fournisseur selectionne. */
|
|
get currentProviderModels(): string[] {
|
|
const group = this.oneminGroups.find(g => g.provider === this.oneminProvider);
|
|
return group ? group.models : [];
|
|
}
|
|
|
|
/**
|
|
* Recupere la fenetre max supportee par le modele Ollama selectionne.
|
|
* Si la valeur courante de num_ctx depasse ce max, on la clamp.
|
|
*/
|
|
fetchOllamaModelInfo(): void {
|
|
if (!this.settings || this.settings.llm_provider !== 'ollama') return;
|
|
const modelName = this.settings.llm_model;
|
|
if (!modelName) return;
|
|
this.settingsService.getOllamaModelInfo(modelName).subscribe({
|
|
next: (info) => {
|
|
this.ollamaModelMaxContext = info.context_length;
|
|
const max = this.effectiveMaxContext;
|
|
if (this.settings && this.settings.llm_num_ctx > max) {
|
|
this.settings.llm_num_ctx = max;
|
|
}
|
|
},
|
|
error: () => this.ollamaModelMaxContext = 0
|
|
});
|
|
}
|
|
|
|
/** Max effectif a afficher pour le slider (modele Ollama ou fallback). */
|
|
get effectiveMaxContext(): number {
|
|
return this.ollamaModelMaxContext > 0 ? this.ollamaModelMaxContext : this.CTX_FALLBACK_MAX;
|
|
}
|
|
|
|
/** Quand on change de fournisseur, bascule automatiquement sur son premier modele. */
|
|
onProviderChange(): void {
|
|
if (!this.settings) return;
|
|
const models = this.currentProviderModels;
|
|
if (models.length > 0 && !models.includes(this.settings.onemin_model)) {
|
|
this.settings.onemin_model = models[0];
|
|
}
|
|
}
|
|
|
|
save(): void {
|
|
if (!this.settings) return;
|
|
this.saving = true;
|
|
this.errorMessage = '';
|
|
this.successMessage = '';
|
|
|
|
const patch: AppSettingsUpdate = {
|
|
llm_provider: this.settings.llm_provider,
|
|
ollama_base_url: this.settings.ollama_base_url,
|
|
llm_model: this.settings.llm_model,
|
|
onemin_model: this.settings.onemin_model,
|
|
openrouter_model: this.settings.openrouter_model,
|
|
mistral_model: this.settings.mistral_model,
|
|
gemini_model: this.settings.gemini_model,
|
|
embedding_provider: this.settings.embedding_provider,
|
|
ollama_embedding_model: this.settings.ollama_embedding_model,
|
|
mistral_embedding_model: this.settings.mistral_embedding_model,
|
|
auto_pull_embedding_model: this.settings.auto_pull_embedding_model,
|
|
rag_top_k: this.settings.rag_top_k,
|
|
llm_num_ctx: this.settings.llm_num_ctx,
|
|
import_chunk_tokens: this.settings.import_chunk_tokens,
|
|
llm_timeout_seconds: this.settings.llm_timeout_seconds
|
|
};
|
|
if (this.clearApiKey) {
|
|
patch.onemin_api_key = '';
|
|
} else if (this.oneminApiKeyInput.trim()) {
|
|
patch.onemin_api_key = this.oneminApiKeyInput.trim();
|
|
}
|
|
if (this.clearOpenrouterKey) {
|
|
patch.openrouter_api_key = '';
|
|
} else if (this.openrouterApiKeyInput.trim()) {
|
|
patch.openrouter_api_key = this.openrouterApiKeyInput.trim();
|
|
}
|
|
if (this.clearMistralKey) {
|
|
patch.mistral_api_key = '';
|
|
} else if (this.mistralApiKeyInput.trim()) {
|
|
patch.mistral_api_key = this.mistralApiKeyInput.trim();
|
|
}
|
|
if (this.clearGeminiKey) {
|
|
patch.gemini_api_key = '';
|
|
} else if (this.geminiApiKeyInput.trim()) {
|
|
patch.gemini_api_key = this.geminiApiKeyInput.trim();
|
|
}
|
|
|
|
this.settingsService.updateSettings(patch).subscribe({
|
|
next: (s) => {
|
|
this.settings = { ...s };
|
|
this.oneminApiKeyInput = '';
|
|
this.clearApiKey = false;
|
|
this.openrouterApiKeyInput = '';
|
|
this.clearOpenrouterKey = false;
|
|
this.mistralApiKeyInput = '';
|
|
this.clearMistralKey = false;
|
|
this.geminiApiKeyInput = '';
|
|
this.clearGeminiKey = false;
|
|
this.successMessage = this.translate.instant('settings.messages.saveSuccess');
|
|
this.saving = false;
|
|
},
|
|
error: (err) => {
|
|
this.errorMessage = this.extractError(err, this.translate.instant('settings.messages.saveError'));
|
|
this.saving = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
goBack(): void {
|
|
this.router.navigate(['/lore']);
|
|
}
|
|
|
|
// --- Sauvegarde / Restauration des donnees --------------------------------
|
|
|
|
exporting = false;
|
|
importing = false;
|
|
dataMessage = '';
|
|
dataError = '';
|
|
|
|
/** Telecharge l'export complet (zip) via un lien temporaire. */
|
|
exportData(): void {
|
|
this.exporting = true;
|
|
this.dataMessage = '';
|
|
this.dataError = '';
|
|
this.settingsService.exportData().subscribe({
|
|
next: (blob) => {
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = 'loremind-export.zip';
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
this.exporting = false;
|
|
},
|
|
error: (err) => {
|
|
this.dataError = this.extractError(err, this.translate.instant('settings.data.exportError'));
|
|
this.exporting = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
/** Déclenché par le sélecteur de fichier : importe le zip choisi (fusion). */
|
|
onImportFileSelected(event: Event): void {
|
|
const input = event.target as HTMLInputElement;
|
|
const file = input.files?.[0];
|
|
input.value = ''; // permet de re-sélectionner le même fichier
|
|
if (!file) return;
|
|
|
|
if (!confirm(this.translate.instant('settings.data.importConfirm'))) return;
|
|
|
|
this.importing = true;
|
|
this.dataMessage = '';
|
|
this.dataError = '';
|
|
this.settingsService.importData(file).subscribe({
|
|
next: (result: ImportResult) => {
|
|
const total = Object.values(result.created).reduce((a, b) => a + b, 0);
|
|
this.dataMessage = this.translate.instant('settings.data.importSuccess', { count: total });
|
|
this.importing = false;
|
|
},
|
|
error: (err) => {
|
|
this.dataError = this.extractError(err, this.translate.instant('settings.data.importError'));
|
|
this.importing = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
private extractError(err: any, fallback: string): string {
|
|
if (err?.error?.detail) return String(err.error.detail);
|
|
if (err?.message) return err.message;
|
|
return fallback;
|
|
}
|
|
}
|