Configure data sources (#259)

This commit is contained in:
Thorsten Sommer 2025-01-13 19:51:26 +01:00 committed by GitHub
parent 06f66fdab2
commit 63be312bb4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
47 changed files with 3388 additions and 283 deletions

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@ -2,6 +2,10 @@
Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MindWork AI Studio", "MindWork AI Studio\MindWork AI Studio.csproj", "{059FDFCC-7D0B-474E-9F20-B9C437DF1CDD}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ERIClients", "ERIClients", "{5C2AF789-287B-4FCB-B675-7273D8CD4579}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ERIClientV1", "ERIClientV1\ERIClientV1.csproj", "{9E35A273-0FA6-4BD5-8880-A1DDAC106926}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -12,5 +16,12 @@ Global
{059FDFCC-7D0B-474E-9F20-B9C437DF1CDD}.Debug|Any CPU.Build.0 = Debug|Any CPU
{059FDFCC-7D0B-474E-9F20-B9C437DF1CDD}.Release|Any CPU.ActiveCfg = Release|Any CPU
{059FDFCC-7D0B-474E-9F20-B9C437DF1CDD}.Release|Any CPU.Build.0 = Release|Any CPU
{9E35A273-0FA6-4BD5-8880-A1DDAC106926}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9E35A273-0FA6-4BD5-8880-A1DDAC106926}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9E35A273-0FA6-4BD5-8880-A1DDAC106926}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9E35A273-0FA6-4BD5-8880-A1DDAC106926}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{9E35A273-0FA6-4BD5-8880-A1DDAC106926} = {5C2AF789-287B-4FCB-B675-7273D8CD4579}
EndGlobalSection
EndGlobal

View File

@ -52,7 +52,7 @@ else
<MudButton Disabled="@this.AreServerPresetsBlocked" OnClick="@this.AddERIServer" Variant="Variant.Filled" Color="Color.Primary">
Add ERI server preset
</MudButton>
<MudButton OnClick="@this.RemoveERIServer" Disabled="@(this.AreServerPresetsBlocked || this.IsNoneERIServerSelected)" Variant="Variant.Filled" Color="Color.Primary">
<MudButton OnClick="@this.RemoveERIServer" Disabled="@(this.AreServerPresetsBlocked || this.IsNoneERIServerSelected)" Variant="Variant.Filled" Color="Color.Error">
Delete this server preset
</MudButton>
</MudStack>
@ -346,4 +346,4 @@ else
</MudText>
<MudTextSwitch Label="Should we write the generated code to the file system?" Disabled="@this.IsNoneERIServerSelected" @bind-Value="@this.writeToFilesystem" LabelOn="Yes, please write or update all generated code to the file system" LabelOff="No, just show me the code" />
<SelectDirectory Label="Base directory where to write the code" @bind-Directory="@this.baseDirectory" Disabled="@(this.IsNoneERIServerSelected || !this.writeToFilesystem)" DirectoryDialogTitle="Select the target directory for the ERI server"/>
<SelectDirectory Label="Base directory where to write the code" @bind-Directory="@this.baseDirectory" Disabled="@(this.IsNoneERIServerSelected || !this.writeToFilesystem)" DirectoryDialogTitle="Select the target directory for the ERI server" Validation="@this.ValidateDirectory" />

View File

