Implement support for self-hosted and local LLMs (#20)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2024-07-03 20:31:04 +02:00
1 parent 6cc1d37db8
commit 29263660fc
21 files changed
+418 -62

No files matched your search

+1 -1
View File
@@ -9,7 +9,7 @@ public sealed class Data
/// The version of the settings file. Allows us to upgrade the settings
/// when a new version is available.
/// </summary>
public Version Version { get; init; } = Version.V1;
public Version Version { get; init; } = Version.V2;
/// <summary>
/// List of configured providers.
+6 -1
View File
@@ -9,8 +9,10 @@ namespace AIStudio.Settings;
/// <param name="Id">The provider's ID.</param>
/// <param name="InstanceName">The provider's instance name. Useful for multiple instances of the same provider, e.g., to distinguish between different OpenAI API keys.</param>
/// <param name="UsedProvider">The provider used.</param>
/// <param name="IsSelfHosted">Whether the provider is self-hosted.</param>
/// <param name="Hostname">The hostname of the provider. Useful for self-hosted providers.</param>
/// <param name="Model">The LLM model to use for chat.</param>
public readonly record struct Provider(uint Num, string Id, string InstanceName, Providers UsedProvider, Model Model)
public readonly record struct Provider(uint Num, string Id, string InstanceName, Providers UsedProvider, Model Model, bool IsSelfHosted = false, string Hostname = "http://localhost:1234")
{
#region Overrides of ValueType
@@ -21,6 +23,9 @@ public readonly record struct Provider(uint Num, string Id, string InstanceName,
/// <returns>A string that represents the current provider in a human-readable format.</returns>
public override string ToString()
{
if(this.IsSelfHosted)
return $"{this.InstanceName} ({this.UsedProvider.ToName()}, {this.Hostname}, {this.Model})";
return $"{this.InstanceName} ({this.UsedProvider.ToName()}, {this.Model})";
}
@@ -4,6 +4,53 @@
<MudDialog>
<DialogContent>
<MudForm @ref="@this.form" @bind-IsValid="@this.dataIsValid" @bind-Errors="@this.dataIssues">
<MudStack Row="@true" AlignItems="AlignItems.Center">
@* ReSharper disable once CSharpWarnings::CS8974 *@
<MudSelect @bind-Value="@this.DataProvider" Label="Provider" Class="mb-3" OpenIcon="@Icons.Material.Filled.AccountBalance" AdornmentColor="Color.Info" Adornment="Adornment.Start" Validation="@this.ValidatingProvider">
@foreach (Providers provider in Enum.GetValues(typeof(Providers)))
{
<MudSelectItem Value="@provider">@provider</MudSelectItem>
}
</MudSelect>
<MudButton Disabled="@this.IsSelfHostedOrNone" Variant="Variant.Filled" Size="Size.Small" StartIcon="@Icons.Material.Filled.OpenInBrowser" Href="@this.GetProviderCreationURL()" Target="_blank">Create account</MudButton>
</MudStack>
@* ReSharper disable once CSharpWarnings::CS8974 *@
<MudTextField
T="string"
@bind-Text="@this.dataAPIKey"
Label="API Key"
Disabled="@this.IsSelfHostedOrNone"
Class="mb-3"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.VpnKey"
AdornmentColor="Color.Info"
InputType="InputType.Password"
Validation="@this.ValidatingAPIKey"
/>
<MudTextField
T="string"
@bind-Text="@this.DataHostname"
Label="Hostname"
Disabled="@this.IsCloudProvider"
Class="mb-3"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Dns"
AdornmentColor="Color.Info"
Validation="@this.ValidatingHostname"
/>
<MudStack Row="@true" AlignItems="AlignItems.Center">
<MudButton Disabled="@(!this.CanLoadModels)" Variant="Variant.Filled" Size="Size.Small" StartIcon="@Icons.Material.Filled.Refresh" OnClick="this.ReloadModels">Load</MudButton>
<MudSelect Disabled="@this.IsSelfHostedOrNone" @bind-Value="@this.DataModel" Label="Model" Class="mb-3" OpenIcon="@Icons.Material.Filled.FaceRetouchingNatural" AdornmentColor="Color.Info" Adornment="Adornment.Start" Validation="@this.ValidatingModel">
@foreach (var model in this.availableModels)
{
<MudSelectItem Value="@model">@model</MudSelectItem>
}
</MudSelect>
</MudStack>
@* ReSharper disable once CSharpWarnings::CS8974 *@
<MudTextField
T="string"
@@ -17,37 +64,6 @@
UserAttributes="@INSTANCE_NAME_ATTRIBUTES"
/>
@* ReSharper disable once CSharpWarnings::CS8974 *@
<MudSelect @bind-Value="@this.DataProvider" Label="Provider" Class="mb-3" OpenIcon="@Icons.Material.Filled.AccountBalance" AdornmentColor="Color.Info" Adornment="Adornment.Start" Validation="@this.ValidatingProvider">
@foreach (Providers provider in Enum.GetValues(typeof(Providers)))
{
<MudSelectItem Value="@provider">@provider</MudSelectItem>
}
</MudSelect>
@* ReSharper disable once CSharpWarnings::CS8974 *@
<MudTextField
T="string"
@bind-Text="@this.dataAPIKey"
Label="API Key"
Class="mb-3"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.VpnKey"
AdornmentColor="Color.Info"
InputType="InputType.Password"
Validation="@this.ValidatingAPIKey"
/>
<MudStack Row="@true" AlignItems="AlignItems.Center">
<MudButton Disabled="@(!this.CanLoadModels)" Variant="Variant.Filled" Size="Size.Small" StartIcon="@Icons.Material.Filled.Refresh" OnClick="this.ReloadModels">Reload</MudButton>
<MudSelect @bind-Value="@this.DataModel" Label="Model" Class="mb-3" OpenIcon="@Icons.Material.Filled.FaceRetouchingNatural" AdornmentColor="Color.Info" Adornment="Adornment.Start" Validation="@this.ValidatingModel">
@foreach (var model in this.availableModels)
{
<MudSelectItem Value="@model">@model</MudSelectItem>
}
</MudSelect>
</MudStack>
</MudForm>
@if (this.dataIssues.Any())
@@ -32,6 +32,18 @@ public partial class ProviderDialog : ComponentBase
[Parameter]
public string DataInstanceName { get; set; } = string.Empty;
/// <summary>
/// The chosen hostname for self-hosted providers.
/// </summary>
[Parameter]
public string DataHostname { get; set; } = string.Empty;
/// <summary>
/// Is this provider self-hosted?
/// </summary>
[Parameter]
public bool IsSelfHosted { get; set; }
/// <summary>
/// The provider to use.
/// </summary>
@@ -99,7 +111,8 @@ public partial class ProviderDialog : ComponentBase
this.dataAPIKey = requestedSecret.Secret;
// Now, we try to load the list of available models:
await this.ReloadModels();
if(this.DataProvider is not Providers.SELF_HOSTED)
await this.ReloadModels();
}
else
{
@@ -142,6 +155,8 @@ public partial class ProviderDialog : ComponentBase
InstanceName = this.DataInstanceName,
UsedProvider = this.DataProvider,
Model = this.DataModel,
IsSelfHosted = this.DataProvider is Providers.SELF_HOSTED,
Hostname = this.DataHostname,
};
// We need to instantiate the provider to store the API key:
@@ -169,33 +184,49 @@ public partial class ProviderDialog : ComponentBase
private string? ValidatingModel(Model model)
{
if(this.DataProvider is Providers.SELF_HOSTED)
return null;
if (model == default)
return "Please select a model.";
return null;
}
[GeneratedRegex("^[a-zA-Z0-9 ]+$")]
[GeneratedRegex(@"^[a-zA-Z0-9\-_. ]+$")]
private static partial Regex InstanceNameRegex();
private static readonly string[] RESERVED_NAMES = { "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9" };
private string? ValidatingInstanceName(string instanceName)
{
if(string.IsNullOrWhiteSpace(instanceName))
if (string.IsNullOrWhiteSpace(instanceName))
return "Please enter an instance name.";
if(instanceName.StartsWith(' '))
return "The instance name must not start with a space.";
if (instanceName.StartsWith(' ') || instanceName.StartsWith('.'))
return "The instance name must not start with a space or a dot.";
if(instanceName.EndsWith(' '))
return "The instance name must not end with a space.";
if (instanceName.EndsWith(' ') || instanceName.EndsWith('.'))
return "The instance name must not end with a space or a dot.";
if (instanceName.StartsWith('-') || instanceName.StartsWith('_'))
return "The instance name must not start with a hyphen or an underscore.";
if (instanceName.Length > 255)
return "The instance name must not exceed 255 characters.";
// The instance name must only contain letters, numbers, and spaces:
if (!InstanceNameRegex().IsMatch(instanceName))
return "The instance name must only contain letters, numbers, and spaces.";
return "The instance name must only contain letters, numbers, spaces, hyphens, underscores, and dots.";
if(instanceName.Contains(" "))
if (instanceName.Contains(" "))
return "The instance name must not contain consecutive spaces.";
if (RESERVED_NAMES.Contains(instanceName.ToUpperInvariant()))
return "This name is reserved and cannot be used.";
if (instanceName.Any(c => Path.GetInvalidFileNameChars().Contains(c)))
return "The instance name contains invalid characters.";
// The instance name must be unique:
var lowerInstanceName = instanceName.ToLowerInvariant();
if (lowerInstanceName != this.dataEditingPreviousInstanceName && this.UsedInstanceNames.Contains(lowerInstanceName))
@@ -206,6 +237,9 @@ public partial class ProviderDialog : ComponentBase
private string? ValidatingAPIKey(string apiKey)
{
if(this.DataProvider is Providers.SELF_HOSTED)
return null;
if(!string.IsNullOrWhiteSpace(this.dataAPIKeyStorageIssue))
return this.dataAPIKeyStorageIssue;
@@ -215,13 +249,28 @@ public partial class ProviderDialog : ComponentBase
return null;
}
private void Cancel() => this.MudDialog.Cancel();
private string? ValidatingHostname(string hostname)
{
if(this.DataProvider != Providers.SELF_HOSTED)
return null;
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://";
private bool CanLoadModels => !string.IsNullOrWhiteSpace(this.dataAPIKey) && this.DataProvider != Providers.NONE && !string.IsNullOrWhiteSpace(this.DataInstanceName);
if(!Uri.TryCreate(hostname, UriKind.Absolute, out _))
return "The hostname is not a valid HTTP(S) URL.";
return null;
}
private void Cancel() => this.MudDialog.Cancel();
private async Task ReloadModels()
{
var provider = this.DataProvider.CreateProvider(this.DataInstanceName);
var provider = this.DataProvider.CreateProvider("temp");
if(provider is NoProvider)
return;
@@ -233,4 +282,19 @@ public partial class ProviderDialog : ComponentBase
this.availableModels.Clear();
this.availableModels.AddRange(orderedModels);
}
private bool CanLoadModels => !string.IsNullOrWhiteSpace(this.dataAPIKey) && this.DataProvider != Providers.NONE && this.DataProvider != Providers.SELF_HOSTED;
private bool IsCloudProvider => this.DataProvider is not Providers.SELF_HOSTED;
private bool IsSelfHostedOrNone => this.DataProvider is Providers.SELF_HOSTED or Providers.NONE;
private string GetProviderCreationURL() => this.DataProvider switch
{
Providers.OPEN_AI => "https://platform.openai.com/signup",
Providers.MISTRAL => "https://console.mistral.ai/",
Providers.ANTHROPIC => "https://console.anthropic.com/dashboard",
_ => string.Empty,
};
}
@@ -103,7 +103,7 @@ public sealed class SettingsManager
if(loadedConfiguration is null)
return;
this.ConfigurationData = loadedConfiguration;
this.ConfigurationData = SettingsMigrations.Migrate(loadedConfiguration);
}
/// <summary>
@@ -0,0 +1,39 @@
namespace AIStudio.Settings;
public static class SettingsMigrations
{
public static Data Migrate(Data previousData)
{
switch (previousData.Version)
{
case Version.V1:
return MigrateFromV1(previousData);
default:
Console.WriteLine("No migration needed.");
return previousData;
}
}
private static Data MigrateFromV1(Data previousData)
{
//
// Summary:
// In v1 we had no self-hosted providers. Thus, we had no hostnames.
//
Console.WriteLine("Migrating from v1 to v2...");
return new()
{
Version = Version.V2,
Providers = previousData.Providers.Select(provider => provider with { IsSelfHosted = false, Hostname = "" }).ToList(),
EnableSpellchecking = previousData.EnableSpellchecking,
IsSavingEnergy = previousData.IsSavingEnergy,
NextProviderNum = previousData.NextProviderNum,
ShortcutSendBehavior = previousData.ShortcutSendBehavior,
UpdateBehavior = previousData.UpdateBehavior,
};
}
}
@@ -7,5 +7,7 @@ namespace AIStudio.Settings;
public enum Version
{
UNKNOWN,
V1,
V2,
}