2025-05-11 16:57:52 +02:00
using AIStudio.Components ;
2024-12-03 15:24:40 +01:00
using AIStudio.Provider ;
2026-08-30 16:45:14 +02:00
using AIStudio.Provider.HuggingFace ;
2024-12-03 15:24:40 +01:00
using AIStudio.Settings ;
2026-05-13 18:13:34 +02:00
using AIStudio.Tools.Rust ;
2025-02-15 15:41:12 +01:00
using AIStudio.Tools.Services ;
2024-12-03 15:24:40 +01:00
using AIStudio.Tools.Validation ;
using Microsoft.AspNetCore.Components ;
2026-04-10 18:31:20 +02:00
using Microsoft.AspNetCore.Components.Web ;
2024-12-03 15:24:40 +01:00
using Host = AIStudio . Provider . SelfHosted . Host ;
namespace AIStudio.Dialogs ;
2025-05-11 16:57:52 +02:00
public partial class EmbeddingProviderDialog : MSGComponentBase , ISecretId
2024-12-03 15:24:40 +01:00
{
[CascadingParameter]
2025-03-12 19:12:56 +01:00
private IMudDialogInstance MudDialog { get ; set ; } = null !;
2024-12-03 15:24:40 +01:00
/// <summary>
/// The embedding's number in the list.
/// </summary>
[Parameter]
public uint DataNum { get ; set ; }
/// <summary>
/// The embedding's ID.
/// </summary>
[Parameter]
public string DataId { get ; set ; } = Guid . NewGuid (). ToString ();
/// <summary>
/// The user chosen name.
/// </summary>
[Parameter]
public string DataName { get ; set ; } = string . Empty ;
/// <summary>
/// The chosen hostname for self-hosted providers.
/// </summary>
[Parameter]
public string DataHostname { get ; set ; } = string . Empty ;
/// <summary>
/// The host to use, e.g., llama.cpp.
/// </summary>
[Parameter]
public Host DataHost { get ; set ; } = Host . NONE ;
/// <summary>
/// Is this provider self-hosted?
/// </summary>
[Parameter]
public bool IsSelfHosted { get ; set ; }
/// <summary>
/// The provider to use.
/// </summary>
[Parameter]
public LLMProviders DataLLMProvider { get ; set ; } = LLMProviders . NONE ;
2026-08-25 12:46:10 +02:00
/// <summary>
/// The validated custom icon supplied by a configuration plugin.
/// </summary>
[Parameter]
public string DataCustomIconDataUrl { get ; set ; } = string . Empty ;
2024-12-03 15:24:40 +01:00
/// <summary>
/// The embedding model to use.
/// </summary>
[Parameter]
public Model DataModel { get ; set ; }
2026-08-30 16:45:14 +02:00
/// <summary>
/// The Hugging Face inference provider to use.
/// </summary>
[Parameter]
public HFInferenceProvider HFInferenceProviderId { get ; set ; } = HFInferenceProvider . NONE ;
2024-12-03 15:24:40 +01:00
/// <summary>
/// Should the dialog be in editing mode?
/// </summary>
[Parameter]
public bool IsEditing { get ; init ; }
2026-04-10 18:34:10 +02:00
[Parameter]
public string DataTokenizerPath { get ; set ; } = string . Empty ;
2026-07-28 16:37:07 +02:00
[Parameter]
public int DataTokenLimit { get ; set ; } = EmbeddingProvider . DEFAULT_TOKEN_LIMIT ;
2026-07-29 18:47:59 +02:00
[Parameter]
public int DataEmbeddingBatchSize { get ; set ; } = EmbeddingProvider . DEFAULT_EMBEDDING_BATCH_SIZE ;
2026-09-05 21:17:42 +02:00
2026-08-15 11:20:36 +02:00
/// <summary>
/// Whether this embedding provider is managed by an enterprise configuration plugin. When true,
/// every field except the API key is locked, matching Settings.EmbeddingProvider.IsEnterpriseConfiguration.
/// </summary>
[Parameter]
public bool IsEnterpriseConfiguration { get ; set ; }
2024-12-03 15:24:40 +01:00
[Inject]
private RustService RustService { get ; init ; } = null !;
2026-01-18 17:15:18 +01:00
[Inject]
private ILogger < EmbeddingProviderDialog > Logger { get ; init ; } = null !;
2024-12-03 15:24:40 +01:00
private static readonly Dictionary < string , object? > SPELLCHECK_ATTRIBUTES = new ();
/// <summary>
/// The list of used instance names. We need this to check for uniqueness.
/// </summary>
private List < string > UsedInstanceNames { get ; set ; } = [];
private bool dataIsValid ;
private string [] dataIssues = [];
private string dataAPIKey = string . Empty ;
2026-08-15 11:20:36 +02:00
private bool dataHadStoredAPIKeyOnLoad ;
2024-12-03 15:24:40 +01:00
private string dataManuallyModel = string . Empty ;
private string dataAPIKeyStorageIssue = string . Empty ;
private string dataEditingPreviousInstanceName = string . Empty ;
2026-01-18 17:15:18 +01:00
private string dataLoadingModelsIssue = string . Empty ;
2026-04-10 18:31:20 +02:00
private string dataFilePath = string . Empty ;
private string dataCustomTokenizerValidationIssue = string . Empty ;
private Task dataTokenizerValidationTask = Task . CompletedTask ;
private bool dataStoreWasAttempted ;
2026-08-04 18:01:25 +02:00
private bool isTokenizerFileDialogOpen ;
2026-07-29 18:47:59 +02:00
private bool showExpertSettings ;
2026-04-10 18:31:20 +02:00
private int dataTokenizerValidationRevision ;
2026-01-18 17:15:18 +01:00
2024-12-03 15:24:40 +01:00
// We get the form reference from Blazor code to validate it manually:
private MudForm form = null !;
private readonly List < Model > availableModels = new ();
private readonly Encryption encryption = Program . ENCRYPTION ;
private readonly ProviderValidation providerValidation ;
2026-04-10 18:31:20 +02:00
2025-01-01 15:49:27 +01:00
public EmbeddingProviderDialog ()
2024-12-03 15:24:40 +01:00
{
this . providerValidation = new ()
{
GetProvider = () => this . DataLLMProvider ,
GetAPIKeyStorageIssue = () => this . dataAPIKeyStorageIssue ,
GetPreviousInstanceName = () => this . dataEditingPreviousInstanceName ,
GetUsedInstanceNames = () => this . UsedInstanceNames ,
GetHost = () => this . DataHost ,
2026-05-13 18:13:34 +02:00
IsModelProvidedManually = () => this . DataLLMProvider . IsEmbeddingModelProvidedManually ( this . DataHost ),
2026-04-10 18:31:20 +02:00
GetCustomTokenizerValidationIssue = () => this . dataCustomTokenizerValidationIssue ,
2024-12-03 15:24:40 +01:00
};
}
private EmbeddingProvider CreateEmbeddingProviderSettings ()
{
var cleanedHostname = this . DataHostname . Trim ();
2025-01-13 20:10:58 +01:00
Model model = default ;
if ( this . DataLLMProvider is LLMProviders . SELF_HOSTED )
{
2026-05-13 18:13:34 +02:00
if ( this . DataLLMProvider . IsEmbeddingModelProvidedManually ( this . DataHost ))
2025-01-13 20:10:58 +01:00
model = new Model ( this . dataManuallyModel , null );
else if ( this . DataHost is Host . LM_STUDIO )
model = this . DataModel ;
}
else
model = this . DataModel ;
2024-12-03 15:24:40 +01:00
return new ()
{
Num = this . DataNum ,
Id = this . DataId ,
Name = this . DataName ,
UsedLLMProvider = this . DataLLMProvider ,
2025-01-13 20:10:58 +01:00
Model = model ,
2024-12-03 15:24:40 +01:00
IsSelfHosted = this . DataLLMProvider is LLMProviders . SELF_HOSTED ,
Hostname = cleanedHostname . EndsWith ( '/' ) ? cleanedHostname [..^ 1 ] : cleanedHostname ,
Host = this . DataHost ,
2026-08-15 11:20:36 +02:00
IsEnterpriseConfiguration = this . IsEnterpriseConfiguration ,
2026-01-09 15:41:54 +01:00
EnterpriseConfigurationPluginId = Guid . Empty ,
2026-04-10 18:34:10 +02:00
TokenizerPath = this . dataFilePath ,
2026-07-29 18:47:59 +02:00
EmbeddingBatchSize = this . DataEmbeddingBatchSize ,
2026-07-28 16:37:07 +02:00
TokenLimit = this . DataTokenLimit ,
2026-08-25 12:46:10 +02:00
CustomIconDataUrl = this . DataCustomIconDataUrl ,
2026-08-30 16:45:14 +02:00
HFInferenceProvider = this . HFInferenceProviderId ,
2024-12-03 15:24:40 +01:00
};
}
#region Overrides of ComponentBase
protected override async Task OnInitializedAsync ()
{
2026-01-11 16:02:28 +01:00
// Call the base initialization first so that the I18N is ready:
await base . OnInitializedAsync ();
2024-12-03 15:24:40 +01:00
// Configure the spellchecking for the instance name input:
this . SettingsManager . InjectSpellchecking ( SPELLCHECK_ATTRIBUTES );
// Load the used instance names:
this . UsedInstanceNames = this . SettingsManager . ConfigurationData . EmbeddingProviders . Select ( x => x . Name . ToLowerInvariant ()). ToList ();
// When editing, we need to load the data:
if ( this . IsEditing )
{
this . dataEditingPreviousInstanceName = this . DataName . ToLowerInvariant ();
2026-04-10 18:34:10 +02:00
this . dataFilePath = this . DataTokenizerPath ;
2026-07-29 18:47:59 +02:00
this . showExpertSettings = ! string . IsNullOrWhiteSpace ( this . DataTokenizerPath )
|| this . DataTokenLimit != EmbeddingProvider . DEFAULT_TOKEN_LIMIT
|| this . DataEmbeddingBatchSize != EmbeddingProvider . DEFAULT_EMBEDDING_BATCH_SIZE ;
2024-12-03 15:24:40 +01:00
// When using self-hosted embedding, we must copy the model name:
if ( this . DataLLMProvider is LLMProviders . SELF_HOSTED )
this . dataManuallyModel = this . DataModel . Id ;
//
// We cannot load the API key for self-hosted providers:
//
2026-05-13 18:13:34 +02:00
if ( this . DataLLMProvider is LLMProviders . SELF_HOSTED && this . DataHost is not Host . OLLAMA && this . DataHost is not Host . VLLM )
2024-12-03 15:24:40 +01:00
{
await this . ReloadModels ();
await base . OnInitializedAsync ();
return ;
}
// Load the API key:
2026-01-11 16:02:28 +01:00
var requestedSecret = await this . RustService . GetAPIKey ( this , SecretStoreType . EMBEDDING_PROVIDER , isTrying : this . DataLLMProvider is LLMProviders . SELF_HOSTED );
2024-12-03 15:24:40 +01:00
if ( requestedSecret . Success )
2026-08-15 11:20:36 +02:00
{
2024-12-03 15:24:40 +01:00
this . dataAPIKey = await requestedSecret . Secret . Decrypt ( this . encryption );
2026-08-15 11:20:36 +02:00
this . dataHadStoredAPIKeyOnLoad = ! string . IsNullOrWhiteSpace ( this . dataAPIKey );
}
2024-12-03 15:24:40 +01:00
else
{
this . dataAPIKey = string . Empty ;
2026-08-15 11:20:36 +02:00
// For an enterprise-managed provider, having no key yet is the expected first-run
// state, not a storage failure -- the user is just about to set their own key:
if ( this . DataLLMProvider is not LLMProviders . SELF_HOSTED && ! this . IsEnterpriseConfiguration )
2024-12-03 15:24:40 +01:00
{
2025-05-11 16:57:52 +02:00
this . dataAPIKeyStorageIssue = string . Format ( T ( "Failed to load the API key from the operating system. The message was: {0}. You might ignore this message and provide the API key again." ), requestedSecret . Issue );
2024-12-03 15:24:40 +01:00
await this . form . Validate ();
}
}
await this . ReloadModels ();
}
}
protected override async Task OnAfterRenderAsync ( bool firstRender )
{
// Reset the validation when not editing and on the first render.
// We don't want to show validation errors when the user opens the dialog.
if (! this . IsEditing && firstRender )
this . form . ResetValidation ();
await base . OnAfterRenderAsync ( firstRender );
}
#endregion
#region Implementation of ISecretId
2026-08-15 11:20:36 +02:00
// Must mirror Settings.EmbeddingProvider.SecretId exactly: when editing an enterprise-managed
// provider, the key has to be stored under the same "ENT::"-prefixed keyring row that the
// app reads from at runtime (see BaseProvider.SecretId). Otherwise, a key entered here would
// silently end up in the wrong keyring row and never be found again.
public string SecretId => this . IsEnterpriseConfiguration ? $"{ISecretId.ENTERPRISE_KEY_PREFIX}::{this.DataLLMProvider.ToSecretId()}" : this . DataLLMProvider . ToSecretId ();
2024-12-03 15:24:40 +01:00
public string SecretName => this . DataName ;
#endregion
private async Task Store ()
{
2026-04-10 18:31:20 +02:00
this . dataStoreWasAttempted = true ;
await this . dataTokenizerValidationTask ;
2024-12-03 15:24:40 +01:00
await this . form . Validate ();
2025-01-13 19:51:26 +01:00
this . dataAPIKeyStorageIssue = string . Empty ;
2026-01-18 17:15:18 +01:00
// Manually validate the model selection (needed when no models are loaded
// and the MudSelect is not rendered):
var modelValidationError = this . providerValidation . ValidatingModel ( this . DataModel );
if (! string . IsNullOrWhiteSpace ( modelValidationError ))
{
this . dataIssues = [.. this . dataIssues , modelValidationError ];
this . dataIsValid = false ;
}
2024-12-03 15:24:40 +01:00
// When the data is not valid, we don't store it:
if (! this . dataIsValid )
return ;
2026-05-13 18:13:34 +02:00
var response = await this . StoreOrDeleteTokenizerAsync ();
2026-04-10 18:31:20 +02:00
if (! response . Success )
2026-04-10 18:34:10 +02:00
{
2026-05-13 18:13:34 +02:00
this . dataCustomTokenizerValidationIssue = string . IsNullOrWhiteSpace ( response . Message ) ? string . Empty : response . Message ;
2026-04-10 18:34:10 +02:00
await this . form . Validate ();
2026-04-10 18:31:20 +02:00
return ;
2026-04-10 18:34:10 +02:00
}
2026-05-27 20:02:43 +02:00
this . dataFilePath = response . StoredPath ;
2026-04-10 18:31:20 +02:00
2024-12-03 15:24:40 +01:00
// Use the data model to store the provider.
// We just return this data to the parent component:
var addedProviderSettings = this . CreateEmbeddingProviderSettings ();
if (! string . IsNullOrWhiteSpace ( this . dataAPIKey ))
{
// Store the API key in the OS secure storage:
2026-01-11 16:02:28 +01:00
var storeResponse = await this . RustService . SetAPIKey ( this , this . dataAPIKey , SecretStoreType . EMBEDDING_PROVIDER );
2024-12-03 15:24:40 +01:00
if (! storeResponse . Success )
{
2025-05-11 16:57:52 +02:00
this . dataAPIKeyStorageIssue = string . Format ( T ( "Failed to store the API key in the operating system. The message was: {0}. Please try again." ), storeResponse . Issue );
2024-12-03 15:24:40 +01:00
await this . form . Validate ();
return ;
}
2026-08-15 11:20:36 +02:00
this . dataHadStoredAPIKeyOnLoad = true ;
}
else if ( this . dataHadStoredAPIKeyOnLoad )
{
// The user cleared a previously stored key. Without this, the old key would simply
// stay in the OS keyring untouched and keep being used:
var deleteResponse = await this . RustService . DeleteAPIKey ( this , SecretStoreType . EMBEDDING_PROVIDER );
if (! deleteResponse . Success )
{
this . dataAPIKeyStorageIssue = string . Format ( T ( "Failed to remove the API key from the operating system. The message was: {0}. Please try again." ), deleteResponse . Issue );
await this . form . Validate ();
return ;
}
this . dataHadStoredAPIKeyOnLoad = false ;
2024-12-03 15:24:40 +01:00
}
this . MudDialog . Close ( DialogResult . Ok ( addedProviderSettings ));
}
private string? ValidateManuallyModel ( string manuallyModel )
{
if ( this . DataLLMProvider is LLMProviders . SELF_HOSTED && string . IsNullOrWhiteSpace ( manuallyModel ))
2025-05-11 16:57:52 +02:00
return T ( "Please enter an embedding model name." );
2024-12-03 15:24:40 +01:00
return null ;
}
2026-07-28 16:37:07 +02:00
private string? ValidateTokenLimit ( int tokenLimit )
{
if ( tokenLimit < 1 )
return T ( "Please enter a token limit greater than 0." );
return null ;
}
2026-07-29 18:47:59 +02:00
private string? ValidateEmbeddingBatchSize ( int embeddingBatchSize )
{
if ( embeddingBatchSize < 1 )
return T ( "Please enter an embedding batch size greater than 0." );
return null ;
}
2024-12-03 15:24:40 +01:00
private void Cancel () => this . MudDialog . Cancel ();
2026-01-11 16:02:28 +01:00
private async Task OnAPIKeyChanged ( string apiKey )
{
this . dataAPIKey = apiKey ;
if (! string . IsNullOrWhiteSpace ( this . dataAPIKeyStorageIssue ))
{
this . dataAPIKeyStorageIssue = string . Empty ;
await this . form . Validate ();
}
}
2026-01-18 17:15:18 +01:00
2026-08-04 18:01:25 +02:00
private async Task OpenTokenizerFileDialog ()
{
if ( this . isTokenizerFileDialogOpen )
return ;
this . isTokenizerFileDialogOpen = true ;
try
{
var response = await this . RustService . SelectFile ( T ( "Choose a custom tokenizer here" ), [ FileTypes . JSON ], string . IsNullOrWhiteSpace ( this . dataFilePath ) ? null : this . dataFilePath );
if (! response . UserCancelled )
await this . OnDataFilePathChanged ( response . SelectedFilePath );
}
finally
{
this . isTokenizerFileDialogOpen = false ;
}
}
2026-04-10 18:31:20 +02:00
private Task ClearPathTokenizer ( MouseEventArgs _ )
{
return this . OnDataFilePathChanged ( string . Empty );
}
private async Task OnDataFilePathChanged ( string filePath )
{
this . dataFilePath = filePath ;
var validationRevision = ++ this . dataTokenizerValidationRevision ;
this . dataTokenizerValidationTask = this . ValidateCustomTokenizer ( filePath , validationRevision );
await this . dataTokenizerValidationTask ;
if ( validationRevision != this . dataTokenizerValidationRevision )
return ;
if ( this . dataStoreWasAttempted )
await this . form . Validate ();
else
this . form . ResetValidation ();
}
private async Task ValidateCustomTokenizer ( string filePath , int validationRevision )
{
if ( string . IsNullOrWhiteSpace ( filePath ))
{
if ( validationRevision == this . dataTokenizerValidationRevision )
this . dataCustomTokenizerValidationIssue = string . Empty ;
return ;
}
try
{
var response = await this . RustService . ValidateTokenizer ( filePath );
if ( validationRevision != this . dataTokenizerValidationRevision )
return ;
if ( response . Success )
this . dataCustomTokenizerValidationIssue = string . Empty ;
else
this . dataCustomTokenizerValidationIssue = T ( "Invalid tokenizer: " ) + response . Message ;
}
catch ( Exception e )
{
if ( validationRevision != this . dataTokenizerValidationRevision )
return ;
this . Logger . LogError ( e , "Failed to validate custom tokenizer." );
this . dataCustomTokenizerValidationIssue = T ( "Failed to validate the selected tokenizer. Please try again." );
}
}
2026-07-28 15:25:10 +02:00
/// <summary>
/// Stores a new tokenizer or deletes the existing one, based on the specified tokenizer path.
/// If the path is null or empty, any existing tokenizer is removed.
/// Otherwise, the tokenizer is stored at the specified path.
/// </summary>
2026-05-13 18:13:34 +02:00
private Task < TokenizerResponse > StoreOrDeleteTokenizerAsync ()
{
var tokenizerId = TokenizerModelId . ForEmbeddingProviderId ( this . DataId );
if ( string . IsNullOrWhiteSpace ( this . dataFilePath ))
return this . RustService . DeleteTokenizer ( tokenizerId );
return this . RustService . StoreTokenizer ( tokenizerId , this . dataFilePath );
}
2026-01-18 17:15:18 +01:00
private void OnHostChanged ( Host selectedHost )
{
// When the host changes, reset the model selection state:
this . DataHost = selectedHost ;
this . DataModel = default ;
this . dataManuallyModel = string . Empty ;
this . availableModels . Clear ();
this . dataLoadingModelsIssue = string . Empty ;
}
2026-08-30 16:45:14 +02:00
/// <summary>
/// Resets the model selection when the user picks another Hugging Face inference provider.
/// </summary>
/// <remarks>
/// Each inference provider offers embedding models of its own, so the models loaded for the
/// previous one say nothing about the new one.
/// </remarks>
/// <param name="selectedInferenceProvider">The inference provider the user chose.</param>
private void OnHFInferenceProviderChanged ( HFInferenceProvider selectedInferenceProvider )
{
this . HFInferenceProviderId = selectedInferenceProvider ;
this . DataModel = default ;
this . availableModels . Clear ();
this . dataLoadingModelsIssue = string . Empty ;
}
2024-12-03 15:24:40 +01:00
private async Task ReloadModels ()
{
2026-01-18 17:15:18 +01:00
this . dataLoadingModelsIssue = string . Empty ;
2024-12-03 15:24:40 +01:00
var currentEmbeddingProviderSettings = this . CreateEmbeddingProviderSettings ();
2025-09-03 21:25:17 +02:00
var provider = currentEmbeddingProviderSettings . CreateProvider ();
2026-01-18 17:15:18 +01:00
if ( provider is NoProvider )
2024-12-03 15:24:40 +01:00
return ;
2026-01-18 17:15:18 +01:00
try
{
2026-04-14 13:39:11 +02:00
var result = await provider . GetEmbeddingModels ( this . dataAPIKey );
if (! result . Success )
this . dataLoadingModelsIssue = result . FailureReason . ToUserMessage ( provider . InstanceName );
2026-01-18 17:15:18 +01:00
// Order descending by ID means that the newest models probably come first:
2026-04-14 13:39:11 +02:00
var orderedModels = result . Models . OrderByDescending ( n => n . Id );
2026-01-18 17:15:18 +01:00
this . availableModels . Clear ();
this . availableModels . AddRange ( orderedModels );
}
catch ( Exception e )
{
this . Logger . LogError ( $"Failed to load models from provider '{this.DataLLMProvider}' (host={this.DataHost}, hostname='{this.DataHostname}'): {e.Message}" );
this . dataLoadingModelsIssue = T ( "We are currently unable to communicate with the provider to load models. Please try again later." );
}
2024-12-03 15:24:40 +01:00
}
private string APIKeyText => this . DataLLMProvider switch
{
2025-05-11 16:57:52 +02:00
LLMProviders . SELF_HOSTED => T ( "(Optional) API Key" ),
_ => T ( "API Key" ),
2024-12-03 15:24:40 +01:00
};
private bool IsNoneProvider => this . DataLLMProvider is LLMProviders . NONE ;
2026-07-29 18:47:59 +02:00
private void ToggleExpertSettings () => this . showExpertSettings = ! this . showExpertSettings ;
private string GetExpertStyles => this . showExpertSettings ? "border-2 border-dashed rounded pa-2" : string . Empty ;
2026-04-10 18:31:20 +02:00
}