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

This commit is contained in:
Thorsten Sommer 2024-07-03 20:31:04 +02:00 committed by GitHub
parent 6cc1d37db8
commit 29263660fc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 418 additions and 62 deletions

View File

@ -13,6 +13,7 @@ public partial class Changelog
public static readonly Log[] LOGS = public static readonly Log[] LOGS =
[ [
new (159, "v0.6.3, build 159 (2024-07-03 18:26 UTC)", "v0.6.3.md"),
new (158, "v0.6.2, build 158 (2024-07-01 18:03 UTC)", "v0.6.2.md"), new (158, "v0.6.2, build 158 (2024-07-01 18:03 UTC)", "v0.6.2.md"),
new (157, "v0.6.1, build 157 (2024-06-30 19:00 UTC)", "v0.6.1.md"), new (157, "v0.6.1, build 157 (2024-06-30 19:00 UTC)", "v0.6.1.md"),
new (156, "v0.6.0, build 156 (2024-06-30 12:49 UTC)", "v0.6.0.md"), new (156, "v0.6.0, build 156 (2024-06-30 12:49 UTC)", "v0.6.0.md"),

View File

@ -101,7 +101,7 @@ public partial class Chat : ComponentBase
// Use the selected provider to get the AI response. // Use the selected provider to get the AI response.
// By awaiting this line, we wait for the entire // By awaiting this line, we wait for the entire
// content to be streamed. // content to be streamed.
await aiText.CreateFromProviderAsync(this.selectedProvider.UsedProvider.CreateProvider(this.selectedProvider.InstanceName), this.JsRuntime, this.SettingsManager, this.selectedProvider.Model, this.chatThread); await aiText.CreateFromProviderAsync(this.selectedProvider.UsedProvider.CreateProvider(this.selectedProvider.InstanceName, this.selectedProvider.Hostname), this.JsRuntime, this.SettingsManager, this.selectedProvider.Model, this.chatThread);
// Disable the stream state: // Disable the stream state:
this.isStreaming = false; this.isStreaming = false;

View File

@ -1,4 +1,5 @@
@page "/settings" @page "/settings"
@using AIStudio.Provider
<MudText Typo="Typo.h3" Class="mb-12">Settings</MudText> <MudText Typo="Typo.h3" Class="mb-12">Settings</MudText>
@ -11,7 +12,7 @@
<col style="width: 12em;"/> <col style="width: 12em;"/>
<col style="width: 12em;"/> <col style="width: 12em;"/>
<col/> <col/>
<col style="width: 20em;"/> <col style="width: 34em;"/>
</ColGroup> </ColGroup>
<HeaderContent> <HeaderContent>
<MudTh>#</MudTh> <MudTh>#</MudTh>
@ -24,12 +25,20 @@
<MudTd>@context.Num</MudTd> <MudTd>@context.Num</MudTd>
<MudTd>@context.InstanceName</MudTd> <MudTd>@context.InstanceName</MudTd>
<MudTd>@context.UsedProvider</MudTd> <MudTd>@context.UsedProvider</MudTd>
<MudTd>@context.Model</MudTd> <MudTd>
@if(context.UsedProvider is not Providers.SELF_HOSTED)
@context.Model
else
@("as selected by provider")
</MudTd>
<MudTd Style="text-align: left;"> <MudTd Style="text-align: left;">
<MudButton Variant="Variant.Filled" Color="Color.Info" StartIcon="@Icons.Material.Filled.Edit" Class="mr-2" OnClick="() => this.EditProvider(context)"> <MudButton Variant="Variant.Filled" Color="Color.Info" StartIcon="@Icons.Material.Filled.OpenInBrowser" Class="ma-2" Href="@this.GetProviderDashboardURL(context.UsedProvider)" Target="_blank" Disabled="@(context.UsedProvider is Providers.NONE or Providers.SELF_HOSTED)">
Open Dashboard
</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Info" StartIcon="@Icons.Material.Filled.Edit" Class="ma-2" OnClick="() => this.EditProvider(context)">
Edit Edit
</MudButton> </MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Error" StartIcon="@Icons.Material.Filled.Delete" Class="mr-2" OnClick="() => this.DeleteProvider(context)"> <MudButton Variant="Variant.Filled" Color="Color.Error" StartIcon="@Icons.Material.Filled.Delete" Class="ma-2" OnClick="() => this.DeleteProvider(context)">
Delete Delete
</MudButton> </MudButton>
</MudTd> </MudTd>

View File

@ -50,6 +50,8 @@ public partial class Settings : ComponentBase
{ x => x.DataInstanceName, provider.InstanceName }, { x => x.DataInstanceName, provider.InstanceName },
{ x => x.DataProvider, provider.UsedProvider }, { x => x.DataProvider, provider.UsedProvider },
{ x => x.DataModel, provider.Model }, { x => x.DataModel, provider.Model },
{ x => x.DataHostname, provider.Hostname },
{ x => x.IsSelfHosted, provider.IsSelfHosted },
{ x => x.IsEditing, true }, { x => x.IsEditing, true },
}; };
@ -81,7 +83,7 @@ public partial class Settings : ComponentBase
if (dialogResult.Canceled) if (dialogResult.Canceled)
return; return;
var providerInstance = provider.UsedProvider.CreateProvider(provider.InstanceName); var providerInstance = provider.UsedProvider.CreateProvider(provider.InstanceName, provider.Hostname);
var deleteSecretResponse = await this.SettingsManager.DeleteAPIKey(this.JsRuntime, providerInstance); var deleteSecretResponse = await this.SettingsManager.DeleteAPIKey(this.JsRuntime, providerInstance);
if(deleteSecretResponse.Success) if(deleteSecretResponse.Success)
{ {
@ -90,5 +92,14 @@ public partial class Settings : ComponentBase
} }
} }
private string GetProviderDashboardURL(Providers provider) => provider switch
{
Providers.OPEN_AI => "https://platform.openai.com/usage",
Providers.MISTRAL => "https://console.mistral.ai/usage/",
Providers.ANTHROPIC => "https://console.anthropic.com/settings/plans",
_ => string.Empty,
};
#endregion #endregion
} }

