385 lines
18 KiB
HTML
385 lines
18 KiB
HTML
<div class="settings-page">
|
|
|
|
<header class="page-header">
|
|
<button class="btn-back" (click)="goBack()">
|
|
<lucide-icon [img]="ArrowLeft" [size]="16"></lucide-icon>
|
|
<span>{{ 'common.back' | translate }}</span>
|
|
</button>
|
|
<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>
|
|
<span>{{ errorMessage }}</span>
|
|
</div>
|
|
}
|
|
@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>{{ 'settings.ai.title' | translate }}</h2>
|
|
<p class="hint">{{ 'settings.ai.hint' | translate }}</p>
|
|
<div class="form-row">
|
|
<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>{{ 'settings.ai.providerOllama' | translate }}</span>
|
|
</label>
|
|
<label class="radio">
|
|
<input type="radio" name="provider" value="onemin" [(ngModel)]="settings.llm_provider">
|
|
<span>{{ 'settings.ai.providerOnemin' | translate }}</span>
|
|
</label>
|
|
<label class="radio">
|
|
<input type="radio" name="provider" value="openrouter" [(ngModel)]="settings.llm_provider">
|
|
<span>{{ 'settings.ai.providerOpenrouter' | translate }}</span>
|
|
</label>
|
|
<label class="radio">
|
|
<input type="radio" name="provider" value="mistral" [(ngModel)]="settings.llm_provider">
|
|
<span>{{ 'settings.ai.providerMistral' | translate }}</span>
|
|
</label>
|
|
<label class="radio">
|
|
<input type="radio" name="provider" value="gemini" [(ngModel)]="settings.llm_provider">
|
|
<span>{{ 'settings.ai.providerGemini' | translate }}</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
}
|
|
|
|
<!-- Bloc Ollama -->
|
|
@if (settings && settings.llm_provider === 'ollama') {
|
|
<section class="card">
|
|
<h2>{{ 'settings.ollama.title' | translate }}</h2>
|
|
<div class="form-row">
|
|
<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">{{ 'settings.ollama.model' | translate }}</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 ? '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>{{ 'common.download' | translate }}</span>
|
|
</button>
|
|
</div>
|
|
@if (ollamaModels.length === 0) {
|
|
<p class="hint">{{ 'settings.ollama.noModels' | translate }}</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 -->
|
|
@if (settings && settings.llm_provider === 'onemin') {
|
|
<section class="card">
|
|
<h2>{{ 'settings.onemin.title' | translate }}</h2>
|
|
<div class="form-row">
|
|
<label for="onemin-key">{{ 'settings.apiKey.label' | translate }}</label>
|
|
<input
|
|
id="onemin-key"
|
|
type="password"
|
|
[(ngModel)]="oneminApiKeyInput"
|
|
[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>{{ 'settings.apiKey.clear' | translate }}</span>
|
|
</label>
|
|
}
|
|
</div>
|
|
<div class="form-row">
|
|
<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>
|
|
}
|
|
</select>
|
|
</div>
|
|
<div class="form-row">
|
|
<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>
|
|
}
|
|
</select>
|
|
</div>
|
|
</section>
|
|
}
|
|
|
|
<!-- Bloc OpenRouter -->
|
|
@if (settings && settings.llm_provider === 'openrouter') {
|
|
<section class="card">
|
|
<h2>{{ 'settings.openrouter.title' | translate }}</h2>
|
|
<div class="form-row">
|
|
<label for="openrouter-key">{{ 'settings.apiKey.label' | translate }}</label>
|
|
<input
|
|
id="openrouter-key"
|
|
type="password"
|
|
[(ngModel)]="openrouterApiKeyInput"
|
|
[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>{{ 'settings.apiKey.clear' | translate }}</span>
|
|
</label>
|
|
}
|
|
</div>
|
|
<div class="form-row">
|
|
<label for="openrouter-model">{{ 'settings.model' | translate }}</label>
|
|
<label class="checkbox">
|
|
<input type="checkbox" [(ngModel)]="openrouterFreeOnly">
|
|
<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" [innerHTML]="'settings.openrouter.hint' | translate"></p>
|
|
</div>
|
|
</section>
|
|
}
|
|
|
|
<!-- Bloc Mistral -->
|
|
@if (settings && settings.llm_provider === 'mistral') {
|
|
<section class="card">
|
|
<h2>{{ 'settings.mistral.title' | translate }}</h2>
|
|
<div class="form-row">
|
|
<label for="mistral-key">{{ 'settings.apiKey.label' | translate }}</label>
|
|
<input
|
|
id="mistral-key"
|
|
type="password"
|
|
[(ngModel)]="mistralApiKeyInput"
|
|
[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>{{ 'settings.apiKey.clear' | translate }}</span>
|
|
</label>
|
|
}
|
|
</div>
|
|
<div class="form-row">
|
|
<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" [innerHTML]="'settings.mistral.hint' | translate"></p>
|
|
</div>
|
|
</section>
|
|
}
|
|
|
|
<!-- Bloc Gemini -->
|
|
@if (settings && settings.llm_provider === 'gemini') {
|
|
<section class="card">
|
|
<h2>{{ 'settings.gemini.title' | translate }}</h2>
|
|
<div class="form-row">
|
|
<label for="gemini-key">{{ 'settings.apiKey.label' | translate }}</label>
|
|
<input
|
|
id="gemini-key"
|
|
type="password"
|
|
[(ngModel)]="geminiApiKeyInput"
|
|
[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>{{ 'settings.apiKey.clear' | translate }}</span>
|
|
</label>
|
|
}
|
|
</div>
|
|
<div class="form-row">
|
|
<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" [innerHTML]="'settings.gemini.hint' | translate"></p>
|
|
</div>
|
|
</section>
|
|
}
|
|
|
|
<!-- Bloc Embeddings (Atelier RAG) : indépendant du modèle de CHAT ci-dessus. -->
|
|
@if (settings) {
|
|
<section class="card">
|
|
<h2>{{ 'settings.embeddings.title' | translate }}</h2>
|
|
<p class="hint" [innerHTML]="'settings.embeddings.hint' | translate"></p>
|
|
<div class="form-row">
|
|
<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>{{ 'settings.embeddings.providerOllama' | translate }}</span>
|
|
</label>
|
|
<label class="radio">
|
|
<input type="radio" name="embprovider" value="mistral" [(ngModel)]="settings.embedding_provider">
|
|
<span>{{ 'settings.embeddings.providerMistral' | translate }}</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<!-- Ollama embeddings -->
|
|
@if (settings.embedding_provider === 'ollama') {
|
|
<div>
|
|
<div class="form-row">
|
|
<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" [innerHTML]="'settings.embeddings.ollamaModelHint' | translate"></p>
|
|
</div>
|
|
<div class="form-row">
|
|
<label class="checkbox">
|
|
<input type="checkbox" [(ngModel)]="settings.auto_pull_embedding_model">
|
|
<span>{{ 'settings.embeddings.autoPull' | translate }}</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
}
|
|
<div class="form-row">
|
|
<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" [innerHTML]="'settings.embeddings.topKHint' | translate"></p>
|
|
</div>
|
|
<!-- Mistral embeddings -->
|
|
@if (settings.embedding_provider === 'mistral') {
|
|
<div>
|
|
<div class="form-row">
|
|
<label for="emb-mistral-key">
|
|
{{ 'settings.embeddings.mistralKey' | translate }}
|
|
@if (settings.mistral_api_key_set) {
|
|
<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 ? 'settings.apiKey.placeholderSet' : 'settings.apiKey.placeholderMistral') | translate">
|
|
@if (settings.mistral_api_key_set) {
|
|
<label class="checkbox">
|
|
<input type="checkbox" [(ngModel)]="clearMistralKey">
|
|
<span>{{ 'settings.apiKey.clear' | translate }}</span>
|
|
</label>
|
|
}
|
|
<p class="hint" [innerHTML]="'settings.embeddings.mistralKeyHint' | translate"></p>
|
|
</div>
|
|
<div class="form-row">
|
|
<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>
|
|
</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. -->
|
|
@if (settings && settings.llm_provider === 'ollama') {
|
|
<section class="card">
|
|
<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">
|
|
{{ 'settings.context.tokens' | translate }}
|
|
<span class="ctx-value">{{ settings.llm_num_ctx | number }}</span>
|
|
@if (ollamaModelMaxContext > 0) {
|
|
<span class="ctx-max">
|
|
{{ 'settings.context.max' | translate:{ max: (ollamaModelMaxContext | number) } }}
|
|
</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" [innerHTML]="'settings.context.hintKnown' | translate:{ model: settings.llm_model, max: (ollamaModelMaxContext | number) }"></p>
|
|
}
|
|
@if (ollamaModelMaxContext === 0) {
|
|
<p class="hint" [innerHTML]="'settings.context.hintUnknown' | translate:{ max: (CTX_FALLBACK_MAX | number) }"></p>
|
|
}
|
|
</div>
|
|
}
|
|
</section>
|
|
}
|
|
|
|
<!-- Bloc Import de PDF -->
|
|
@if (settings) {
|
|
<section class="card">
|
|
<h2>{{ 'settings.pdf.title' | translate }}</h2>
|
|
<div class="form-row">
|
|
<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" [innerHTML]="'settings.pdf.chunkTokensHint' | translate"></p>
|
|
</div>
|
|
<div class="form-row">
|
|
<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" [innerHTML]="'settings.pdf.timeoutHint' | translate"></p>
|
|
</div>
|
|
</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 ? 'common.saving' : 'common.save') | translate }}</span>
|
|
</button>
|
|
</div>
|
|
}
|
|
|
|
</div>
|