mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-17 17:38:00 +00:00
Integrated data sources into any chat (#282)
This commit is contained in:
1 parent
954cf44939
commit
f4780939fc
70 files changed
+1506
-250
No files matched your search
@@ -0,0 +1,209 @@
|
||||
using AIStudio.Assistants.ERI;
|
||||
using AIStudio.Provider;
|
||||
using AIStudio.Provider.SelfHosted;
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Settings.DataModel;
|
||||
using AIStudio.Tools.ERIClient;
|
||||
using AIStudio.Tools.ERIClient.DataModel;
|
||||
|
||||
namespace AIStudio.Tools.Services;
|
||||
|
||||
public sealed class DataSourceService
|
||||
{
|
||||
private readonly RustService rustService;
|
||||
private readonly SettingsManager settingsManager;
|
||||
private readonly ILogger<DataSourceService> logger;
|
||||
|
||||
public DataSourceService(SettingsManager settingsManager, ILogger<DataSourceService> logger, RustService rustService)
|
||||
{
|
||||
this.logger = logger;
|
||||
this.rustService = rustService;
|
||||
this.settingsManager = settingsManager;
|
||||
|
||||
this.logger.LogInformation("The data source service has been initialized.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a list of data sources that are allowed for the selected LLM provider.
|
||||
/// It also returns the data sources selected before when they are still allowed.
|
||||
/// </summary>
|
||||
/// <param name="selectedLLMProvider">The selected LLM provider.</param>
|
||||
/// <param name="previousSelectedDataSources">The data sources selected before.</param>
|
||||
/// <returns>The allowed data sources and the data sources selected before -- when they are still allowed.</returns>
|
||||
public async Task<AllowedSelectedDataSources> GetDataSources(AIStudio.Settings.Provider selectedLLMProvider, IReadOnlyCollection<IDataSource>? previousSelectedDataSources = null)
|
||||
{
|
||||
//
|
||||
// Case: Somehow the selected LLM provider was not set. The default provider
|
||||
// does not mean anything. We cannot filter the data sources by any means.
|
||||
// We return an empty list. Better safe than sorry.
|
||||
//
|
||||
if (selectedLLMProvider == default)
|
||||
{
|
||||
this.logger.LogWarning("The selected LLM provider is not set. We cannot filter the data sources by any means.");
|
||||
return new([], []);
|
||||
}
|
||||
|
||||
return await this.GetDataSources(selectedLLMProvider.IsSelfHosted, previousSelectedDataSources);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a list of data sources that are allowed for the selected LLM provider.
|
||||
/// It also returns the data sources selected before when they are still allowed.
|
||||
/// </summary>
|
||||
/// <param name="selectedLLMProvider">The selected LLM provider.</param>
|
||||
/// <param name="previousSelectedDataSources">The data sources selected before.</param>
|
||||
/// <returns>The allowed data sources and the data sources selected before -- when they are still allowed.</returns>
|
||||
public async Task<AllowedSelectedDataSources> GetDataSources(IProvider selectedLLMProvider, IReadOnlyCollection<IDataSource>? previousSelectedDataSources = null)
|
||||
{
|
||||
//
|
||||
// Case: Somehow the selected LLM provider was not set. The default provider
|
||||
// does not mean anything. We cannot filter the data sources by any means.
|
||||
// We return an empty list. Better safe than sorry.
|
||||
//
|
||||
if (selectedLLMProvider is NoProvider)
|
||||
{
|
||||
this.logger.LogWarning("The selected LLM provider is the default provider. We cannot filter the data sources by any means.");
|
||||
return new([], []);
|
||||
}
|
||||
|
||||
return await this.GetDataSources(selectedLLMProvider is ProviderSelfHosted, previousSelectedDataSources);
|
||||
}
|
||||
|
||||
private async Task<AllowedSelectedDataSources> GetDataSources(bool usingSelfHostedProvider, IReadOnlyCollection<IDataSource>? previousSelectedDataSources = null)
|
||||
{
|
||||
var allDataSources = this.settingsManager.ConfigurationData.DataSources;
|
||||
var filteredDataSources = new List<IDataSource>(allDataSources.Count);
|
||||
var filteredSelectedDataSources = new List<IDataSource>(previousSelectedDataSources?.Count ?? 0);
|
||||
var tasks = new List<Task<IDataSource?>>(allDataSources.Count);
|
||||
|
||||
// Start all checks in parallel:
|
||||
foreach (var source in allDataSources)
|
||||
tasks.Add(this.CheckOneDataSource(source, usingSelfHostedProvider));
|
||||
|
||||
// Wait for all checks and collect the results:
|
||||
foreach (var task in tasks)
|
||||
{
|
||||
var source = await task;
|
||||
if (source is not null)
|
||||
{
|
||||
filteredDataSources.Add(source);
|
||||
if (previousSelectedDataSources is not null && previousSelectedDataSources.Contains(source))
|
||||
filteredSelectedDataSources.Add(source);
|
||||
}
|
||||
}
|
||||
|
||||
return new(filteredDataSources, filteredSelectedDataSources);
|
||||
}
|
||||
|
||||
private async Task<IDataSource?> CheckOneDataSource(IDataSource source, bool usingSelfHostedProvider)
|
||||
{
|
||||
//
|
||||
// Unfortunately, we have to live-check any ERI source for its security requirements.
|
||||
// Because the ERI server operator might change the security requirements at any time.
|
||||
//
|
||||
SecurityRequirements? eriSourceRequirements = null;
|
||||
if (source is DataSourceERI_V1 eriSource)
|
||||
{
|
||||
using var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(6));
|
||||
using var client = ERIClientFactory.Get(ERIVersion.V1, eriSource);
|
||||
if(client is null)
|
||||
{
|
||||
this.logger.LogError($"Could not create ERI client for source '{source.Name}' (id={source.Id}). We skip this source.");
|
||||
return null;
|
||||
}
|
||||
|
||||
this.logger.LogInformation($"Authenticating with ERI source '{source.Name}' (id={source.Id})...");
|
||||
var loginResult = await client.AuthenticateAsync(eriSource, this.rustService, cancellationTokenSource.Token);
|
||||
if (!loginResult.Successful)
|
||||
{
|
||||
this.logger.LogWarning($"Authentication with ERI source '{source.Name}' (id={source.Id}) failed. We skip this source. Reason: {loginResult.Message}");
|
||||
return null;
|
||||
}
|
||||
|
||||
this.logger.LogInformation($"Checking security requirements for ERI source '{source.Name}' (id={source.Id})...");
|
||||
var securityRequest = await client.GetSecurityRequirementsAsync(cancellationTokenSource.Token);
|
||||
if (!securityRequest.Successful)
|
||||
{
|
||||
this.logger.LogWarning($"Could not retrieve security requirements for ERI source '{source.Name}' (id={source.Id}). We skip this source. Reason: {loginResult.Message}");
|
||||
return null;
|
||||
}
|
||||
|
||||
eriSourceRequirements = securityRequest.Data;
|
||||
this.logger.LogInformation($"Security requirements for ERI source '{source.Name}' (id={source.Id}) retrieved successfully.");
|
||||
}
|
||||
|
||||
switch (source.SecurityPolicy)
|
||||
{
|
||||
case DataSourceSecurity.ALLOW_ANY:
|
||||
|
||||
//
|
||||
// Case: The data source allows any provider type. We want to use a self-hosted provider.
|
||||
// There is no issue with this source. Accept it.
|
||||
//
|
||||
if(usingSelfHostedProvider)
|
||||
return source;
|
||||
|
||||
//
|
||||
// Case: This is a local data source. When the source allows any provider type, we can use it.
|
||||
// Accept it.
|
||||
//
|
||||
if(eriSourceRequirements is null)
|
||||
return source;
|
||||
|
||||
//
|
||||
// Case: The ERI source requires a self-hosted provider. This misconfiguration happens
|
||||
// when the ERI server operator changes the security requirements. The ERI server
|
||||
// operator owns the data -- we have to respect their rules. We skip this source.
|
||||
//
|
||||
if (eriSourceRequirements is { AllowedProviderType: ProviderType.SELF_HOSTED })
|
||||
{
|
||||
this.logger.LogWarning($"The ERI source '{source.Name}' (id={source.Id}) requires a self-hosted provider. We skip this source.");
|
||||
return null;
|
||||
}
|
||||
|
||||
//
|
||||
// Case: The ERI source allows any provider type. The data source configuration is correct.
|
||||
// Accept it.
|
||||
//
|
||||
if(eriSourceRequirements is { AllowedProviderType: ProviderType.ANY })
|
||||
return source;
|
||||
|
||||
//
|
||||
// Case: Missing rules. We skip this source. Better safe than sorry.
|
||||
//
|
||||
this.logger.LogDebug($"The ERI source '{source.Name}' (id={source.Id}) was filtered out due to missing rules.");
|
||||
return null;
|
||||
|
||||
//
|
||||
// Case: The data source requires a self-hosted provider. We want to use a self-hosted provider.
|
||||
// There is no issue with this source. Accept it.
|
||||
//
|
||||
case DataSourceSecurity.SELF_HOSTED when usingSelfHostedProvider:
|
||||
return source;
|
||||
|
||||
//
|
||||
// Case: The data source requires a self-hosted provider. We want to use a cloud provider.
|
||||
// We skip this source.
|
||||
//
|
||||
case DataSourceSecurity.SELF_HOSTED when !usingSelfHostedProvider:
|
||||
this.logger.LogWarning($"The data source '{source.Name}' (id={source.Id}) requires a self-hosted provider. We skip this source.");
|
||||
return null;
|
||||
|
||||
//
|
||||
// Case: The data source did not specify a security policy. We skip this source.
|
||||
// Better safe than sorry.
|
||||
//
|
||||
case DataSourceSecurity.NOT_SPECIFIED:
|
||||
this.logger.LogWarning($"The data source '{source.Name}' (id={source.Id}) has no security policy. We skip this source.");
|
||||
return null;
|
||||
|
||||
//
|
||||
// Case: Some developer forgot to implement a security policy. We skip this source.
|
||||
// Better safe than sorry.
|
||||
//
|
||||
default:
|
||||
this.logger.LogWarning($"The data source '{source.Name}' (id={source.Id}) was filtered out due unknown security policy.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using AIStudio.Tools.Rust;
|
||||
|
||||
namespace AIStudio.Tools.Services;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace AIStudio.Tools.Services;
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using AIStudio.Tools.Rust;
|
||||
|
||||
namespace AIStudio.Tools.Services;
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using AIStudio.Tools.Rust;
|
||||
|
||||
namespace AIStudio.Tools.Services;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using AIStudio.Tools.Rust;
|
||||
|
||||
namespace AIStudio.Tools.Services;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using AIStudio.Tools.Rust;
|
||||
|
||||
namespace AIStudio.Tools.Services;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
|
||||
// ReSharper disable NotAccessedPositionalProperty.Local
|
||||
|
||||
namespace AIStudio.Tools.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Calling Rust functions.
|
||||
/// </summary>
|
||||
public sealed partial class RustService : IDisposable
|
||||
{
|
||||
private readonly HttpClient http;
|
||||
|
||||
private readonly JsonSerializerOptions jsonRustSerializerOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
};
|
||||
|
||||
private ILogger<RustService>? logger;
|
||||
private Encryption? encryptor;
|
||||
|
||||
private readonly string apiPort;
|
||||
private readonly string certificateFingerprint;
|
||||
|
||||
public RustService(string apiPort, string certificateFingerprint)
|
||||
{
|
||||
this.apiPort = apiPort;
|
||||
this.certificateFingerprint = certificateFingerprint;
|
||||
var certificateValidationHandler = new HttpClientHandler
|
||||
{
|
||||
ServerCertificateCustomValidationCallback = (_, certificate, _, _) =>
|
||||
{
|
||||
if(certificate is null)
|
||||
return false;
|
||||
|
||||
var currentCertificateFingerprint = certificate.GetCertHashString(HashAlgorithmName.SHA256);
|
||||
return currentCertificateFingerprint == certificateFingerprint;
|
||||
},
|
||||
};
|
||||
|
||||
this.http = new HttpClient(certificateValidationHandler)
|
||||
{
|
||||
BaseAddress = new Uri($"https://127.0.0.1:{apiPort}"),
|
||||
DefaultRequestVersion = Version.Parse("2.0"),
|
||||
DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrHigher,
|
||||
};
|
||||
|
||||
this.http.DefaultRequestHeaders.AddApiToken();
|
||||
}
|
||||
|
||||
public void SetLogger(ILogger<RustService> logService)
|
||||
{
|
||||
this.logger = logService;
|
||||
}
|
||||
|
||||
public void SetEncryptor(Encryption encryptionService)
|
||||
{
|
||||
this.encryptor = encryptionService;
|
||||
}
|
||||
|
||||
#region IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
this.http.Dispose();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -30,9 +30,15 @@ public sealed class UpdateService : BackgroundService, IMessageBusReceiver
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
//
|
||||
// Wait until the app is fully initialized.
|
||||
//
|
||||
while (!stoppingToken.IsCancellationRequested && !IS_INITIALIZED)
|
||||
await Task.Delay(TimeSpan.FromSeconds(3), stoppingToken);
|
||||
|
||||
//
|
||||
// Set the update interval based on the user's settings.
|
||||
//
|
||||
this.updateInterval = this.settingsManager.ConfigurationData.App.UpdateBehavior switch
|
||||
{
|
||||
UpdateBehavior.NO_CHECK => Timeout.InfiniteTimeSpan,
|
||||
@@ -45,16 +51,35 @@ public sealed class UpdateService : BackgroundService, IMessageBusReceiver
|
||||
_ => TimeSpan.FromHours(1)
|
||||
};
|
||||
|
||||
//
|
||||
// When the user doesn't want to check for updates, we can
|
||||
// return early.
|
||||
//
|
||||
if(this.settingsManager.ConfigurationData.App.UpdateBehavior is UpdateBehavior.NO_CHECK)
|
||||
return;
|
||||
|
||||
//
|
||||
// Check for updates at the beginning. The user aspects this when the app
|
||||
// is started.
|
||||
//
|
||||
await this.CheckForUpdate();
|
||||
|
||||
//
|
||||
// Start the update loop. This will check for updates based on the
|
||||
// user's settings.
|
||||
//
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(this.updateInterval, stoppingToken);
|
||||
await this.CheckForUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public override async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
this.messageBus.Unregister(this);
|
||||
await base.StopAsync(cancellationToken);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -79,16 +104,6 @@ public sealed class UpdateService : BackgroundService, IMessageBusReceiver
|
||||
|
||||
#endregion
|
||||
|
||||
#region Overrides of BackgroundService
|
||||
|
||||
public override async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
this.messageBus.Unregister(this);
|
||||
await base.StopAsync(cancellationToken);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private async Task CheckForUpdate(bool notifyUserWhenNoUpdate = false)
|
||||
{
|
||||
if(!IS_INITIALIZED)
|
||||
|
||||
Reference in new issue
Block a user