View File

@ -1,6 +1,7 @@
using AIStudio.Provider.Anthropic; using AIStudio.Provider.Anthropic;
using AIStudio.Provider.Mistral; using AIStudio.Provider.Mistral;
using AIStudio.Provider.OpenAI; using AIStudio.Provider.OpenAI;
using AIStudio.Provider.SelfHosted;
namespace AIStudio.Provider; namespace AIStudio.Provider;
@ -10,9 +11,12 @@ namespace AIStudio.Provider;
public enum Providers public enum Providers
{ {
NONE, NONE,
OPEN_AI, OPEN_AI,
ANTHROPIC, ANTHROPIC,
MISTRAL, MISTRAL,
SELF_HOSTED,
} }
/// <summary> /// <summary>
@ -27,11 +31,14 @@ public static class ExtensionsProvider
/// <returns>The human-readable name of the provider.</returns> /// <returns>The human-readable name of the provider.</returns>
public static string ToName(this Providers provider) => provider switch public static string ToName(this Providers provider) => provider switch
{ {
Providers.NONE => "No provider selected",
Providers.OPEN_AI => "OpenAI", Providers.OPEN_AI => "OpenAI",
Providers.ANTHROPIC => "Anthropic", Providers.ANTHROPIC => "Anthropic",
Providers.MISTRAL => "Mistral", Providers.MISTRAL => "Mistral",
Providers.NONE => "No provider selected", Providers.SELF_HOSTED => "Self-hosted",
_ => "Unknown", _ => "Unknown",
}; };
@ -40,13 +47,16 @@ public static class ExtensionsProvider
/// </summary> /// </summary>
/// <param name="provider">The provider value.</param> /// <param name="provider">The provider value.</param>
/// <param name="instanceName">The used instance name.</param> /// <param name="instanceName">The used instance name.</param>
/// <param name="hostname">The hostname of the provider.</param>
/// <returns>The provider instance.</returns> /// <returns>The provider instance.</returns>
public static IProvider CreateProvider(this Providers provider, string instanceName) => provider switch public static IProvider CreateProvider(this Providers provider, string instanceName, string hostname = "http://localhost:1234") => provider switch
{ {
Providers.OPEN_AI => new ProviderOpenAI { InstanceName = instanceName }, Providers.OPEN_AI => new ProviderOpenAI { InstanceName = instanceName },
Providers.ANTHROPIC => new ProviderAnthropic { InstanceName = instanceName }, Providers.ANTHROPIC => new ProviderAnthropic { InstanceName = instanceName },
Providers.MISTRAL => new ProviderMistral { InstanceName = instanceName }, Providers.MISTRAL => new ProviderMistral { InstanceName = instanceName },
Providers.SELF_HOSTED => new ProviderSelfHosted(hostname) { InstanceName = instanceName },
_ => new NoProvider(), _ => new NoProvider(),
}; };
} }