@ -739,6 +739,17 @@ public partial class AssistantERI : AssistantBaseCore
return null;
}
private string? ValidateDirectory(string path)
{
if(!this.writeToFilesystem)
return null;
if(string.IsNullOrWhiteSpace(path))
return "Please provide a base directory for the ERI server to write files to.";
return null;
}
private string GetMultiSelectionAuthText(List<Auth> selectedValues)
{

View File

@ -4,6 +4,7 @@
Text="@this.Directory"
Label="@this.Label"
ReadOnly="@true"
Validation="@this.Validation"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Folder"
UserAttributes="@SPELLCHECK_ATTRIBUTES"

View File

@ -21,6 +21,9 @@ public partial class SelectDirectory : ComponentBase
[Parameter]
public string DirectoryDialogTitle { get; set; } = "Select Directory";
[Parameter]
public Func<string, string?> Validation { get; set; } = _ => null;
[Inject]
private SettingsManager SettingsManager { get; init; } = null!;

View File

@ -0,0 +1,17 @@
<MudStack Row="@true" Spacing="3" Class="mb-3" StretchItems="StretchItems.None" AlignItems="AlignItems.Center">
<MudTextField
T="string"
Text="@this.File"
Label="@this.Label"
ReadOnly="@true"
Validation="@this.Validation"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.AttachFile"
UserAttributes="@SPELLCHECK_ATTRIBUTES"
Variant="Variant.Outlined"
/>
<MudButton StartIcon="@Icons.Material.Filled.FolderOpen" Variant="Variant.Outlined" Color="Color.Primary" Disabled="this.Disabled" OnClick="@this.OpenFileDialog">
Choose File
</MudButton>
</MudStack>

View File

@ -0,0 +1,63 @@
using AIStudio.Settings;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Components;
public partial class SelectFile : ComponentBase
{
[Parameter]
public string File { get; set; } = string.Empty;
[Parameter]
public EventCallback<string> FileChanged { get; set; }
[Parameter]
public bool Disabled { get; set; }
[Parameter]
public string Label { get; set; } = string.Empty;
[Parameter]
public string FileDialogTitle { get; set; } = "Select File";
[Parameter]
public Func<string, string?> Validation { get; set; } = _ => null;
[Inject]
private SettingsManager SettingsManager { get; init; } = null!;
[Inject]
public RustService RustService { get; set; } = null!;
[Inject]
protected ILogger<SelectDirectory> Logger { get; init; } = null!;
private static readonly Dictionary<string, object?> SPELLCHECK_ATTRIBUTES = new();
#region Overrides of ComponentBase
protected override async Task OnInitializedAsync()
{
// Configure the spellchecking for the instance name input:
this.SettingsManager.InjectSpellchecking(SPELLCHECK_ATTRIBUTES);
await base.OnInitializedAsync();
}
#endregion
private void InternalFileChanged(string file)
{
this.File = file;
this.FileChanged.InvokeAsync(file);
}
private async Task OpenFileDialog()
{
var response = await this.RustService.SelectFile(this.FileDialogTitle, string.IsNullOrWhiteSpace(this.File) ? null : this.File);
this.Logger.LogInformation($"The user selected the file '{response.SelectedFilePath}'.");
if (!response.UserCancelled)
this.InternalFileChanged(response.SelectedFilePath);
}
}

View File

@ -0,0 +1,67 @@
@using AIStudio.Settings
@using AIStudio.Settings.DataModel
@inherits SettingsPanelBase
@if (PreviewFeatures.PRE_RAG_2024.IsEnabled(this.SettingsManager))
{
<ExpansionPanel HeaderIcon="@Icons.Material.Filled.IntegrationInstructions" HeaderText="Configure Data Sources">
<PreviewPrototype/>
<MudText Typo="Typo.h4" Class="mb-3">
Configured Data Sources
</MudText>
<MudJustifiedText Typo="Typo.body1" Class="mb-3">
You might configure different data sources. A data source can include one file, all files
in a directory, or data from your company. Later, you can incorporate these data sources
as needed when the AI requires this data to complete a certain task.
</MudJustifiedText>
<MudTable Items="@this.SettingsManager.ConfigurationData.DataSources" Hover="@true" Class="border-dashed border rounded-lg">
<ColGroup>
<col style="width: 3em;"/>
<col/>
<col style="width: 12em;"/>
<col style="width: 12em;"/>
<col style="width: 40em;"/>
</ColGroup>
<HeaderContent>
<MudTh>#</MudTh>
<MudTh>Name</MudTh>
<MudTh>Type</MudTh>
<MudTh>Embedding</MudTh>
<MudTh Style="text-align: left;">Actions</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Num</MudTd>
<MudTd>@context.Name</MudTd>
<MudTd>@context.Type.GetDisplayName()</MudTd>
<MudTd>@this.GetEmbeddingName(context)</MudTd>
<MudTd Style="text-align: left;">
@if (context is IERIDataSource)
{
@* <MudButton Variant="Variant.Filled" Color="Color.Info" StartIcon="@Icons.Material.Filled.Info" Class="ma-2" OnClick="() => this.ShowInformation(context)"> *@
@* Show Information *@
@* </MudButton> *@
}
<MudButton Variant="Variant.Filled" Color="Color.Info" StartIcon="@Icons.Material.Filled.Edit" Class="ma-2" OnClick="() => this.EditDataSource(context)">
Edit
</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Error" StartIcon="@Icons.Material.Filled.Delete" Class="ma-2" OnClick="() => this.DeleteDataSource(context)">
Delete
</MudButton>
</MudTd>
</RowTemplate>
</MudTable>
@if (this.SettingsManager.ConfigurationData.DataSources.Count == 0)
{
<MudText Typo="Typo.h6" Class="mt-3">No data sources configured yet.</MudText>
}
<MudMenu EndIcon="@Icons.Material.Filled.KeyboardArrowDown" Label="Add Data Source" Color="Color.Primary" Variant="Variant.Filled" AnchorOrigin="Origin.CenterCenter" TransformOrigin="Origin.TopLeft" Class="mt-3 mb-6">
<MudMenuItem OnClick="() => this.AddDataSource(DataSourceType.ERI_V1)">External Data (ERI-Server v1)</MudMenuItem>
<MudMenuItem OnClick="() => this.AddDataSource(DataSourceType.LOCAL_DIRECTORY)">Local Directory</MudMenuItem>
<MudMenuItem OnClick="() => this.AddDataSource(DataSourceType.LOCAL_FILE)">Local File</MudMenuItem>
</MudMenu>
</ExpansionPanel>
}

View File

@ -0,0 +1,233 @@
using AIStudio.Dialogs;
using AIStudio.Settings;
using AIStudio.Settings.DataModel;
using ERI_Client.V1;
using Microsoft.AspNetCore.Components;
using DialogOptions = AIStudio.Dialogs.DialogOptions;
namespace AIStudio.Components.Settings;
public partial class SettingsPanelDataSources : SettingsPanelBase
{
[Parameter]
public List<ConfigurationSelectData<string>> AvailableDataSources { get; set; } = new();
[Parameter]
public EventCallback<List<ConfigurationSelectData<string>>> AvailableDataSourcesChanged { get; set; }
[Parameter]
public Func<IReadOnlyList<ConfigurationSelectData<string>>> AvailableEmbeddingsFunc { get; set; } = () => [];
#region Overrides of ComponentBase
protected override async Task OnInitializedAsync()
{
await this.UpdateDataSources();
await base.OnInitializedAsync();
}
#endregion
private string GetEmbeddingName(IDataSource dataSource)
{
if(dataSource is IInternalDataSource internalDataSource)
{
var matchedEmbedding = this.SettingsManager.ConfigurationData.EmbeddingProviders.FirstOrDefault(x => x.Id == internalDataSource.EmbeddingId);
if(matchedEmbedding == default)
return "No valid embedding";
return matchedEmbedding.Name;
}
if(dataSource is IExternalDataSource)
return "External (ERI)";
return "Unknown";
}
private async Task AddDataSource(DataSourceType type)
{
IDataSource? addedDataSource = null;
switch (type)
{
case DataSourceType.LOCAL_FILE:
var localFileDialogParameters = new DialogParameters<DataSourceLocalFileDialog>
{
{ x => x.IsEditing, false },
{ x => x.AvailableEmbeddings, this.AvailableEmbeddingsFunc() }
};
var localFileDialogReference = await this.DialogService.ShowAsync<DataSourceLocalFileDialog>("Add Local File as Data Source", localFileDialogParameters, DialogOptions.FULLSCREEN);
var localFileDialogResult = await localFileDialogReference.Result;
if (localFileDialogResult is null || localFileDialogResult.Canceled)
return;
var localFile = (DataSourceLocalFile)localFileDialogResult.Data!;
localFile = localFile with { Num = this.SettingsManager.ConfigurationData.NextDataSourceNum++ };
addedDataSource = localFile;
break;
case DataSourceType.LOCAL_DIRECTORY:
var localDirectoryDialogParameters = new DialogParameters<DataSourceLocalDirectoryDialog>
{
{ x => x.IsEditing, false },
{ x => x.AvailableEmbeddings, this.AvailableEmbeddingsFunc() }
};
var localDirectoryDialogReference = await this.DialogService.ShowAsync<DataSourceLocalDirectoryDialog>("Add Local Directory as Data Source", localDirectoryDialogParameters, DialogOptions.FULLSCREEN);
var localDirectoryDialogResult = await localDirectoryDialogReference.Result;
if (localDirectoryDialogResult is null || localDirectoryDialogResult.Canceled)
return;
var localDirectory = (DataSourceLocalDirectory)localDirectoryDialogResult.Data!;
localDirectory = localDirectory with { Num = this.SettingsManager.ConfigurationData.NextDataSourceNum++ };
addedDataSource = localDirectory;
break;
case DataSourceType.ERI_V1:
var eriDialogParameters = new DialogParameters<DataSourceERI_V1Dialog>
{
{ x => x.IsEditing, false },
};
var eriDialogReference = await this.DialogService.ShowAsync<DataSourceERI_V1Dialog>("Add ERI v1 Data Source", eriDialogParameters, DialogOptions.FULLSCREEN);
var eriDialogResult = await eriDialogReference.Result;
if (eriDialogResult is null || eriDialogResult.Canceled)
return;
var eriDataSource = (DataSourceERI_V1)eriDialogResult.Data!;
eriDataSource = eriDataSource with { Num = this.SettingsManager.ConfigurationData.NextDataSourceNum++ };
addedDataSource = eriDataSource;
break;
}
if(addedDataSource is null)
return;
this.SettingsManager.ConfigurationData.DataSources.Add(addedDataSource);
await this.UpdateDataSources();
await this.SettingsManager.StoreSettings();
await this.MessageBus.SendMessage<bool>(this, Event.CONFIGURATION_CHANGED);
}
private async Task EditDataSource(IDataSource dataSource)
{
IDataSource? editedDataSource = null;
switch (dataSource)
{
case DataSourceLocalFile localFile:
var localFileDialogParameters = new DialogParameters<DataSourceLocalFileDialog>
{
{ x => x.IsEditing, true },
{ x => x.DataSource, localFile },
{ x => x.AvailableEmbeddings, this.AvailableEmbeddingsFunc() }
};
var localFileDialogReference = await this.DialogService.ShowAsync<DataSourceLocalFileDialog>("Edit Local File Data Source", localFileDialogParameters, DialogOptions.FULLSCREEN);
var localFileDialogResult = await localFileDialogReference.Result;
if (localFileDialogResult is null || localFileDialogResult.Canceled)
return;
editedDataSource = (DataSourceLocalFile)localFileDialogResult.Data!;
break;
case DataSourceLocalDirectory localDirectory:
var localDirectoryDialogParameters = new DialogParameters<DataSourceLocalDirectoryDialog>
{
{ x => x.IsEditing, true },
{ x => x.DataSource, localDirectory },
{ x => x.AvailableEmbeddings, this.AvailableEmbeddingsFunc() }
};
var localDirectoryDialogReference = await this.DialogService.ShowAsync<DataSourceLocalDirectoryDialog>("Edit Local Directory Data Source", localDirectoryDialogParameters, DialogOptions.FULLSCREEN);
var localDirectoryDialogResult = await localDirectoryDialogReference.Result;
if (localDirectoryDialogResult is null || localDirectoryDialogResult.Canceled)
return;
editedDataSource = (DataSourceLocalDirectory)localDirectoryDialogResult.Data!;
break;
case DataSourceERI_V1 eriDataSource:
var eriDialogParameters = new DialogParameters<DataSourceERI_V1Dialog>
{
{ x => x.IsEditing, true },
{ x => x.DataSource, eriDataSource },
};
var eriDialogReference = await this.DialogService.ShowAsync<DataSourceERI_V1Dialog>("Edit ERI v1 Data Source", eriDialogParameters, DialogOptions.FULLSCREEN);
var eriDialogResult = await eriDialogReference.Result;
if (eriDialogResult is null || eriDialogResult.Canceled)
return;
editedDataSource = (DataSourceERI_V1)eriDialogResult.Data!;
break;
}
if(editedDataSource is null)
return;
this.SettingsManager.ConfigurationData.DataSources[this.SettingsManager.ConfigurationData.DataSources.IndexOf(dataSource)] = editedDataSource;
await this.UpdateDataSources();
await this.SettingsManager.StoreSettings();
await this.MessageBus.SendMessage<bool>(this, Event.CONFIGURATION_CHANGED);
}
private async Task DeleteDataSource(IDataSource dataSource)
{
var dialogParameters = new DialogParameters
{
{ "Message", $"Are you sure you want to delete the data source '{dataSource.Name}' of type {dataSource.Type.GetDisplayName()}?" },
};
var dialogReference = await this.DialogService.ShowAsync<ConfirmDialog>("Delete Data Source", dialogParameters, DialogOptions.FULLSCREEN);
var dialogResult = await dialogReference.Result;
if (dialogResult is null || dialogResult.Canceled)
return;
var applyChanges = dataSource is IInternalDataSource;
// External data sources may need a secret for authentication:
if (dataSource is IExternalDataSource externalDataSource)
{
// When the auth method is NONE or KERBEROS, we don't need to delete a secret.
// In the case of KERBEROS, we don't store the Kerberos ticket in the secret store.
if(dataSource is IERIDataSource { AuthMethod: AuthMethod.NONE or AuthMethod.KERBEROS })
applyChanges = true;
// All other auth methods require a secret, which we need to delete now:
else
{
var deleteSecretResponse = await this.RustService.DeleteSecret(externalDataSource);
if (deleteSecretResponse.Success)
applyChanges = true;
}
}
if(applyChanges)
{
this.SettingsManager.ConfigurationData.DataSources.Remove(dataSource);
await this.SettingsManager.StoreSettings();
await this.UpdateDataSources();
await this.MessageBus.SendMessage<bool>(this, Event.CONFIGURATION_CHANGED);
}
}
private Task ShowInformation(IDataSource dataSource)
{
#warning Implement the information dialog for ERI data sources.
return Task.CompletedTask;
}
private async Task UpdateDataSources()
{
this.AvailableDataSources.Clear();
foreach (var dataSource in this.SettingsManager.ConfigurationData.DataSources)
this.AvailableDataSources.Add(new (dataSource.Name, dataSource.Id));
await this.AvailableDataSourcesChanged.InvokeAsync(this.AvailableDataSources);
}
}

View File

@ -21,7 +21,17 @@ public partial class SettingsPanelEmbeddings : SettingsPanelBase
var modelName = provider.Model.ToString();
return modelName.Length > MAX_LENGTH ? "[...] " + modelName[^Math.Min(MAX_LENGTH, modelName.Length)..] : modelName;
}
#region Overrides of ComponentBase
protected override async Task OnInitializedAsync()
{
await this.UpdateEmbeddingProviders();
await base.OnInitializedAsync();
}
#endregion
private async Task AddEmbeddingProvider()
{
var dialogParameters = new DialogParameters<EmbeddingProviderDialog>

View File

@ -0,0 +1,128 @@
@using ERI_Client.V1
<MudDialog>
<DialogContent>
<MudForm @ref="@this.form" @bind-IsValid="@this.dataIsValid" @bind-Errors="@this.dataIssues">
@* ReSharper disable once CSharpWarnings::CS8974 *@
<MudTextField
T="string"
@bind-Text="@this.dataName"
Label="Data Source Name"
Class="mb-6"
MaxLength="40"
Counter="40"
Immediate="@true"
Validation="@this.dataSourceValidation.ValidatingName"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Lightbulb"
AdornmentColor="Color.Info"
UserAttributes="@SPELLCHECK_ATTRIBUTES"
/>
<MudStack Row="@true">
@* ReSharper disable once CSharpWarnings::CS8974 *@
<MudTextField
T="string"
@bind-Text="@this.dataHostname"
Label="ERI v1 Server Hostname"
Class="mb-6"
Immediate="@true"
Validation="@this.dataSourceValidation.ValidatingHostname"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.NetworkCheck"
AdornmentColor="Color.Info"
Variant="Variant.Text"
UserAttributes="@SPELLCHECK_ATTRIBUTES"/>
<MudNumericField
Label="Port"
Immediate="@true"
Min="1" Max="65535"
Validation="@this.dataSourceValidation.ValidatePort"
@bind-Value="@this.dataPort"
Variant="Variant.Text"
Margin="Margin.Dense"/>
</MudStack>
@if (!this.IsConnectionEncrypted())
{
<MudJustifiedText Typo="Typo.body1" Color="Color.Error" Class="mb-3">
Please note: the connection to the ERI v1 server is not encrypted. This means that all
data sent to the server is transmitted in plain text. Please ask the ERI server administrator
to enable encryption.
</MudJustifiedText>
}
@if (this.IsConnectionPossible())
{
<MudStack Row="@true" AlignItems="AlignItems.Center">
<MudButton Variant="Variant.Filled" Color="@this.GetTestResultColor()" StartIcon="@this.GetTestResultIcon()" Class="mb-3" OnClick="@this.TestConnection">
Test connection & read available metadata
</MudButton>
<MudText Typo="Typo.body1" Class="mb-3">
@this.GetTestResultText()
</MudText>
</MudStack>
}
@if(this.availableAuthMethods.Count > 0 || this.dataAuthMethod != default)
{
<MudSelect @bind-Value="@this.dataAuthMethod" Text="@this.dataAuthMethod.DisplayName()" Label="Authentication Method" Class="mb-3" OpenIcon="@Icons.Material.Filled.ExpandMore" AdornmentColor="Color.Info" Adornment="Adornment.Start" Validation="@this.dataSourceValidation.ValidateAuthMethod">
@foreach (var authMethod in this.availableAuthMethods)
{
<MudSelectItem Value="@authMethod">@authMethod.DisplayName()</MudSelectItem>
}
</MudSelect>
}
@if (this.NeedsSecret())
{
if (this.dataAuthMethod is AuthMethod.USERNAME_PASSWORD)
{
@* ReSharper disable once CSharpWarnings::CS8974 *@
<MudTextField
T="string"
@bind-Text="@this.dataUsername"
Label="Username"
Class="mb-6"
Immediate="@true"
Validation="@this.dataSourceValidation.ValidateUsername"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Person2"
AdornmentColor="Color.Info"
Variant="Variant.Text"
UserAttributes="@SPELLCHECK_ATTRIBUTES"/>
}
@* ReSharper disable once CSharpWarnings::CS8974 *@
<MudTextField
T="string"
@bind-Text="@this.dataSecret"
Label="@this.GetSecretLabel()"
Class="mb-6"
Immediate="@true"
Validation="@this.dataSourceValidation.ValidatingSecret"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Security"
AdornmentColor="Color.Info"
Variant="Variant.Text"
InputType="InputType.Password"
UserAttributes="@SPELLCHECK_ATTRIBUTES"/>
}
</MudForm>
<Issues IssuesData="@this.dataIssues"/>
</DialogContent>
<DialogActions>
<MudButton OnClick="@this.Cancel" Variant="Variant.Filled">Cancel</MudButton>
<MudButton OnClick="@this.Store" Variant="Variant.Filled" Color="Color.Primary">
@if(this.IsEditing)
{
@:Update
}
else
{
@:Add
}
</MudButton>
</DialogActions>
</MudDialog>

View File

@ -0,0 +1,277 @@
using AIStudio.Settings;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.Validation;
using ERI_Client.V1;
using Microsoft.AspNetCore.Components;
// ReSharper disable InconsistentNaming
namespace AIStudio.Dialogs;
public partial class DataSourceERI_V1Dialog : ComponentBase, ISecretId
{
[CascadingParameter]
private MudDialogInstance MudDialog { get; set; } = null!;
[Parameter]
public bool IsEditing { get; set; }
[Parameter]
public DataSourceERI_V1 DataSource { get; set; }
[Inject]
private SettingsManager SettingsManager { get; init; } = null!;
[Inject]
private ILogger<ProviderDialog> Logger { get; init; } = null!;
[Inject]
private RustService RustService { get; init; } = null!;
private static readonly Dictionary<string, object?> SPELLCHECK_ATTRIBUTES = new();
private readonly DataSourceValidation dataSourceValidation;
private readonly Encryption encryption = Program.ENCRYPTION;
/// <summary>
/// The list of used data source names. We need this to check for uniqueness.
/// </summary>
private List<string> UsedDataSourcesNames { get; set; } = [];
private bool dataIsValid;
private string[] dataIssues = [];
private string dataSecretStorageIssue = string.Empty;
private string dataEditingPreviousInstanceName = string.Empty;
private HttpClient? httpClient;
private List<AuthMethod> availableAuthMethods = [];
private bool connectionTested;
private bool connectionSuccessfulTested;
private uint dataNum;
private string dataSecret = string.Empty;
private string dataId = Guid.NewGuid().ToString();
private string dataName = string.Empty;
private string dataHostname = string.Empty;
private int dataPort;
private AuthMethod dataAuthMethod;
private string dataUsername = string.Empty;
// We get the form reference from Blazor code to validate it manually:
private MudForm form = null!;
public DataSourceERI_V1Dialog()
{
this.dataSourceValidation = new()
{
GetAuthMethod = () => this.dataAuthMethod,
GetPreviousDataSourceName = () => this.dataEditingPreviousInstanceName,
GetUsedDataSourceNames = () => this.UsedDataSourcesNames,
GetSecretStorageIssue = () => this.dataSecretStorageIssue,
GetTestedConnection = () => this.connectionTested,
GetTestedConnectionResult = () => this.connectionSuccessfulTested,
GetAvailableAuthMethods = () => this.availableAuthMethods,
};
}
#region Overrides of ComponentBase
protected override async Task OnInitializedAsync()
{
// Configure the spellchecking for the instance name input:
this.SettingsManager.InjectSpellchecking(SPELLCHECK_ATTRIBUTES);
// Load the used instance names:
this.UsedDataSourcesNames = this.SettingsManager.ConfigurationData.DataSources.Select(x => x.Name.ToLowerInvariant()).ToList();
// When editing, we need to load the data:
if(this.IsEditing)
{
this.dataEditingPreviousInstanceName = this.DataSource.Name.ToLowerInvariant();
this.dataNum = this.DataSource.Num;
this.dataId = this.DataSource.Id;
this.dataName = this.DataSource.Name;
this.dataHostname = this.DataSource.Hostname;
this.dataPort = this.DataSource.Port;
this.dataAuthMethod = this.DataSource.AuthMethod;
this.dataUsername = this.DataSource.Username;
if (this.dataAuthMethod is AuthMethod.TOKEN or AuthMethod.USERNAME_PASSWORD)
{
// Load the secret:
var requestedSecret = await this.RustService.GetSecret(this);
if (requestedSecret.Success)
this.dataSecret = await requestedSecret.Secret.Decrypt(this.encryption);
else
{
this.dataSecret = string.Empty;
this.dataSecretStorageIssue = $"Failed to load the auth. secret from the operating system. The message was: {requestedSecret.Issue}. You might ignore this message and provide the secret again.";
await this.form.Validate();
}
}
}
await base.OnInitializedAsync();
}
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
public string SecretId => this.dataId;
public string SecretName => this.dataName;
#endregion
private DataSourceERI_V1 CreateDataSource()
{
var cleanedHostname = this.dataHostname.Trim();
return new DataSourceERI_V1
{
Id = this.dataId,
Num = this.dataNum,
Port = this.dataPort,
Name = this.dataName,
Hostname = cleanedHostname.EndsWith('/') ? cleanedHostname[..^1] : cleanedHostname,
AuthMethod = this.dataAuthMethod,
Username = this.dataUsername,
Type = DataSourceType.ERI_V1,
};
}
private bool IsConnectionEncrypted() => this.dataHostname.StartsWith("https://", StringComparison.InvariantCultureIgnoreCase);
private bool IsConnectionPossible()
{
if(this.dataSourceValidation.ValidatingHostname(this.dataHostname) is not null)
return false;
if(this.dataSourceValidation.ValidatePort(this.dataPort) is not null)
return false;
return true;
}
private async Task TestConnection()
{
try
{
this.httpClient = new HttpClient
{
BaseAddress = new Uri($"{this.dataHostname}:{this.dataPort}"),
Timeout = TimeSpan.FromSeconds(5),
};
using (this.httpClient)
{
var client = new Client(this.httpClient);
var authSchemes = await client.GetAuthMethodsAsync();
if (authSchemes is null)
{
await this.form.Validate();
Array.Resize(ref this.dataIssues, this.dataIssues.Length + 1);
this.dataIssues[^1] = "Failed to connect to the ERI v1 server. The server did not respond.";
return;
}
this.availableAuthMethods = authSchemes.Select(n => n.AuthMethod).ToList();
this.connectionTested = true;
this.connectionSuccessfulTested = true;
this.Logger.LogInformation("Connection to the ERI v1 server was successful tested.");
}
}
catch (Exception e)
{
await this.form.Validate();
Array.Resize(ref this.dataIssues, this.dataIssues.Length + 1);
this.dataIssues[^1] = $"Failed to connect to the ERI v1 server. The message was: {e.Message}";
this.Logger.LogError($"Failed to connect to the ERI v1 server. Message: {e.Message}");
this.connectionTested = true;
this.connectionSuccessfulTested = false;
}
}
private string GetTestResultText()
{
if(!this.connectionTested)
return "Not tested yet.";
return this.connectionSuccessfulTested ? "Connection successful." : "Connection failed.";
}
private Color GetTestResultColor()
{
if (!this.connectionTested)
return Color.Default;
return this.connectionSuccessfulTested ? Color.Success : Color.Error;
}
private string GetTestResultIcon()
{
if (!this.connectionTested)
return Icons.Material.Outlined.HourglassEmpty;
return this.connectionSuccessfulTested ? Icons.Material.Outlined.CheckCircle : Icons.Material.Outlined.Error;
}
private bool NeedsSecret() => this.dataAuthMethod is AuthMethod.TOKEN or AuthMethod.USERNAME_PASSWORD;
private string GetSecretLabel() => this.dataAuthMethod switch
{
AuthMethod.TOKEN => "Access Token",
AuthMethod.USERNAME_PASSWORD => "Password",
_ => "Secret",
};
private async Task Store()
{
await this.form.Validate();
var testConnectionValidation = this.dataSourceValidation.ValidateTestedConnection();
if(testConnectionValidation is not null)
{
Array.Resize(ref this.dataIssues, this.dataIssues.Length + 1);
this.dataIssues[^1] = testConnectionValidation;
this.dataIsValid = false;
}
this.dataSecretStorageIssue = string.Empty;
// When the data is not valid, we don't store it:
if (!this.dataIsValid)
return;
var addedDataSource = this.CreateDataSource();
if (!string.IsNullOrWhiteSpace(this.dataSecret))
{
// Store the secret in the OS secure storage:
var storeResponse = await this.RustService.SetSecret(this, this.dataSecret);
if (!storeResponse.Success)
{
this.dataSecretStorageIssue = $"Failed to store the auth. secret in the operating system. The message was: {storeResponse.Issue}. Please try again.";
await this.form.Validate();
return;
}
}
this.MudDialog.Close(DialogResult.Ok(addedDataSource));
}
private void Cancel() => this.MudDialog.Cancel();
}

View File

@ -0,0 +1,79 @@
<MudDialog>
<DialogContent>
<MudForm @ref="@this.form" @bind-IsValid="@this.dataIsValid" @bind-Errors="@this.dataIssues">
@* ReSharper disable once CSharpWarnings::CS8974 *@
<MudTextField
T="string"
@bind-Text="@this.dataName"
Label="Data Source Name"
Class="mb-6"
MaxLength="40"
Counter="40"
Immediate="@true"
Validation="@this.dataSourceValidation.ValidatingName"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Lightbulb"
AdornmentColor="Color.Info"
UserAttributes="@SPELLCHECK_ATTRIBUTES"
/>
<MudJustifiedText Typo="Typo.body1" Class="mb-3">
Select a root directory for this data source. All data in this directory and all
its subdirectories will be processed for this data source.
</MudJustifiedText>
<SelectDirectory @bind-Directory="@this.dataPath" Label="Selected base directory for this data source" DirectoryDialogTitle="Select the base directory" Validation="@this.dataSourceValidation.ValidatePath" />
<MudJustifiedText Typo="Typo.body1" Class="mb-3">
In order for the AI to be able to determine the appropriate data at any time, you must
choose an embedding method.
</MudJustifiedText>
<MudSelect @bind-Value="@this.dataEmbeddingId" Label="Embedding" Class="mb-3" OpenIcon="@Icons.Material.Filled.ExpandMore" AdornmentColor="Color.Info" Adornment="Adornment.Start" Validation="@this.dataSourceValidation.ValidateEmbeddingId">
@foreach (var embedding in this.AvailableEmbeddings)
{
<MudSelectItem Value="@embedding.Value">@embedding.Name</MudSelectItem>
}
</MudSelect>
@if (!string.IsNullOrWhiteSpace(this.dataEmbeddingId))
{
if (this.SelectedCloudEmbedding)
{
<MudJustifiedText Typo="Typo.body1" Color="Color.Error" Class="mb-3">
@if (string.IsNullOrWhiteSpace(this.dataPath))
{
@: Please note: the embedding you selected runs in the cloud. All your data will be sent to the cloud.
@: Please confirm that you have read and understood this.
}
else
{
@: Please note: the embedding you selected runs in the cloud. All your data from the
@: folder '@this.dataPath' and all its subdirectories will be sent to the cloud. Please
@: confirm that you have read and understood this.
}
</MudJustifiedText>
<MudTextSwitch @bind-Value="@this.dataUserAcknowledgedCloudEmbedding" Label="I confirm that I have read and understood the above" LabelOn="Yes, please send my data to the cloud" LabelOff="No, I will chose another embedding" Validation="@this.dataSourceValidation.ValidateUserAcknowledgedCloudEmbedding"/>
}
else
{
<MudJustifiedText Typo="Typo.body1" Color="Color.Tertiary" Class="mb-3">
The embedding you selected runs locally or in your organization. Your data is not sent to the cloud.
</MudJustifiedText>
}
}
</MudForm>
<Issues IssuesData="@this.dataIssues"/>
</DialogContent>
<DialogActions>
<MudButton OnClick="@this.Cancel" Variant="Variant.Filled">Cancel</MudButton>
<MudButton OnClick="@this.Store" Variant="Variant.Filled" Color="Color.Primary">
@if(this.IsEditing)
{
@:Update
}
else
{
@:Add
}
</MudButton>
</DialogActions>
</MudDialog>

View File

@ -0,0 +1,120 @@
using AIStudio.Settings;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.Validation;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Dialogs;
public partial class DataSourceLocalDirectoryDialog : ComponentBase
{
[CascadingParameter]
private MudDialogInstance MudDialog { get; set; } = null!;
[Parameter]
public bool IsEditing { get; set; }
[Parameter]
public DataSourceLocalDirectory DataSource { get; set; }
[Parameter]
public IReadOnlyList<ConfigurationSelectData<string>> AvailableEmbeddings { get; set; } = [];
[Inject]
private SettingsManager SettingsManager { get; init; } = null!;
private static readonly Dictionary<string, object?> SPELLCHECK_ATTRIBUTES = new();
private readonly DataSourceValidation dataSourceValidation;
/// <summary>
/// The list of used data source names. We need this to check for uniqueness.
/// </summary>
private List<string> UsedDataSourcesNames { get; set; } = [];
private bool dataIsValid;
private string[] dataIssues = [];
private string dataEditingPreviousInstanceName = string.Empty;
private uint dataNum;
private string dataId = Guid.NewGuid().ToString();
private string dataName = string.Empty;
private bool dataUserAcknowledgedCloudEmbedding;
private string dataEmbeddingId = string.Empty;
private string dataPath = string.Empty;
// We get the form reference from Blazor code to validate it manually:
private MudForm form = null!;
public DataSourceLocalDirectoryDialog()
{
this.dataSourceValidation = new()
{
GetSelectedCloudEmbedding = () => this.SelectedCloudEmbedding,
GetPreviousDataSourceName = () => this.dataEditingPreviousInstanceName,
GetUsedDataSourceNames = () => this.UsedDataSourcesNames,
};
}
#region Overrides of ComponentBase
protected override async Task OnInitializedAsync()
{
// Configure the spellchecking for the instance name input:
this.SettingsManager.InjectSpellchecking(SPELLCHECK_ATTRIBUTES);
// Load the used instance names:
this.UsedDataSourcesNames = this.SettingsManager.ConfigurationData.DataSources.Select(x => x.Name.ToLowerInvariant()).ToList();
// When editing, we need to load the data:
if(this.IsEditing)
{
this.dataEditingPreviousInstanceName = this.DataSource.Name.ToLowerInvariant();
this.dataNum = this.DataSource.Num;
this.dataId = this.DataSource.Id;
this.dataName = this.DataSource.Name;
this.dataEmbeddingId = this.DataSource.EmbeddingId;
this.dataPath = this.DataSource.Path;
}
await base.OnInitializedAsync();
}
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
private bool SelectedCloudEmbedding => !this.SettingsManager.ConfigurationData.EmbeddingProviders.FirstOrDefault(x => x.Id == this.dataEmbeddingId).IsSelfHosted;
private DataSourceLocalDirectory CreateDataSource() => new()
{
Id = this.dataId,
Num = this.dataNum,
Name = this.dataName,
Type = DataSourceType.LOCAL_DIRECTORY,
EmbeddingId = this.dataEmbeddingId,
Path = this.dataPath,
};
private async Task Store()
{
await this.form.Validate();
// When the data is not valid, we don't store it:
if (!this.dataIsValid)
return;
var addedDataSource = this.CreateDataSource();
this.MudDialog.Close(DialogResult.Ok(addedDataSource));
}
private void Cancel() => this.MudDialog.Cancel();
}

View File

@ -0,0 +1,78 @@
<MudDialog>
<DialogContent>
<MudForm @ref="@this.form" @bind-IsValid="@this.dataIsValid" @bind-Errors="@this.dataIssues">
@* ReSharper disable once CSharpWarnings::CS8974 *@
<MudTextField
T="string"
@bind-Text="@this.dataName"
Label="Data Source Name"
Class="mb-6"
MaxLength="40"
Counter="40"
Immediate="@true"
Validation="@this.dataSourceValidation.ValidatingName"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Lightbulb"
AdornmentColor="Color.Info"
UserAttributes="@SPELLCHECK_ATTRIBUTES"
/>
<MudJustifiedText Typo="Typo.body1" Class="mb-3">
Select a file for this data source. The content of this file will be processed for the data source.
</MudJustifiedText>
<SelectFile @bind-File="@this.dataFilePath" Label="Selected file path for this data source" FileDialogTitle="Select the file" Validation="@this.dataSourceValidation.ValidateFilePath" />
<MudJustifiedText Typo="Typo.body1" Class="mb-3">
In order for the AI to be able to determine the appropriate data at any time, you must
choose an embedding method.
</MudJustifiedText>
<MudSelect @bind-Value="@this.dataEmbeddingId" Label="Embedding" Class="mb-3" OpenIcon="@Icons.Material.Filled.ExpandMore" AdornmentColor="Color.Info" Adornment="Adornment.Start" Validation="@this.dataSourceValidation.GetSelectedCloudEmbedding">
@foreach (var embedding in this.AvailableEmbeddings)
{
<MudSelectItem Value="@embedding.Value">@embedding.Name</MudSelectItem>
}
</MudSelect>
@if (!string.IsNullOrWhiteSpace(this.dataEmbeddingId))
{
if (this.SelectedCloudEmbedding)
{
<MudJustifiedText Typo="Typo.body1" Color="Color.Error" Class="mb-3">
@if (string.IsNullOrWhiteSpace(this.dataFilePath))
{
@: Please note: the embedding you selected runs in the cloud. All your data will be sent to the cloud.
@: Please confirm that you have read and understood this.
}
else
{
@: Please note: the embedding you selected runs in the cloud. All your data within the
@: file '@this.dataFilePath' will be sent to the cloud. Please confirm that you have read
@: and understood this.
}
</MudJustifiedText>
<MudTextSwitch @bind-Value="@this.dataUserAcknowledgedCloudEmbedding" Label="I confirm that I have read and understood the above" LabelOn="Yes, please send my data to the cloud" LabelOff="No, I will chose another embedding" Validation="@this.dataSourceValidation.ValidateUserAcknowledgedCloudEmbedding"/>
}
else
{
<MudJustifiedText Typo="Typo.body1" Color="Color.Tertiary" Class="mb-3">
The embedding you selected runs locally or in your organization. Your data is not sent to the cloud.
</MudJustifiedText>
}
}
</MudForm>
<Issues IssuesData="@this.dataIssues"/>
</DialogContent>
<DialogActions>
<MudButton OnClick="@this.Cancel" Variant="Variant.Filled">Cancel</MudButton>
<MudButton OnClick="@this.Store" Variant="Variant.Filled" Color="Color.Primary">
@if(this.IsEditing)
{
@:Update
}
else
{
@:Add
}
</MudButton>
</DialogActions>
</MudDialog>

View File

@ -0,0 +1,121 @@
using AIStudio.Settings;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.Validation;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Dialogs;
public partial class DataSourceLocalFileDialog : ComponentBase
{
[CascadingParameter]
private MudDialogInstance MudDialog { get; set; } = null!;
[Parameter]
public bool IsEditing { get; set; }
[Parameter]
public DataSourceLocalFile DataSource { get; set; }
[Parameter]
public IReadOnlyList<ConfigurationSelectData<string>> AvailableEmbeddings { get; set; } = [];
[Inject]
private SettingsManager SettingsManager { get; init; } = null!;
private static readonly Dictionary<string, object?> SPELLCHECK_ATTRIBUTES = new();
private readonly DataSourceValidation dataSourceValidation;
/// <summary>
/// The list of used data source names. We need this to check for uniqueness.
/// </summary>
private List<string> UsedDataSourcesNames { get; set; } = [];
private bool dataIsValid;
private string[] dataIssues = [];
private string dataEditingPreviousInstanceName = string.Empty;
private uint dataNum;
private string dataId = Guid.NewGuid().ToString();
private string dataName = string.Empty;
private bool dataUserAcknowledgedCloudEmbedding;
private string dataEmbeddingId = string.Empty;
private string dataFilePath = string.Empty;
// We get the form reference from Blazor code to validate it manually:
private MudForm form = null!;
public DataSourceLocalFileDialog()
{
this.dataSourceValidation = new()
{
GetSelectedCloudEmbedding = () => this.SelectedCloudEmbedding,
GetPreviousDataSourceName = () => this.dataEditingPreviousInstanceName,
GetUsedDataSourceNames = () => this.UsedDataSourcesNames,
};
}
#region Overrides of ComponentBase
protected override async Task OnInitializedAsync()
{
// Configure the spellchecking for the instance name input:
this.SettingsManager.InjectSpellchecking(SPELLCHECK_ATTRIBUTES);
// Load the used instance names:
this.UsedDataSourcesNames = this.SettingsManager.ConfigurationData.DataSources.Select(x => x.Name.ToLowerInvariant()).ToList();
// When editing, we need to load the data:
if(this.IsEditing)
{
this.dataEditingPreviousInstanceName = this.DataSource.Name.ToLowerInvariant();
this.dataNum = this.DataSource.Num;
this.dataId = this.DataSource.Id;
this.dataName = this.DataSource.Name;
this.dataEmbeddingId = this.DataSource.EmbeddingId;
this.dataFilePath = this.DataSource.FilePath;
}
await base.OnInitializedAsync();
}
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
private bool SelectedCloudEmbedding => !this.SettingsManager.ConfigurationData.EmbeddingProviders.FirstOrDefault(x => x.Id == this.dataEmbeddingId).IsSelfHosted;
private DataSourceLocalFile CreateDataSource() => new()
{
Id = this.dataId,
Num = this.dataNum,
Name = this.dataName,
Type = DataSourceType.LOCAL_FILE,
EmbeddingId = this.dataEmbeddingId,
FilePath = this.dataFilePath,
};
private async Task Store()
{
await this.form.Validate();
// When the data is not valid, we don't store it:
if (!this.dataIsValid)
return;
var addedDataSource = this.CreateDataSource();
this.MudDialog.Close(DialogResult.Ok(addedDataSource));
}
private void Cancel() => this.MudDialog.Cancel();
}

View File

@ -197,8 +197,7 @@ public partial class EmbeddingProviderDialog : ComponentBase, ISecretId
private async Task Store()
{
await this.form.Validate();
if (!string.IsNullOrWhiteSpace(this.dataAPIKeyStorageIssue))
this.dataAPIKeyStorageIssue = string.Empty;
this.dataAPIKeyStorageIssue = string.Empty;
// When the data is not valid, we don't store it:
if (!this.dataIsValid)

View File

@ -53,6 +53,10 @@
<PackageReference Include="ReverseMarkdown" Version="4.6.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ERIClientV1\ERIClientV1.csproj" />
</ItemGroup>
<!-- Read the meta data file -->
<Target Name="ReadMetaData" BeforeTargets="BeforeBuild">
<Error Text="The ../../metadata.txt file was not found!" Condition="!Exists('../../metadata.txt')" />

View File

@ -7,6 +7,7 @@
<MudExpansionPanels Class="mb-3" MultiExpansion="@false">
<SettingsPanelProviders @bind-AvailableLLMProviders="@this.availableLLMProviders" />
<SettingsPanelEmbeddings AvailableLLMProvidersFunc="() => this.availableLLMProviders" @bind-AvailableEmbeddingProviders="@this.availableEmbeddingProviders" />
<SettingsPanelDataSources AvailableLLMProvidersFunc="() => this.availableLLMProviders" AvailableEmbeddingsFunc="() => this.availableEmbeddingProviders" @bind-AvailableDataSources="@this.availableDataSources" />
<SettingsPanelProfiles AvailableLLMProvidersFunc="() => this.availableLLMProviders" />
<SettingsPanelApp AvailableLLMProvidersFunc="() => this.availableLLMProviders" />
<SettingsPanelChat AvailableLLMProvidersFunc="() => this.availableLLMProviders" />

View File

@ -11,6 +11,7 @@ public partial class Settings : ComponentBase, IMessageBusReceiver, IDisposable
private List<ConfigurationSelectData<string>> availableLLMProviders = new();
private List<ConfigurationSelectData<string>> availableEmbeddingProviders = new();
private List<ConfigurationSelectData<string>> availableDataSources = new();
#region Overrides of ComponentBase

View File

@ -25,6 +25,11 @@ public sealed class Data
/// A collection of embedding providers configured.
/// </summary>
public List<EmbeddingProvider> EmbeddingProviders { get; init; } = [];
/// <summary>
/// A collection of data sources configured.
/// </summary>
public List<IDataSource> DataSources { get; set; } = [];
/// <summary>
/// List of configured profiles.
@ -41,6 +46,11 @@ public sealed class Data
/// </summary>
public uint NextEmbeddingNum { get; set; } = 1;
/// <summary>
/// The next data source number to use.
/// </summary>
public uint NextDataSourceNum { get; set; } = 1;
/// <summary>
/// The next profile number to use.
/// </summary>

View File

@ -0,0 +1,46 @@
using ERI_Client.V1;
// ReSharper disable InconsistentNaming
namespace AIStudio.Settings.DataModel;
/// <summary>
/// An external data source, accessed via an ERI server, cf. https://github.com/MindWorkAI/ERI.
/// </summary>
public readonly record struct DataSourceERI_V1 : IERIDataSource
{
public DataSourceERI_V1()
{
}
/// <inheritdoc />
public uint Num { get; init; }
/// <inheritdoc />
public string Id { get; init; } = Guid.Empty.ToString();
/// <inheritdoc />
public string Name { get; init; } = string.Empty;
/// <inheritdoc />
public DataSourceType Type { get; init; } = DataSourceType.NONE;
/// <summary>
/// The hostname of the ERI server.
/// </summary>
public string Hostname { get; init; } = string.Empty;
/// <summary>
/// The port of the ERI server.
/// </summary>
public int Port { get; init; }
/// <summary>
/// The authentication method to use.
/// </summary>
public AuthMethod AuthMethod { get; init; } = AuthMethod.NONE;
/// <summary>
/// The username to use for authentication, when the auth. method is USERNAME_PASSWORD.
/// </summary>
public string Username { get; init; } = string.Empty;
}

View File

@ -0,0 +1,31 @@
namespace AIStudio.Settings.DataModel;
/// <summary>
/// Represents a local directory as a data source.
/// </summary>
public readonly record struct DataSourceLocalDirectory : IInternalDataSource
{
public DataSourceLocalDirectory()
{
}
/// <inheritdoc />
public uint Num { get; init; }
/// <inheritdoc />
public string Id { get; init; } = Guid.Empty.ToString();
/// <inheritdoc />
public string Name { get; init; } = string.Empty;
/// <inheritdoc />
public DataSourceType Type { get; init; } = DataSourceType.NONE;
/// <inheritdoc />
public string EmbeddingId { get; init; } = Guid.Empty.ToString();
/// <summary>
/// The path to the directory.
/// </summary>
public string Path { get; init; } = string.Empty;
}

View File

@ -0,0 +1,31 @@
namespace AIStudio.Settings.DataModel;
/// <summary>
/// Represents one local file as a data source.
/// </summary>
public readonly record struct DataSourceLocalFile : IInternalDataSource
{
public DataSourceLocalFile()
{
}
/// <inheritdoc />
public uint Num { get; init; }
/// <inheritdoc />
public string Id { get; init; } = Guid.Empty.ToString();
/// <inheritdoc />
public string Name { get; init; } = string.Empty;
/// <inheritdoc />
public DataSourceType Type { get; init; } = DataSourceType.NONE;
/// <inheritdoc />
public string EmbeddingId { get; init; } = Guid.Empty.ToString();
/// <summary>
/// The path to the file.
/// </summary>
public string FilePath { get; init; } = string.Empty;
}

View File

@ -0,0 +1,27 @@
namespace AIStudio.Settings.DataModel;
/// <summary>
/// AI Studio data source types.
/// </summary>
public enum DataSourceType
{
/// <summary>
/// No data source.
/// </summary>
NONE = 0,
/// <summary>
/// One file on the local machine (or a network share).
/// </summary>
LOCAL_FILE,
/// <summary>
/// A directory on the local machine (or a network share).
/// </summary>
LOCAL_DIRECTORY,
/// <summary>
/// External data source accessed via an ERI server, cf. https://github.com/MindWorkAI/ERI.
/// </summary>
ERI_V1,
}

View File

@ -0,0 +1,24 @@
namespace AIStudio.Settings.DataModel;
/// <summary>
/// Extension methods for data source types.
/// </summary>
public static class DataSourceTypeExtension
{
/// <summary>
/// Get the display name of the data source type.
/// </summary>
/// <param name="type">The data source type.</param>
/// <returns>The display name of the data source type.</returns>
public static string GetDisplayName(this DataSourceType type)
{
return type switch
{
DataSourceType.LOCAL_FILE => "Local File",
DataSourceType.LOCAL_DIRECTORY => "Local Directory",
DataSourceType.ERI_V1 => "External ERI Server (v1)",
_ => "None",
};
}
}

View File

@ -0,0 +1,35 @@
using System.Text.Json.Serialization;
using AIStudio.Settings.DataModel;
namespace AIStudio.Settings;
/// <summary>
/// The common interface for all data sources.
/// </summary>
[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type_discriminator")]
[JsonDerivedType(typeof(DataSourceLocalDirectory), nameof(DataSourceType.LOCAL_DIRECTORY))]
[JsonDerivedType(typeof(DataSourceLocalFile), nameof(DataSourceType.LOCAL_FILE))]
[JsonDerivedType(typeof(DataSourceERI_V1), nameof(DataSourceType.ERI_V1))]
public interface IDataSource
{
/// <summary>
/// The number of the data source.
/// </summary>
public uint Num { get; init; }
/// <summary>
/// The unique identifier of the data source.
/// </summary>
public string Id { get; init; }
/// <summary>
/// The name of the data source.
/// </summary>
public string Name { get; init; }
/// <summary>
/// Which type of data source is this?
/// </summary>
public DataSourceType Type { get; init; }
}

View File

@ -0,0 +1,12 @@
using ERI_Client.V1;
namespace AIStudio.Settings;
public interface IERIDataSource : IExternalDataSource
{
public string Hostname { get; init; }
public int Port { get; init; }
public AuthMethod AuthMethod { get; init; }
}

View File

@ -0,0 +1,16 @@
using System.Text.Json.Serialization;
namespace AIStudio.Settings;
public interface IExternalDataSource : IDataSource, ISecretId
{
#region Implementation of ISecretId
[JsonIgnore]
string ISecretId.SecretId => this.Id;
[JsonIgnore]
string ISecretId.SecretName => this.Name;
#endregion
}

View File

@ -0,0 +1,9 @@
namespace AIStudio.Settings;
public interface IInternalDataSource : IDataSource
{
/// <summary>
/// The unique identifier of the embedding method used by this internal data source.
/// </summary>
public string EmbeddingId { get; init; }
}

View File

@ -0,0 +1,16 @@
using ERI_Client.V1;
namespace AIStudio.Tools;
public static class AuthMethodsV1Extensions
{
public static string DisplayName(this AuthMethod authMethod) => authMethod switch
{
AuthMethod.NONE => "None",
AuthMethod.USERNAME_PASSWORD => "Username & Password",
AuthMethod.KERBEROS => "SSO (Kerberos)",
AuthMethod.TOKEN => "Access Token",
_ => "Unknown authentication method",
};
}

View File

@ -0,0 +1,8 @@
namespace AIStudio.Tools.Rust;
/// <summary>
/// Data structure for selecting a file.
/// </summary>
/// <param name="UserCancelled">Was the file selection canceled?</param>
/// <param name="SelectedFilePath">The selected file, if any.</param>
public readonly record struct FileSelectionResponse(bool UserCancelled, string SelectedFilePath);

View File

@ -0,0 +1,7 @@
namespace AIStudio.Tools.Rust;
/// <summary>
/// Data structure for selecting a file when a previous file was selected.
/// </summary>
/// <param name="FilePath">The path of the previous file.</param>
public readonly record struct PreviousFile(string FilePath);

View File

@ -0,0 +1,76 @@
using AIStudio.Tools.Rust;
namespace AIStudio.Tools;
public sealed partial class RustService
{
/// <summary>
/// Try to get the API key for the given secret ID.
/// </summary>
/// <param name="secretId">The secret ID to get the API key for.</param>
/// <param name="isTrying">Indicates if we are trying to get the API key. In that case, we don't log errors.</param>
/// <returns>The requested secret.</returns>
public async Task<RequestedSecret> GetAPIKey(ISecretId secretId, bool isTrying = false)
{
var secretRequest = new SelectSecretRequest($"provider::{secretId.SecretId}::{secretId.SecretName}::api_key", Environment.UserName, isTrying);
var result = await this.http.PostAsJsonAsync("/secrets/get", secretRequest, this.jsonRustSerializerOptions);
if (!result.IsSuccessStatusCode)
{
if(!isTrying)
this.logger!.LogError($"Failed to get the API key for secret ID '{secretId.SecretId}' due to an API issue: '{result.StatusCode}'");
return new RequestedSecret(false, new EncryptedText(string.Empty), "Failed to get the API key due to an API issue.");
}
var secret = await result.Content.ReadFromJsonAsync<RequestedSecret>(this.jsonRustSerializerOptions);
if (!secret.Success && !isTrying)
this.logger!.LogError($"Failed to get the API key for secret ID '{secretId.SecretId}': '{secret.Issue}'");
return secret;
}
/// <summary>
/// Try to store the API key for the given secret ID.
/// </summary>
/// <param name="secretId">The secret ID to store the API key for.</param>
/// <param name="key">The API key to store.</param>
/// <returns>The store secret response.</returns>
public async Task<StoreSecretResponse> SetAPIKey(ISecretId secretId, string key)
{
var encryptedKey = await this.encryptor!.Encrypt(key);
var request = new StoreSecretRequest($"provider::{secretId.SecretId}::{secretId.SecretName}::api_key", Environment.UserName, encryptedKey);
var result = await this.http.PostAsJsonAsync("/secrets/store", request, this.jsonRustSerializerOptions);
if (!result.IsSuccessStatusCode)
{
this.logger!.LogError($"Failed to store the API key for secret ID '{secretId.SecretId}' due to an API issue: '{result.StatusCode}'");
return new StoreSecretResponse(false, "Failed to get the API key due to an API issue.");
}
var state = await result.Content.ReadFromJsonAsync<StoreSecretResponse>(this.jsonRustSerializerOptions);
if (!state.Success)
this.logger!.LogError($"Failed to store the API key for secret ID '{secretId.SecretId}': '{state.Issue}'");
return state;
}
/// <summary>
/// Tries to delete the API key for the given secret ID.
/// </summary>
/// <param name="secretId">The secret ID to delete the API key for.</param>
/// <returns>The delete secret response.</returns>
public async Task<DeleteSecretResponse> DeleteAPIKey(ISecretId secretId)
{
var request = new SelectSecretRequest($"provider::{secretId.SecretId}::{secretId.SecretName}::api_key", Environment.UserName, false);
var result = await this.http.PostAsJsonAsync("/secrets/delete", request, this.jsonRustSerializerOptions);
if (!result.IsSuccessStatusCode)
{
this.logger!.LogError($"Failed to delete the API key for secret ID '{secretId.SecretId}' due to an API issue: '{result.StatusCode}'");
return new DeleteSecretResponse{Success = false, WasEntryFound = false, Issue = "Failed to delete the API key due to an API issue."};
}
var state = await result.Content.ReadFromJsonAsync<DeleteSecretResponse>(this.jsonRustSerializerOptions);
if (!state.Success)
this.logger!.LogError($"Failed to delete the API key for secret ID '{secretId.SecretId}': '{state.Issue}'");
return state;
}
}

View File

@ -0,0 +1,120 @@
using System.Security.Cryptography;
namespace AIStudio.Tools;
public sealed partial class RustService
{
public async Task<int> GetAppPort()
{
Console.WriteLine("Trying to get app port from Rust runtime...");
//
// Note I: In the production environment, the Rust runtime is already running
// and listening on the given port. In the development environment, the IDE
// starts the Rust runtime in parallel with the .NET runtime. Since the
// Rust runtime needs some time to start, we have to wait for it to be ready.
//
const int MAX_TRIES = 160;
var tris = 0;
var wait4Try = TimeSpan.FromMilliseconds(250);
var url = new Uri($"https://127.0.0.1:{this.apiPort}/system/dotnet/port");
while (tris++ < MAX_TRIES)
{
//
// Note II: We use a new HttpClient instance for each try to avoid
// .NET is caching the result. When we use the same HttpClient
// instance, we would always get the same result (403 forbidden),
// without even trying to connect to the Rust server.
//
using var initialHttp = new HttpClient(new HttpClientHandler
{
//
// Note III: We have to create also a new HttpClientHandler instance
// for each try to avoid .NET is caching the result. This is necessary
// because it gets disposed when the HttpClient instance gets disposed.
//
ServerCertificateCustomValidationCallback = (_, certificate, _, _) =>
{
if(certificate is null)
return false;
var currentCertificateFingerprint = certificate.GetCertHashString(HashAlgorithmName.SHA256);
return currentCertificateFingerprint == this.certificateFingerprint;
}
});
initialHttp.DefaultRequestVersion = Version.Parse("2.0");
initialHttp.DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrHigher;
initialHttp.DefaultRequestHeaders.AddApiToken();
try
{
var response = await initialHttp.GetAsync(url);
if (!response.IsSuccessStatusCode)
{
Console.WriteLine($"Try {tris}/{MAX_TRIES} to get the app port from Rust runtime");
await Task.Delay(wait4Try);
continue;
}
var appPortContent = await response.Content.ReadAsStringAsync();
var appPort = int.Parse(appPortContent);
Console.WriteLine($"Received app port from Rust runtime: '{appPort}'");
return appPort;
}
catch (Exception e)
{
Console.WriteLine($"Error: Was not able to get the app port from Rust runtime: '{e.Message}'");
Console.WriteLine(e.InnerException);
throw;
}
}
Console.WriteLine("Failed to receive the app port from Rust runtime.");
return 0;
}
public async Task AppIsReady()
{
const string URL = "/system/dotnet/ready";
this.logger!.LogInformation("Notifying Rust runtime that the app is ready.");
try
{
var response = await this.http.GetAsync(URL);
if (!response.IsSuccessStatusCode)
{
this.logger!.LogError($"Failed to notify Rust runtime that the app is ready: '{response.StatusCode}'");
}
}
catch (Exception e)
{
this.logger!.LogError(e, "Failed to notify the Rust runtime that the app is ready.");
throw;
}
}
public async Task<string> GetConfigDirectory()
{
var response = await this.http.GetAsync("/system/directories/config");
if (!response.IsSuccessStatusCode)
{
this.logger!.LogError($"Failed to get the config directory from Rust: '{response.StatusCode}'");
return string.Empty;
}
return await response.Content.ReadAsStringAsync();
}
public async Task<string> GetDataDirectory()
{
var response = await this.http.GetAsync("/system/directories/data");
if (!response.IsSuccessStatusCode)
{
this.logger!.LogError($"Failed to get the data directory from Rust: '{response.StatusCode}'");
return string.Empty;
}
return await response.Content.ReadAsStringAsync();
}
}

View File

@ -0,0 +1,50 @@
using AIStudio.Tools.Rust;
namespace AIStudio.Tools;
public sealed partial class RustService
{
/// <summary>
/// Tries to copy the given text to the clipboard.
/// </summary>
/// <param name="snackbar">The snackbar to show the result.</param>
/// <param name="text">The text to copy to the clipboard.</param>
public async Task CopyText2Clipboard(ISnackbar snackbar, string text)
{
var message = "Successfully copied the text to your clipboard";
var iconColor = Color.Error;
var severity = Severity.Error;
try
{
var encryptedText = await text.Encrypt(this.encryptor!);
var response = await this.http.PostAsync("/clipboard/set", new StringContent(encryptedText.EncryptedData));
if (!response.IsSuccessStatusCode)
{
this.logger!.LogError($"Failed to copy the text to the clipboard due to an network error: '{response.StatusCode}'");
message = "Failed to copy the text to your clipboard.";
return;
}
var state = await response.Content.ReadFromJsonAsync<SetClipboardResponse>(this.jsonRustSerializerOptions);
if (!state.Success)
{
this.logger!.LogError("Failed to copy the text to the clipboard.");
message = "Failed to copy the text to your clipboard.";
return;
}
iconColor = Color.Success;
severity = Severity.Success;
this.logger!.LogDebug("Successfully copied the text to the clipboard.");
}
finally
{
snackbar.Add(message, severity, config =>
{
config.Icon = Icons.Material.Filled.ContentCopy;
config.IconSize = Size.Large;
config.IconColor = iconColor;
});
}
}
}

View File

@ -0,0 +1,32 @@
using AIStudio.Tools.Rust;
namespace AIStudio.Tools;
public sealed partial class RustService
{
public async Task<DirectorySelectionResponse> SelectDirectory(string title, string? initialDirectory = null)
{
PreviousDirectory? previousDirectory = initialDirectory is null ? null : new (initialDirectory);
var result = await this.http.PostAsJsonAsync($"/select/directory?title={title}", previousDirectory, this.jsonRustSerializerOptions);
if (!result.IsSuccessStatusCode)
{
this.logger!.LogError($"Failed to select a directory: '{result.StatusCode}'");
return new DirectorySelectionResponse(true, string.Empty);
}
return await result.Content.ReadFromJsonAsync<DirectorySelectionResponse>(this.jsonRustSerializerOptions);
}
public async Task<FileSelectionResponse> SelectFile(string title, string? initialFile = null)
{
PreviousFile? previousFile = initialFile is null ? null : new (initialFile);
var result = await this.http.PostAsJsonAsync($"/select/file?title={title}", previousFile, this.jsonRustSerializerOptions);
if (!result.IsSuccessStatusCode)
{
this.logger!.LogError($"Failed to select a file: '{result.StatusCode}'");
return new FileSelectionResponse(true, string.Empty);
}
return await result.Content.ReadFromJsonAsync<FileSelectionResponse>(this.jsonRustSerializerOptions);
}
}

View File

@ -0,0 +1,76 @@
using AIStudio.Tools.Rust;
namespace AIStudio.Tools;
public sealed partial class RustService
{
/// <summary>
/// Try to get the secret data for the given secret ID.
/// </summary>
/// <param name="secretId">The secret ID to get the data for.</param>
/// <param name="isTrying">Indicates if we are trying to get the data. In that case, we don't log errors.</param>
/// <returns>The requested secret.</returns>
public async Task<RequestedSecret> GetSecret(ISecretId secretId, bool isTrying = false)
{
var secretRequest = new SelectSecretRequest($"secret::{secretId.SecretId}::{secretId.SecretName}", Environment.UserName, isTrying);
var result = await this.http.PostAsJsonAsync("/secrets/get", secretRequest, this.jsonRustSerializerOptions);
if (!result.IsSuccessStatusCode)
{
if(!isTrying)
this.logger!.LogError($"Failed to get the secret data for secret ID '{secretId.SecretId}' due to an API issue: '{result.StatusCode}'");
return new RequestedSecret(false, new EncryptedText(string.Empty), "Failed to get the secret data due to an API issue.");
}
var secret = await result.Content.ReadFromJsonAsync<RequestedSecret>(this.jsonRustSerializerOptions);
if (!secret.Success && !isTrying)
this.logger!.LogError($"Failed to get the secret data for secret ID '{secretId.SecretId}': '{secret.Issue}'");
return secret;
}
/// <summary>
/// Try to store the secret data for the given secret ID.
/// </summary>
/// <param name="secretId">The secret ID to store the data for.</param>
/// <param name="secretData">The data to store.</param>
/// <returns>The store secret response.</returns>
public async Task<StoreSecretResponse> SetSecret(ISecretId secretId, string secretData)
{
var encryptedSecret = await this.encryptor!.Encrypt(secretData);
var request = new StoreSecretRequest($"secret::{secretId.SecretId}::{secretId.SecretName}", Environment.UserName, encryptedSecret);
var result = await this.http.PostAsJsonAsync("/secrets/store", request, this.jsonRustSerializerOptions);
if (!result.IsSuccessStatusCode)
{
this.logger!.LogError($"Failed to store the secret data for secret ID '{secretId.SecretId}' due to an API issue: '{result.StatusCode}'");
return new StoreSecretResponse(false, "Failed to get the secret data due to an API issue.");
}
var state = await result.Content.ReadFromJsonAsync<StoreSecretResponse>(this.jsonRustSerializerOptions);
if (!state.Success)
this.logger!.LogError($"Failed to store the secret data for secret ID '{secretId.SecretId}': '{state.Issue}'");
return state;
}
/// <summary>
/// Tries to delete the secret data for the given secret ID.
/// </summary>
/// <param name="secretId">The secret ID to delete the data for.</param>
/// <returns>The delete secret response.</returns>
public async Task<DeleteSecretResponse> DeleteSecret(ISecretId secretId)
{
var request = new SelectSecretRequest($"secret::{secretId.SecretId}::{secretId.SecretName}", Environment.UserName, false);
var result = await this.http.PostAsJsonAsync("/secrets/delete", request, this.jsonRustSerializerOptions);
if (!result.IsSuccessStatusCode)
{
this.logger!.LogError($"Failed to delete the secret data for secret ID '{secretId.SecretId}' due to an API issue: '{result.StatusCode}'");
return new DeleteSecretResponse{Success = false, WasEntryFound = false, Issue = "Failed to delete the secret data due to an API issue."};
}
var state = await result.Content.ReadFromJsonAsync<DeleteSecretResponse>(this.jsonRustSerializerOptions);
if (!state.Success)
this.logger!.LogError($"Failed to delete the secret data for secret ID '{secretId.SecretId}': '{state.Issue}'");
return state;
}
}

View File

@ -0,0 +1,40 @@
using AIStudio.Tools.Rust;
namespace AIStudio.Tools;
public sealed partial class RustService
{
public async Task<UpdateResponse> CheckForUpdate()
{
try
{
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(45));
var response = await this.http.GetFromJsonAsync<UpdateResponse>("/updates/check", this.jsonRustSerializerOptions, cts.Token);
this.logger!.LogInformation($"Checked for an update: update available='{response.UpdateIsAvailable}'; error='{response.Error}'; next version='{response.NewVersion}'; changelog len='{response.Changelog.Length}'");
return response;
}
catch (Exception e)
{
this.logger!.LogError(e, "Failed to check for an update.");
return new UpdateResponse
{
Error = true,
UpdateIsAvailable = false,
};
}
}
public async Task InstallUpdate()
{
try
{
var cts = new CancellationTokenSource();
await this.http.GetAsync("/updates/install", cts.Token);
}
catch (Exception e)
{
Console.WriteLine(e);
throw;
}
}
}

View File

@ -1,8 +1,6 @@
using System.Security.Cryptography;
using System.Text.Json;
using AIStudio.Tools.Rust;
// ReSharper disable NotAccessedPositionalProperty.Local
namespace AIStudio.Tools;
@ -10,7 +8,7 @@ namespace AIStudio.Tools;
/// <summary>
/// Calling Rust functions.
/// </summary>
public sealed class RustService : IDisposable
public sealed partial class RustService : IDisposable
{
private readonly HttpClient http;
@ -60,281 +58,6 @@ public sealed class RustService : IDisposable
{
this.encryptor = encryptionService;
}
public async Task<int> GetAppPort()
{
Console.WriteLine("Trying to get app port from Rust runtime...");
//
// Note I: In the production environment, the Rust runtime is already running
// and listening on the given port. In the development environment, the IDE
// starts the Rust runtime in parallel with the .NET runtime. Since the
// Rust runtime needs some time to start, we have to wait for it to be ready.
//
const int MAX_TRIES = 160;
var tris = 0;
var wait4Try = TimeSpan.FromMilliseconds(250);
var url = new Uri($"https://127.0.0.1:{this.apiPort}/system/dotnet/port");
while (tris++ < MAX_TRIES)
{
//
// Note II: We use a new HttpClient instance for each try to avoid
// .NET is caching the result. When we use the same HttpClient
// instance, we would always get the same result (403 forbidden),
// without even trying to connect to the Rust server.
//
using var initialHttp = new HttpClient(new HttpClientHandler
{
//
// Note III: We have to create also a new HttpClientHandler instance
// for each try to avoid .NET is caching the result. This is necessary
// because it gets disposed when the HttpClient instance gets disposed.
//
ServerCertificateCustomValidationCallback = (_, certificate, _, _) =>
{
if(certificate is null)
return false;
var currentCertificateFingerprint = certificate.GetCertHashString(HashAlgorithmName.SHA256);
return currentCertificateFingerprint == this.certificateFingerprint;
}
});
initialHttp.DefaultRequestVersion = Version.Parse("2.0");
initialHttp.DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrHigher;
initialHttp.DefaultRequestHeaders.AddApiToken();
try
{
var response = await initialHttp.GetAsync(url);
if (!response.IsSuccessStatusCode)
{
Console.WriteLine($"Try {tris}/{MAX_TRIES} to get the app port from Rust runtime");
await Task.Delay(wait4Try);
continue;
}
var appPortContent = await response.Content.ReadAsStringAsync();
var appPort = int.Parse(appPortContent);
Console.WriteLine($"Received app port from Rust runtime: '{appPort}'");
return appPort;
}
catch (Exception e)
{
Console.WriteLine($"Error: Was not able to get the app port from Rust runtime: '{e.Message}'");
Console.WriteLine(e.InnerException);
throw;
}
}
Console.WriteLine("Failed to receive the app port from Rust runtime.");
return 0;
}
public async Task AppIsReady()
{
const string URL = "/system/dotnet/ready";
this.logger!.LogInformation("Notifying Rust runtime that the app is ready.");
try
{
var response = await this.http.GetAsync(URL);
if (!response.IsSuccessStatusCode)
{
this.logger!.LogError($"Failed to notify Rust runtime that the app is ready: '{response.StatusCode}'");
}
}
catch (Exception e)
{
this.logger!.LogError(e, "Failed to notify the Rust runtime that the app is ready.");
throw;
}
}
public async Task<string> GetConfigDirectory()
{
var response = await this.http.GetAsync("/system/directories/config");
if (!response.IsSuccessStatusCode)
{
this.logger!.LogError($"Failed to get the config directory from Rust: '{response.StatusCode}'");
return string.Empty;
}
return await response.Content.ReadAsStringAsync();
}
public async Task<string> GetDataDirectory()
{
var response = await this.http.GetAsync("/system/directories/data");
if (!response.IsSuccessStatusCode)
{
this.logger!.LogError($"Failed to get the data directory from Rust: '{response.StatusCode}'");
return string.Empty;
}
return await response.Content.ReadAsStringAsync();
}
/// <summary>
/// Tries to copy the given text to the clipboard.
/// </summary>
/// <param name="snackbar">The snackbar to show the result.</param>
/// <param name="text">The text to copy to the clipboard.</param>
public async Task CopyText2Clipboard(ISnackbar snackbar, string text)
{
var message = "Successfully copied the text to your clipboard";
var iconColor = Color.Error;
var severity = Severity.Error;
try
{
var encryptedText = await text.Encrypt(this.encryptor!);
var response = await this.http.PostAsync("/clipboard/set", new StringContent(encryptedText.EncryptedData));
if (!response.IsSuccessStatusCode)
{
this.logger!.LogError($"Failed to copy the text to the clipboard due to an network error: '{response.StatusCode}'");
message = "Failed to copy the text to your clipboard.";
return;
}
var state = await response.Content.ReadFromJsonAsync<SetClipboardResponse>(this.jsonRustSerializerOptions);
if (!state.Success)
{
this.logger!.LogError("Failed to copy the text to the clipboard.");
message = "Failed to copy the text to your clipboard.";
return;
}
iconColor = Color.Success;
severity = Severity.Success;
this.logger!.LogDebug("Successfully copied the text to the clipboard.");
}
finally
{
snackbar.Add(message, severity, config =>
{
config.Icon = Icons.Material.Filled.ContentCopy;
config.IconSize = Size.Large;
config.IconColor = iconColor;
});
}
}
public async Task<UpdateResponse> CheckForUpdate()
{
try
{
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(45));
var response = await this.http.GetFromJsonAsync<UpdateResponse>("/updates/check", this.jsonRustSerializerOptions, cts.Token);
this.logger!.LogInformation($"Checked for an update: update available='{response.UpdateIsAvailable}'; error='{response.Error}'; next version='{response.NewVersion}'; changelog len='{response.Changelog.Length}'");
return response;
}
catch (Exception e)
{
this.logger!.LogError(e, "Failed to check for an update.");
return new UpdateResponse
{
Error = true,
UpdateIsAvailable = false,
};
}
}
public async Task InstallUpdate()
{
try
{
var cts = new CancellationTokenSource();
await this.http.GetAsync("/updates/install", cts.Token);
}
catch (Exception e)
{
Console.WriteLine(e);
throw;
}
}
/// <summary>
/// Try to get the API key for the given secret ID.
/// </summary>
/// <param name="secretId">The secret ID to get the API key for.</param>
/// <param name="isTrying">Indicates if we are trying to get the API key. In that case, we don't log errors.</param>
/// <returns>The requested secret.</returns>
public async Task<RequestedSecret> GetAPIKey(ISecretId secretId, bool isTrying = false)
{
var secretRequest = new SelectSecretRequest($"provider::{secretId.SecretId}::{secretId.SecretName}::api_key", Environment.UserName, isTrying);
var result = await this.http.PostAsJsonAsync("/secrets/get", secretRequest, this.jsonRustSerializerOptions);
if (!result.IsSuccessStatusCode)
{
if(!isTrying)
this.logger!.LogError($"Failed to get the API key for secret ID '{secretId.SecretId}' due to an API issue: '{result.StatusCode}'");
return new RequestedSecret(false, new EncryptedText(string.Empty), "Failed to get the API key due to an API issue.");
}
var secret = await result.Content.ReadFromJsonAsync<RequestedSecret>(this.jsonRustSerializerOptions);
if (!secret.Success && !isTrying)
this.logger!.LogError($"Failed to get the API key for secret ID '{secretId.SecretId}': '{secret.Issue}'");
return secret;
}
/// <summary>
/// Try to store the API key for the given secret ID.
/// </summary>
/// <param name="secretId">The secret ID to store the API key for.</param>
/// <param name="key">The API key to store.</param>
/// <returns>The store secret response.</returns>
public async Task<StoreSecretResponse> SetAPIKey(ISecretId secretId, string key)
{
var encryptedKey = await this.encryptor!.Encrypt(key);
var request = new StoreSecretRequest($"provider::{secretId.SecretId}::{secretId.SecretName}::api_key", Environment.UserName, encryptedKey);
var result = await this.http.PostAsJsonAsync("/secrets/store", request, this.jsonRustSerializerOptions);
if (!result.IsSuccessStatusCode)
{
this.logger!.LogError($"Failed to store the API key for secret ID '{secretId.SecretId}' due to an API issue: '{result.StatusCode}'");
return new StoreSecretResponse(false, "Failed to get the API key due to an API issue.");
}
var state = await result.Content.ReadFromJsonAsync<StoreSecretResponse>(this.jsonRustSerializerOptions);
if (!state.Success)
this.logger!.LogError($"Failed to store the API key for secret ID '{secretId.SecretId}': '{state.Issue}'");
return state;
}
/// <summary>
/// Tries to delete the API key for the given secret ID.
/// </summary>
/// <param name="secretId">The secret ID to delete the API key for.</param>
/// <returns>The delete secret response.</returns>
public async Task<DeleteSecretResponse> DeleteAPIKey(ISecretId secretId)
{
var request = new SelectSecretRequest($"provider::{secretId.SecretId}::{secretId.SecretName}::api_key", Environment.UserName, false);
var result = await this.http.PostAsJsonAsync("/secrets/delete", request, this.jsonRustSerializerOptions);
if (!result.IsSuccessStatusCode)
{
this.logger!.LogError($"Failed to delete the API key for secret ID '{secretId.SecretId}' due to an API issue: '{result.StatusCode}'");
return new DeleteSecretResponse{Success = false, WasEntryFound = false, Issue = "Failed to delete the API key due to an API issue."};
}
var state = await result.Content.ReadFromJsonAsync<DeleteSecretResponse>(this.jsonRustSerializerOptions);
if (!state.Success)
this.logger!.LogError($"Failed to delete the API key for secret ID '{secretId.SecretId}': '{state.Issue}'");
return state;
}
public async Task<DirectorySelectionResponse> SelectDirectory(string title, string? initialDirectory = null)
{
PreviousDirectory? previousDirectory = initialDirectory is null ? null : new (initialDirectory);
var result = await this.http.PostAsJsonAsync($"/select/directory?title={title}", previousDirectory, this.jsonRustSerializerOptions);
if (!result.IsSuccessStatusCode)
{
this.logger!.LogError($"Failed to select a directory: '{result.StatusCode}'");
return new DirectorySelectionResponse(true, string.Empty);
}
return await result.Content.ReadFromJsonAsync<DirectorySelectionResponse>(this.jsonRustSerializerOptions);
}
#region IDisposable

View File

@ -0,0 +1,149 @@
using ERI_Client.V1;
namespace AIStudio.Tools.Validation;
public sealed class DataSourceValidation
{
public Func<string> GetSecretStorageIssue { get; init; } = () => string.Empty;
public Func<string> GetPreviousDataSourceName { get; init; } = () => string.Empty;
public Func<IEnumerable<string>> GetUsedDataSourceNames { get; init; } = () => [];
public Func<AuthMethod> GetAuthMethod { get; init; } = () => AuthMethod.NONE;
public Func<bool> GetSelectedCloudEmbedding { get; init; } = () => false;
public Func<bool> GetTestedConnection { get; init; } = () => false;
public Func<bool> GetTestedConnectionResult { get; init; } = () => false;
public Func<IReadOnlyList<AuthMethod>> GetAvailableAuthMethods { get; init; } = () => [];
public string? ValidatingHostname(string hostname)
{
if(string.IsNullOrWhiteSpace(hostname))
return "Please enter a hostname, e.g., http://localhost:1234";
if(!hostname.StartsWith("http://", StringComparison.InvariantCultureIgnoreCase) && !hostname.StartsWith("https://", StringComparison.InvariantCultureIgnoreCase))
return "The hostname must start with either http:// or https://";
if(!Uri.TryCreate(hostname, UriKind.Absolute, out _))
return "The hostname is not a valid HTTP(S) URL.";
return null;
}
public string? ValidatePort(int port)
{
if(port is < 1 or > 65535)
return "The port must be between 1 and 65535.";
return null;
}
public string? ValidateUsername(string username)
{
if(this.GetAuthMethod() is not AuthMethod.USERNAME_PASSWORD)
return null;
if(string.IsNullOrWhiteSpace(username))
return "The username must not be empty.";
return null;
}
public string? ValidatingSecret(string secret)
{
var authMethod = this.GetAuthMethod();
if(authMethod is AuthMethod.NONE or AuthMethod.KERBEROS)
return null;
var secretStorageIssue = this.GetSecretStorageIssue();
if(!string.IsNullOrWhiteSpace(secretStorageIssue))
return secretStorageIssue;
if (string.IsNullOrWhiteSpace(secret))
return authMethod switch
{
AuthMethod.TOKEN => "Please enter your secure access token.",
AuthMethod.USERNAME_PASSWORD => "Please enter your password.",
_ => "Please enter the secret necessary for authentication."
};
return null;
}
public string? ValidatingName(string dataSourceName)
{
if(string.IsNullOrWhiteSpace(dataSourceName))
return "The name must not be empty.";
if (dataSourceName.Length > 40)
return "The name must not exceed 40 characters.";
var lowerName = dataSourceName.ToLowerInvariant();
if(lowerName != this.GetPreviousDataSourceName() && this.GetUsedDataSourceNames().Contains(lowerName))
return "The name is already used by another data source. Please choose a different name.";
return null;
}
public string? ValidatePath(string path)
{
if(string.IsNullOrWhiteSpace(path))
return "The path must not be empty. Please select a directory.";
if(!Directory.Exists(path))
return "The path does not exist. Please select a valid directory.";
return null;
}
public string? ValidateFilePath(string filePath)
{
if(string.IsNullOrWhiteSpace(filePath))
return "The file path must not be empty. Please select a file.";
if(!File.Exists(filePath))
return "The file does not exist. Please select a valid file.";
return null;
}
public string? ValidateEmbeddingId(string embeddingId)
{
if(string.IsNullOrWhiteSpace(embeddingId))
return "Please select an embedding provider.";
return null;
}
public string? ValidateUserAcknowledgedCloudEmbedding(bool value)
{
if(this.GetSelectedCloudEmbedding() && !value)
return "Please acknowledge that you are aware of the cloud embedding implications.";
return null;
}
public string? ValidateTestedConnection()
{
if(!this.GetTestedConnection())
return "Please test the connection before saving.";
if(!this.GetTestedConnectionResult())
return "The connection test failed. Please check the connection settings.";
return null;
}
public string? ValidateAuthMethod(AuthMethod authMethod)
{
if(!this.GetAvailableAuthMethods().Contains(authMethod))
return "Please select one valid authentication method.";
return null;
}
}

View File

@ -205,6 +205,9 @@
"type": "Transitive",
"resolved": "0.16.9",
"contentHash": "7WaVMHklpT3Ye2ragqRIwlFRsb6kOk63BOGADV0fan3ulVfGLUYkDi5yNUsZS/7FVNkWbtHAlDLmu4WnHGfqvQ=="
},
"ericlientv1": {
"type": "Project"
}
},
"net8.0/osx-arm64": {}

