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; } }