View File

@ -0,0 +1,16 @@
namespace AIStudio.Provider.SelfHosted;
/// <summary>
/// The chat request model.
/// </summary>
/// <param name="Model">Which model to use for chat completion.</param>
/// <param name="Messages">The chat messages.</param>
/// <param name="Stream">Whether to stream the chat completion.</param>
/// <param name="MaxTokens">The maximum number of tokens to generate.</param>
public readonly record struct ChatRequest(
string Model,
IList<Message> Messages,
bool Stream,
int MaxTokens
);

View File

@ -0,0 +1,8 @@
namespace AIStudio.Provider.SelfHosted;
/// <summary>
/// Chat message model.
/// </summary>
/// <param name="Content">The text content of the message.</param>
/// <param name="Role">The role of the message.</param>
public readonly record struct Message(string Content, string Role);

View File

@ -0,0 +1,5 @@
namespace AIStudio.Provider.SelfHosted;
public readonly record struct ModelsResponse(string Object, Model[] Data);
public readonly record struct Model(string Id, string Object, string OwnedBy);

View File

@ -0,0 +1,162 @@
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
using AIStudio.Chat;
using AIStudio.Provider.OpenAI;
using AIStudio.Settings;
namespace AIStudio.Provider.SelfHosted;
public sealed class ProviderSelfHosted(string hostname) : BaseProvider($"{hostname}/v1/"), IProvider
{
private static readonly JsonSerializerOptions JSON_SERIALIZER_OPTIONS = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
};
#region Implementation of IProvider
public string Id => "Self-hosted";
public string InstanceName { get; set; } = "Self-hosted";
public async IAsyncEnumerable<string> StreamChatCompletion(IJSRuntime jsRuntime, SettingsManager settings, Provider.Model chatModel, ChatThread chatThread, [EnumeratorCancellation] CancellationToken token = default)
{
// Prepare the system prompt:
var systemPrompt = new Message
{
Role = "system",
Content = chatThread.SystemPrompt,
};
// Prepare the OpenAI HTTP chat request:
var providerChatRequest = JsonSerializer.Serialize(new ChatRequest
{
Model = (await this.GetTextModels(jsRuntime, settings, token: token)).First().Id,
// Build the messages:
// - First of all the system prompt
// - Then none-empty user and AI messages
Messages = [systemPrompt, ..chatThread.Blocks.Where(n => n.ContentType is ContentType.TEXT && !string.IsNullOrWhiteSpace((n.Content as ContentText)?.Text)).Select(n => new Message
{
Role = n.Role switch
{
ChatRole.USER => "user",
ChatRole.AI => "assistant",
ChatRole.SYSTEM => "system",
_ => "user",
},
Content = n.Content switch
{
ContentText text => text.Text,
_ => string.Empty,
}
}).ToList()],
// Right now, we only support streaming completions:
Stream = true,
MaxTokens = -1,
}, JSON_SERIALIZER_OPTIONS);
// Build the HTTP post request:
var request = new HttpRequestMessage(HttpMethod.Post, "chat/completions");
// Set the content:
request.Content = new StringContent(providerChatRequest, Encoding.UTF8, "application/json");
// Send the request with the ResponseHeadersRead option.
// This allows us to read the stream as soon as the headers are received.
// This is important because we want to stream the responses.
var response = await this.httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, token);
// Open the response stream:
var providerStream = await response.Content.ReadAsStreamAsync(token);
// Add a stream reader to read the stream, line by line:
var streamReader = new StreamReader(providerStream);
// Read the stream, line by line:
while(!streamReader.EndOfStream)
{
// Check if the token is cancelled:
if(token.IsCancellationRequested)
yield break;
// Read the next line:
var line = await streamReader.ReadLineAsync(token);
// Skip empty lines:
if(string.IsNullOrWhiteSpace(line))
continue;
// Skip lines that do not start with "data: ". Regard
// to the specification, we only want to read the data lines:
if(!line.StartsWith("data: ", StringComparison.InvariantCulture))
continue;
// Check if the line is the end of the stream:
if (line.StartsWith("data: [DONE]", StringComparison.InvariantCulture))
yield break;
ResponseStreamLine providerResponse;
try
{
// We know that the line starts with "data: ". Hence, we can
// skip the first 6 characters to get the JSON data after that.
var jsonData = line[6..];
// Deserialize the JSON data:
providerResponse = JsonSerializer.Deserialize<ResponseStreamLine>(jsonData, JSON_SERIALIZER_OPTIONS);
}
catch
{
// Skip invalid JSON data:
continue;
}
// Skip empty responses:
if(providerResponse == default || providerResponse.Choices.Count == 0)
continue;
// Yield the response:
yield return providerResponse.Choices[0].Delta.Content;
}
}
#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously
/// <inheritdoc />
public async IAsyncEnumerable<ImageURL> StreamImageCompletion(IJSRuntime jsRuntime, SettingsManager settings, Provider.Model imageModel, string promptPositive, string promptNegative = FilterOperator.String.Empty, ImageURL referenceImageURL = default, [EnumeratorCancellation] CancellationToken token = default)
{
yield break;
}
#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously
public async Task<IEnumerable<Provider.Model>> GetTextModels(IJSRuntime jsRuntime, SettingsManager settings, string? apiKeyProvisional = null, CancellationToken token = default)
{
var request = new HttpRequestMessage(HttpMethod.Get, "models");
var response = await this.httpClient.SendAsync(request, token);
if(!response.IsSuccessStatusCode)
return [];
var modelResponse = await response.Content.ReadFromJsonAsync<ModelsResponse>(token);
if (modelResponse.Data.Length > 1)
Console.WriteLine("Warning: multiple models found; using the first one.");
var firstModel = modelResponse.Data.First();
return [ new Provider.Model(firstModel.Id) ];
}
#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously
/// <inheritdoc />
public Task<IEnumerable<Provider.Model>> GetImageModels(IJSRuntime jsRuntime, SettingsManager settings, string? apiKeyProvisional = null, CancellationToken token = default)
{
return Task.FromResult(Enumerable.Empty<Provider.Model>());
}
#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously
#endregion
}