View File

@ -0,0 +1,3 @@
# v0.9.26, build 201 (2025-01-xx xx:xx UTC)
- Added the ability to configure local and remote (ERI) data sources in the settings as a preview feature behind the RAG feature flag.
- Fixed a bug in the ERI server assistant that allowed an empty directory as a base directory for the code generation.

View File

@ -270,4 +270,53 @@ pub struct PreviousDirectory {
pub struct DirectorySelectionResponse {
user_cancelled: bool,
selected_directory: String,
}
/// Let the user select a file.
#[post("/select/file?<title>", data = "<previous_file>")]
pub fn select_file(_token: APIToken, title: &str, previous_file: Option<Json<PreviousFile>>) -> Json<FileSelectionResponse> {
let file_path = match previous_file {
Some(previous) => {
let previous_path = previous.file_path.as_str();
FileDialogBuilder::new()
.set_title(title)
.set_directory(previous_path)
.pick_file()
},
None => {
FileDialogBuilder::new()
.set_title(title)
.pick_file()
},
};
match file_path {
Some(path) => {
info!("User selected file: {path:?}");
Json(FileSelectionResponse {
user_cancelled: false,
selected_file_path: path.to_str().unwrap().to_string(),
})
},
None => {
info!("User cancelled file selection.");
Json(FileSelectionResponse {
user_cancelled: true,
selected_file_path: String::from(""),
})
},
}
}
#[derive(Clone, Deserialize)]
pub struct PreviousFile {
file_path: String,
}
#[derive(Serialize)]
pub struct FileSelectionResponse {
user_cancelled: bool,
selected_file_path: String,
}

View File

@ -85,6 +85,7 @@ pub fn start_runtime_api() {
crate::app_window::check_for_update,
crate::app_window::install_update,
crate::app_window::select_directory,
crate::app_window::select_file,
crate::secret::get_secret,
crate::secret::store_secret,
crate::secret::delete_secret,