mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-09-27 07:33:38 +00:00
Resolved 29 conflicting files. The notable decisions: Confidence: main's tool-calling gate (RequiredProviderConfidence) and this branch's local-RAG gate (DataConfidenceLevel) turned out to be the same rule on the same axis, so they are now one field. Both tool results and data sources raise it through RequireProviderConfidence(). The gate checks the level strictly and no longer exempts providers trusted by configuration: TrustedProviderIds is documented as applying to data-source security checks only, and organizations set confidence through DataConfidence .CustomConfidenceScheme instead. The security axis (DataSecurity, ERI, IsTrustedForDataSourceSecurityChecks) is unchanged. Provider creation: main's CreateProvider signature won (hfEndpointKind, capabilityOverrides, no model parameter); tokenizerPath was added to it and is set for every provider, including the new Hetzner, IONOS and LiteLLM. Provider and EmbeddingProvider combine the record parameters, Lua parsing and Lua serialization of both sides. File types: main's hierarchy (ODT leaf, WORD parent, PowerPoint without the legacy .ppt, TABULAR instead of DELIMITED_TABLE) plus this branch's SPREADSHEET parent with ODS and the xlsm/xlsb/xla/xlam extensions, which the runtime already reads. Both sides had added a conflicting HTML filter; the reading family keeps the name, and the export path uses a narrow HTML_DOCUMENT, following the existing LATEX/TEX split. Runtime: main's file_data.rs is the base, including the prompt-injection sanitizer and the extraction routes. Token counting and chunk segmentation moved into take_released, so they act on the text the filter has released rather than on text it is still holding. A failed count is logged and left out instead of ending the extraction, because the app counts such a segment itself. Data sources: the participating-provider checks of this branch are kept, and main's GetAllowedDataSources overload now builds on them. DirectChatService resolves the launched chat's data source options before the check, so filter and chat see the same options. .NET and Rust both build clean; I18N regenerated to 4060 keys.
518 lines
20 KiB
C#
518 lines
20 KiB
C#
using AIStudio.Components;
|
|
using AIStudio.Provider;
|
|
using AIStudio.Provider.HuggingFace;
|
|
using AIStudio.Settings;
|
|
using AIStudio.Tools.Rust;
|
|
using AIStudio.Tools.Services;
|
|
using AIStudio.Tools.Validation;
|
|
|
|
using Microsoft.AspNetCore.Components;
|
|
using Microsoft.AspNetCore.Components.Web;
|
|
using Host = AIStudio.Provider.SelfHosted.Host;
|
|
|
|
namespace AIStudio.Dialogs;
|
|
|
|
public partial class EmbeddingProviderDialog : MSGComponentBase, ISecretId
|
|
{
|
|
[CascadingParameter]
|
|
private IMudDialogInstance MudDialog { get; set; } = null!;
|
|
|
|
/// <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;
|
|
|
|
/// <summary>
|
|
/// The validated custom icon supplied by a configuration plugin.
|
|
/// </summary>
|
|
[Parameter]
|
|
public string DataCustomIconDataUrl { get; set; } = string.Empty;
|
|
|
|
/// <summary>
|
|
/// The embedding model to use.
|
|
/// </summary>
|
|
[Parameter]
|
|
public Model DataModel { get; set; }
|
|
|
|
/// <summary>
|
|
/// The Hugging Face inference provider to use.
|
|
/// </summary>
|
|
[Parameter]
|
|
public HFInferenceProvider HFInferenceProviderId { get; set; } = HFInferenceProvider.NONE;
|
|
|
|
/// <summary>
|
|
/// Should the dialog be in editing mode?
|
|
/// </summary>
|
|
[Parameter]
|
|
public bool IsEditing { get; init; }
|
|
|
|
[Parameter]
|
|
public string DataTokenizerPath { get; set; } = string.Empty;
|
|
|
|
[Parameter]
|
|
public int DataTokenLimit { get; set; } = EmbeddingProvider.DEFAULT_TOKEN_LIMIT;
|
|
|
|
[Parameter]
|
|
public int DataEmbeddingBatchSize { get; set; } = EmbeddingProvider.DEFAULT_EMBEDDING_BATCH_SIZE;
|
|
|
|
/// <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; }
|
|
|
|
[Inject]
|
|
private RustService RustService { get; init; } = null!;
|
|
|
|
[Inject]
|
|
private ILogger<EmbeddingProviderDialog> Logger { get; init; } = null!;
|
|
|
|
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;
|
|
private bool dataHadStoredAPIKeyOnLoad;
|
|
private string dataManuallyModel = string.Empty;
|
|
private string dataAPIKeyStorageIssue = string.Empty;
|
|
private string dataEditingPreviousInstanceName = string.Empty;
|
|
private string dataLoadingModelsIssue = string.Empty;
|
|
private string dataFilePath = string.Empty;
|
|
private string dataCustomTokenizerValidationIssue = string.Empty;
|
|
private Task dataTokenizerValidationTask = Task.CompletedTask;
|
|
private bool dataStoreWasAttempted;
|
|
private bool isTokenizerFileDialogOpen;
|
|
private bool showExpertSettings;
|
|
private int dataTokenizerValidationRevision;
|
|
|
|
// 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;
|
|
|
|
public EmbeddingProviderDialog()
|
|
{
|
|
this.providerValidation = new()
|
|
{
|
|
GetProvider = () => this.DataLLMProvider,
|
|
GetAPIKeyStorageIssue = () => this.dataAPIKeyStorageIssue,
|
|
GetPreviousInstanceName = () => this.dataEditingPreviousInstanceName,
|
|
GetUsedInstanceNames = () => this.UsedInstanceNames,
|
|
GetHost = () => this.DataHost,
|
|
IsModelProvidedManually = () => this.DataLLMProvider.IsEmbeddingModelProvidedManually(this.DataHost),
|
|
GetCustomTokenizerValidationIssue = () => this.dataCustomTokenizerValidationIssue,
|
|
};
|
|
}
|
|
|
|
private EmbeddingProvider CreateEmbeddingProviderSettings()
|
|
{
|
|
var cleanedHostname = this.DataHostname.Trim();
|
|
Model model = default;
|
|
if(this.DataLLMProvider is LLMProviders.SELF_HOSTED)
|
|
{
|
|
if (this.DataLLMProvider.IsEmbeddingModelProvidedManually(this.DataHost))
|
|
model = new Model(this.dataManuallyModel, null);
|
|
else if (this.DataHost is Host.LM_STUDIO)
|
|
model = this.DataModel;
|
|
}
|
|
else
|
|
model = this.DataModel;
|
|
|
|
return new()
|
|
{
|
|
Num = this.DataNum,
|
|
Id = this.DataId,
|
|
Name = this.DataName,
|
|
UsedLLMProvider = this.DataLLMProvider,
|
|
Model = model,
|
|
IsSelfHosted = this.DataLLMProvider is LLMProviders.SELF_HOSTED,
|
|
Hostname = cleanedHostname.EndsWith('/') ? cleanedHostname[..^1] : cleanedHostname,
|
|
Host = this.DataHost,
|
|
IsEnterpriseConfiguration = this.IsEnterpriseConfiguration,
|
|
EnterpriseConfigurationPluginId = Guid.Empty,
|
|
TokenizerPath = this.dataFilePath,
|
|
EmbeddingBatchSize = this.DataEmbeddingBatchSize,
|
|
TokenLimit = this.DataTokenLimit,
|
|
CustomIconDataUrl = this.DataCustomIconDataUrl,
|
|
HFInferenceProvider = this.HFInferenceProviderId,
|
|
};
|
|
}
|
|
|
|
#region Overrides of ComponentBase
|
|
|
|
protected override async Task OnInitializedAsync()
|
|
{
|
|
// Call the base initialization first so that the I18N is ready:
|
|
await base.OnInitializedAsync();
|
|
|
|
// 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();
|
|
this.dataFilePath = this.DataTokenizerPath;
|
|
this.showExpertSettings = !string.IsNullOrWhiteSpace(this.DataTokenizerPath)
|
|
|| this.DataTokenLimit != EmbeddingProvider.DEFAULT_TOKEN_LIMIT
|
|
|| this.DataEmbeddingBatchSize != EmbeddingProvider.DEFAULT_EMBEDDING_BATCH_SIZE;
|
|
|
|
// 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:
|
|
//
|
|
if (this.DataLLMProvider is LLMProviders.SELF_HOSTED && this.DataHost is not Host.OLLAMA && this.DataHost is not Host.VLLM)
|
|
{
|
|
await this.ReloadModels();
|
|
await base.OnInitializedAsync();
|
|
return;
|
|
}
|
|
|
|
// Load the API key:
|
|
var requestedSecret = await this.RustService.GetAPIKey(this, SecretStoreType.EMBEDDING_PROVIDER, isTrying: this.DataLLMProvider is LLMProviders.SELF_HOSTED);
|
|
if (requestedSecret.Success)
|
|
{
|
|
this.dataAPIKey = await requestedSecret.Secret.Decrypt(this.encryption);
|
|
this.dataHadStoredAPIKeyOnLoad = !string.IsNullOrWhiteSpace(this.dataAPIKey);
|
|
}
|
|
else
|
|
{
|
|
this.dataAPIKey = string.Empty;
|
|
|
|
// 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)
|
|
{
|
|
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);
|
|
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
|
|
|
|
// 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();
|
|
|
|
public string SecretName => this.DataName;
|
|
|
|
#endregion
|
|
|
|
private async Task Store()
|
|
{
|
|
this.dataStoreWasAttempted = true;
|
|
await this.dataTokenizerValidationTask;
|
|
await this.form.Validate();
|
|
this.dataAPIKeyStorageIssue = string.Empty;
|
|
|
|
// 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;
|
|
}
|
|
|
|
// When the data is not valid, we don't store it:
|
|
if (!this.dataIsValid)
|
|
return;
|
|
|
|
var response = await this.StoreOrDeleteTokenizerAsync();
|
|
if (!response.Success)
|
|
{
|
|
this.dataCustomTokenizerValidationIssue = string.IsNullOrWhiteSpace(response.Message) ? string.Empty : response.Message;
|
|
await this.form.Validate();
|
|
return;
|
|
}
|
|
this.dataFilePath = response.StoredPath;
|
|
|
|
// 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:
|
|
var storeResponse = await this.RustService.SetAPIKey(this, this.dataAPIKey, SecretStoreType.EMBEDDING_PROVIDER);
|
|
if (!storeResponse.Success)
|
|
{
|
|
this.dataAPIKeyStorageIssue = string.Format(T("Failed to store the API key in the operating system. The message was: {0}. Please try again."), storeResponse.Issue);
|
|
await this.form.Validate();
|
|
return;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
this.MudDialog.Close(DialogResult.Ok(addedProviderSettings));
|
|
}
|
|
|
|
private string? ValidateManuallyModel(string manuallyModel)
|
|
{
|
|
if (this.DataLLMProvider is LLMProviders.SELF_HOSTED && string.IsNullOrWhiteSpace(manuallyModel))
|
|
return T("Please enter an embedding model name.");
|
|
|
|
return null;
|
|
}
|
|
|
|
private string? ValidateTokenLimit(int tokenLimit)
|
|
{
|
|
if (tokenLimit < 1)
|
|
return T("Please enter a token limit greater than 0.");
|
|
|
|
return null;
|
|
}
|
|
|
|
private string? ValidateEmbeddingBatchSize(int embeddingBatchSize)
|
|
{
|
|
if (embeddingBatchSize < 1)
|
|
return T("Please enter an embedding batch size greater than 0.");
|
|
|
|
return null;
|
|
}
|
|
|
|
private void Cancel() => this.MudDialog.Cancel();
|
|
|
|
private async Task OnAPIKeyChanged(string apiKey)
|
|
{
|
|
this.dataAPIKey = apiKey;
|
|
if (!string.IsNullOrWhiteSpace(this.dataAPIKeyStorageIssue))
|
|
{
|
|
this.dataAPIKeyStorageIssue = string.Empty;
|
|
await this.form.Validate();
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
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.");
|
|
}
|
|
}
|
|
|
|
/// <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>
|
|
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);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/// <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;
|
|
}
|
|
|
|
private async Task ReloadModels()
|
|
{
|
|
this.dataLoadingModelsIssue = string.Empty;
|
|
var currentEmbeddingProviderSettings = this.CreateEmbeddingProviderSettings();
|
|
var provider = currentEmbeddingProviderSettings.CreateProvider();
|
|
if (provider is NoProvider)
|
|
return;
|
|
|
|
try
|
|
{
|
|
var result = await provider.GetEmbeddingModels(this.dataAPIKey);
|
|
if (!result.Success)
|
|
this.dataLoadingModelsIssue = result.FailureReason.ToUserMessage(provider.InstanceName);
|
|
|
|
// Order descending by ID means that the newest models probably come first:
|
|
var orderedModels = result.Models.OrderByDescending(n => n.Id);
|
|
|
|
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.");
|
|
}
|
|
}
|
|
|
|
private string APIKeyText => this.DataLLMProvider switch
|
|
{
|
|
LLMProviders.SELF_HOSTED => T("(Optional) API Key"),
|
|
_ => T("API Key"),
|
|
};
|
|
|
|
private bool IsNoneProvider => this.DataLLMProvider is LLMProviders.NONE;
|
|
|
|
private void ToggleExpertSettings() => this.showExpertSettings = !this.showExpertSettings;
|
|
|
|
private string GetExpertStyles => this.showExpertSettings ? "border-2 border-dashed rounded pa-2" : string.Empty;
|
|
}
|