View File

@ -9,7 +9,7 @@ public sealed class Data
/// The version of the settings file. Allows us to upgrade the settings /// The version of the settings file. Allows us to upgrade the settings
/// when a new version is available. /// when a new version is available.
/// </summary> /// </summary>
public Version Version { get; init; } = Version.V1; public Version Version { get; init; } = Version.V2;
/// <summary> /// <summary>
/// List of configured providers. /// List of configured providers.

View File

@ -9,8 +9,10 @@ namespace AIStudio.Settings;
/// <param name="Id">The provider's ID.</param> /// <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="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="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> /// <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 #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> /// <returns>A string that represents the current provider in a human-readable format.</returns>
public override string ToString() 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})"; return $"{this.InstanceName} ({this.UsedProvider.ToName()}, {this.Model})";
} }

View File

@ -4,6 +4,53 @@
<MudDialog> <MudDialog>
<DialogContent> <DialogContent>
<MudForm @ref="@this.form" @bind-IsValid="@this.dataIsValid" @bind-Errors="@this.dataIssues"> <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 *@ @* ReSharper disable once CSharpWarnings::CS8974 *@
<MudTextField <MudTextField
T="string" T="string"
@ -17,37 +64,6 @@
UserAttributes="@INSTANCE_NAME_ATTRIBUTES" 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> </MudForm>
@if (this.dataIssues.Any()) @if (this.dataIssues.Any())

View File

@ -32,6 +32,18 @@ public partial class ProviderDialog : ComponentBase
[Parameter] [Parameter]
public string DataInstanceName { get; set; } = string.Empty; 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> /// <summary>
/// The provider to use. /// The provider to use.
/// </summary> /// </summary>
@ -99,6 +111,7 @@ public partial class ProviderDialog : ComponentBase
this.dataAPIKey = requestedSecret.Secret; this.dataAPIKey = requestedSecret.Secret;
// Now, we try to load the list of available models: // Now, we try to load the list of available models:
if(this.DataProvider is not Providers.SELF_HOSTED)
await this.ReloadModels(); await this.ReloadModels();
} }
else else
@ -142,6 +155,8 @@ public partial class ProviderDialog : ComponentBase
InstanceName = this.DataInstanceName, InstanceName = this.DataInstanceName,
UsedProvider = this.DataProvider, UsedProvider = this.DataProvider,
Model = this.DataModel, Model = this.DataModel,
IsSelfHosted = this.DataProvider is Providers.SELF_HOSTED,
Hostname = this.DataHostname,
}; };
// We need to instantiate the provider to store the API key: // 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) private string? ValidatingModel(Model model)
{ {
if(this.DataProvider is Providers.SELF_HOSTED)
return null;
if (model == default) if (model == default)
return "Please select a model."; return "Please select a model.";
return null; return null;
} }
[GeneratedRegex("^[a-zA-Z0-9 ]+$")] [GeneratedRegex(@"^[a-zA-Z0-9\-_. ]+$")]
private static partial Regex InstanceNameRegex(); 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) private string? ValidatingInstanceName(string instanceName)
{ {
if (string.IsNullOrWhiteSpace(instanceName)) if (string.IsNullOrWhiteSpace(instanceName))
return "Please enter an instance name."; return "Please enter an instance name.";
if(instanceName.StartsWith(' ')) if (instanceName.StartsWith(' ') || instanceName.StartsWith('.'))
return "The instance name must not start with a space."; return "The instance name must not start with a space or a dot.";
if(instanceName.EndsWith(' ')) if (instanceName.EndsWith(' ') || instanceName.EndsWith('.'))
return "The instance name must not end with a space."; 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)) 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."; 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: // The instance name must be unique:
var lowerInstanceName = instanceName.ToLowerInvariant(); var lowerInstanceName = instanceName.ToLowerInvariant();
if (lowerInstanceName != this.dataEditingPreviousInstanceName && this.UsedInstanceNames.Contains(lowerInstanceName)) if (lowerInstanceName != this.dataEditingPreviousInstanceName && this.UsedInstanceNames.Contains(lowerInstanceName))
@ -206,6 +237,9 @@ public partial class ProviderDialog : ComponentBase
private string? ValidatingAPIKey(string apiKey) private string? ValidatingAPIKey(string apiKey)
{ {
if(this.DataProvider is Providers.SELF_HOSTED)
return null;
if(!string.IsNullOrWhiteSpace(this.dataAPIKeyStorageIssue)) if(!string.IsNullOrWhiteSpace(this.dataAPIKeyStorageIssue))
return this.dataAPIKeyStorageIssue; return this.dataAPIKeyStorageIssue;
@ -215,13 +249,28 @@ public partial class ProviderDialog : ComponentBase
return null; return null;
} }
private void Cancel() => this.MudDialog.Cancel(); private string? ValidatingHostname(string hostname)
{
if(this.DataProvider != Providers.SELF_HOSTED)
return null;
private bool CanLoadModels => !string.IsNullOrWhiteSpace(this.dataAPIKey) && this.DataProvider != Providers.NONE && !string.IsNullOrWhiteSpace(this.DataInstanceName); 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;
}
private void Cancel() => this.MudDialog.Cancel();
private async Task ReloadModels() private async Task ReloadModels()
{ {
var provider = this.DataProvider.CreateProvider(this.DataInstanceName); var provider = this.DataProvider.CreateProvider("temp");
if(provider is NoProvider) if(provider is NoProvider)
return; return;
@ -233,4 +282,19 @@ public partial class ProviderDialog : ComponentBase
this.availableModels.Clear(); this.availableModels.Clear();
this.availableModels.AddRange(orderedModels); 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,
};
} }

View File

@ -103,7 +103,7 @@ public sealed class SettingsManager
if(loadedConfiguration is null) if(loadedConfiguration is null)
return; return;
this.ConfigurationData = loadedConfiguration; this.ConfigurationData = SettingsMigrations.Migrate(loadedConfiguration);
} }
/// <summary> /// <summary>

View File

@ -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,
};
}
}

View File

@ -7,5 +7,7 @@ namespace AIStudio.Settings;
public enum Version public enum Version
{ {
UNKNOWN, UNKNOWN,
V1, V1,
V2,
} }

View File

@ -0,0 +1,8 @@
# v0.6.3, build 159 (2024-07-03 18:26 UTC)
- Added possibility to configure a self-hosted or local provider.
- Added settings migration processor to handle settings migration from previous versions.
- Added links to create an account for the selected provider in the provider dialog.
- Added links to each provider's dashboard to the settings page.
- Added self-hosted and local providers.
- Improved instance name validation.
- Optimized the provider dialog: changed the layout to improve usability.

View File

@ -1,9 +1,9 @@
0.6.2 0.6.3
2024-07-01 18:08:01 UTC 2024-07-03 18:26:31 UTC
158 159
8.0.206 (commit bb12410699) 8.0.206 (commit bb12410699)
8.0.6 (commit 3b8b000a0e) 8.0.6 (commit 3b8b000a0e)
1.79.0 (commit 129f3b996) 1.79.0 (commit 129f3b996)
6.20.0 6.20.0
1.6.1 1.6.1
c86a9e32c12, release ac6748e9eb5, release

2
runtime/Cargo.lock generated
View File

@ -2313,7 +2313,7 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]] [[package]]
name = "mindwork-ai-studio" name = "mindwork-ai-studio"
version = "0.6.2" version = "0.6.3"
dependencies = [ dependencies = [
"arboard", "arboard",
"flexi_logger", "flexi_logger",

View File

@ -1,6 +1,6 @@
[package] [package]
name = "mindwork-ai-studio" name = "mindwork-ai-studio"
version = "0.6.2" version = "0.6.3"
edition = "2021" edition = "2021"
description = "MindWork AI Studio" description = "MindWork AI Studio"
authors = ["Thorsten Sommer"] authors = ["Thorsten Sommer"]

View File

@ -6,7 +6,7 @@
}, },
"package": { "package": {
"productName": "MindWork AI Studio", "productName": "MindWork AI Studio",
"version": "0.6.2" "version": "0.6.3"
}, },
"tauri": { "tauri": {
"allowlist": { "allowlist": {