mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-17 04:57:59 +00:00
Improve ipc (#102)
This commit is contained in:
1 parent
47f5756e5a
commit
fc64c0b505
81 files changed
+2408
-799
No files matched your search
@@ -0,0 +1,13 @@
|
||||
using System.Security;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AIStudio.Tools;
|
||||
|
||||
[JsonConverter(typeof(EncryptedTextJsonConverter))]
|
||||
public readonly record struct EncryptedText(string EncryptedData)
|
||||
{
|
||||
public EncryptedText() : this(string.Empty)
|
||||
{
|
||||
throw new SecurityException("Please provide the encrypted data.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace AIStudio.Tools;
|
||||
|
||||
public static class EncryptedTextExtensions
|
||||
{
|
||||
public static async Task<EncryptedText> Encrypt(this string data, Encryption encryption) => await encryption.Encrypt(data);
|
||||
|
||||
public static async Task<string> Decrypt(this EncryptedText encryptedText, Encryption encryption) => await encryption.Decrypt(encryptedText);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AIStudio.Tools;
|
||||
|
||||
public sealed class EncryptedTextJsonConverter : JsonConverter<EncryptedText>
|
||||
{
|
||||
#region Overrides of JsonConverter<EncryptedText>
|
||||
|
||||
public override EncryptedText Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
if (reader.TokenType is JsonTokenType.String)
|
||||
{
|
||||
var value = reader.GetString()!;
|
||||
return new EncryptedText(value);
|
||||
}
|
||||
|
||||
throw new JsonException($"Unexpected token type when parsing EncryptedText. Expected {JsonTokenType.String}, but got {reader.TokenType}.");
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, EncryptedText value, JsonSerializerOptions options)
|
||||
{
|
||||
writer.WriteStringValue(value.EncryptedData);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace AIStudio.Tools;
|
||||
|
||||
public sealed class Encryption(ILogger<Encryption> logger, byte[] secretPassword, byte[] secretKeySalt)
|
||||
{
|
||||
/// <summary>
|
||||
/// The number of iterations to derive the key and IV from the password. For a password manager
|
||||
/// where the user has to enter their primary password, 100 iterations would be too few and
|
||||
/// insecure. Here, the use case is different: We generate a 512-byte long and cryptographically
|
||||
/// secure password at every start. This password already contains enough entropy. In our case,
|
||||
/// we need key and IV primarily because AES, with the algorithms we chose, requires a fixed key
|
||||
/// length, and our password is too long.
|
||||
/// </summary>
|
||||
private const int ITERATIONS = 100;
|
||||
|
||||
private readonly byte[] key = new byte[32];
|
||||
private readonly byte[] iv = new byte[16];
|
||||
|
||||
public async Task Initialize()
|
||||
{
|
||||
logger.LogInformation("Initializing encryption service...");
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
if (secretPassword.Length != 512)
|
||||
{
|
||||
logger.LogError($"The secret password must be 512 bytes long. It was {secretPassword.Length} bytes long.");
|
||||
throw new CryptographicException("The secret password must be 512 bytes long.");
|
||||
}
|
||||
|
||||
if(secretKeySalt.Length != 16)
|
||||
{
|
||||
logger.LogError($"The salt data must be 16 bytes long. It was {secretKeySalt.Length} bytes long.");
|
||||
throw new CryptographicException("The salt data must be 16 bytes long.");
|
||||
}
|
||||
|
||||
// Derive key and iv vector: the operations take several seconds. Thus, using a task:
|
||||
await Task.Run(() =>
|
||||
{
|
||||
using var keyVectorObj = new Rfc2898DeriveBytes(secretPassword, secretKeySalt, ITERATIONS, HashAlgorithmName.SHA512);
|
||||
var keyBytes = keyVectorObj.GetBytes(32); // the max valid key length = 256 bit = 32 bytes
|
||||
var ivBytes = keyVectorObj.GetBytes(16); // the only valid block size = 128 bit = 16 bytes
|
||||
|
||||
Array.Copy(keyBytes, this.key, this.key.Length);
|
||||
Array.Copy(ivBytes, this.iv, this.iv.Length);
|
||||
});
|
||||
|
||||
var initDuration = stopwatch.Elapsed;
|
||||
|
||||
stopwatch.Stop();
|
||||
logger.LogInformation($"Encryption service initialized in {initDuration.TotalMilliseconds} milliseconds.");
|
||||
}
|
||||
|
||||
public async Task<EncryptedText> Encrypt(string data)
|
||||
{
|
||||
// Create AES encryption:
|
||||
using var aes = Aes.Create();
|
||||
aes.Padding = PaddingMode.PKCS7;
|
||||
aes.Key = this.key;
|
||||
aes.IV = this.iv;
|
||||
aes.Mode = CipherMode.CBC;
|
||||
|
||||
using var encryption = aes.CreateEncryptor();
|
||||
|
||||
// Copy the given string data into a memory stream:
|
||||
await using var plainDataStream = new MemoryStream(Encoding.UTF8.GetBytes(data));
|
||||
|
||||
// A memory stream for the final, encrypted data:
|
||||
await using var encryptedAndEncodedData = new MemoryStream();
|
||||
|
||||
// A base64 stream for the encoding:
|
||||
await using var base64Stream = new CryptoStream(encryptedAndEncodedData, new ToBase64Transform(), CryptoStreamMode.Write);
|
||||
|
||||
// Write the salt into the base64 stream:
|
||||
await base64Stream.WriteAsync(secretKeySalt);
|
||||
|
||||
// Create the encryption stream:
|
||||
await using var cryptoStream = new CryptoStream(base64Stream, encryption, CryptoStreamMode.Write);
|
||||
|
||||
// Write the payload into the encryption stream:
|
||||
await plainDataStream.CopyToAsync(cryptoStream);
|
||||
|
||||
// Flush the final block. Please note that it is not enough to call the regular flush method.
|
||||
await cryptoStream.FlushFinalBlockAsync();
|
||||
|
||||
// Convert the base64 encoded data back into a string. Uses GetBuffer due to the advantage that
|
||||
// it does not create another copy of the data. ToArray would create another copy of the data.
|
||||
return new EncryptedText(Encoding.ASCII.GetString(encryptedAndEncodedData.GetBuffer()[..(int)encryptedAndEncodedData.Length]));
|
||||
}
|
||||
|
||||
public async Task<string> Decrypt(EncryptedText encryptedData)
|
||||
{
|
||||
// Build a memory stream to access the given base64 encoded data:
|
||||
await using var encodedEncryptedStream = new MemoryStream(Encoding.ASCII.GetBytes(encryptedData.EncryptedData));
|
||||
|
||||
// Wrap around the base64 decoder stream:
|
||||
await using var base64Stream = new CryptoStream(encodedEncryptedStream, new FromBase64Transform(), CryptoStreamMode.Read);
|
||||
|
||||
// A buffer for the salt's bytes:
|
||||
var readSaltBytes = new byte[16]; // 16 bytes = Guid
|
||||
|
||||
// Read the salt's bytes out of the stream:
|
||||
var readBytes = 0;
|
||||
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1));
|
||||
while(readBytes < readSaltBytes.Length && !cts.Token.IsCancellationRequested)
|
||||
{
|
||||
readBytes += await base64Stream.ReadAsync(readSaltBytes, readBytes, readSaltBytes.Length - readBytes, cts.Token);
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(60), cts.Token);
|
||||
}
|
||||
|
||||
// Check the salt bytes:
|
||||
if(!readSaltBytes.SequenceEqual(secretKeySalt))
|
||||
{
|
||||
logger.LogError("The salt bytes do not match. The data is corrupted or tampered.");
|
||||
throw new CryptographicException("The salt bytes do not match. The data is corrupted or tampered.");
|
||||
}
|
||||
|
||||
// Create AES decryption:
|
||||
using var aes = Aes.Create();
|
||||
aes.Padding = PaddingMode.PKCS7;
|
||||
aes.Key = this.key;
|
||||
aes.IV = this.iv;
|
||||
|
||||
using var decryption = aes.CreateDecryptor();
|
||||
|
||||
// A memory stream for the final, decrypted data:
|
||||
await using var decryptedData = new MemoryStream();
|
||||
|
||||
// The crypto stream:
|
||||
await using var cryptoStream = new CryptoStream(base64Stream, decryption, CryptoStreamMode.Read);
|
||||
|
||||
// Reads all remaining data through the decrypt stream. Note that this operation
|
||||
// starts at the current position, i.e., after the salt bytes:
|
||||
await cryptoStream.CopyToAsync(decryptedData);
|
||||
|
||||
// Convert the decrypted data back into a string. Uses GetBuffer due to the advantage that
|
||||
// it does not create another copy of the data. ToArray would create another copy of the data.
|
||||
return Encoding.UTF8.GetString(decryptedData.GetBuffer()[..(int)decryptedData.Length]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using System.Net.Http.Headers;
|
||||
|
||||
namespace AIStudio.Tools;
|
||||
|
||||
public static class HttpRequestHeadersExtensions
|
||||
{
|
||||
private static readonly string API_TOKEN;
|
||||
|
||||
static HttpRequestHeadersExtensions()
|
||||
{
|
||||
API_TOKEN = Program.API_TOKEN;
|
||||
}
|
||||
|
||||
public static void AddApiToken(this HttpRequestHeaders headers)
|
||||
{
|
||||
headers.Add("token", API_TOKEN);
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
namespace AIStudio.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// Calling Rust functions.
|
||||
/// </summary>
|
||||
public sealed class Rust
|
||||
{
|
||||
/// <summary>
|
||||
/// Tries to copy the given text to the clipboard.
|
||||
/// </summary>
|
||||
/// <param name="jsRuntime">The JS runtime to access the Rust code.</param>
|
||||
/// <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(IJSRuntime jsRuntime, ISnackbar snackbar, string text)
|
||||
{
|
||||
var response = await jsRuntime.InvokeAsync<SetClipboardResponse>("window.__TAURI__.invoke", "set_clipboard", new SetClipboardText(text));
|
||||
var msg = response.Success switch
|
||||
{
|
||||
true => "Successfully copied text to clipboard!",
|
||||
false => $"Failed to copy text to clipboard: {response.Issue}",
|
||||
};
|
||||
|
||||
var severity = response.Success switch
|
||||
{
|
||||
true => Severity.Success,
|
||||
false => Severity.Error,
|
||||
};
|
||||
|
||||
snackbar.Add(msg, severity, config =>
|
||||
{
|
||||
config.Icon = Icons.Material.Filled.ContentCopy;
|
||||
config.IconSize = Size.Large;
|
||||
config.IconColor = severity switch
|
||||
{
|
||||
Severity.Success => Color.Success,
|
||||
Severity.Error => Color.Error,
|
||||
|
||||
_ => Color.Default,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<UpdateResponse> CheckForUpdate(IJSRuntime jsRuntime)
|
||||
{
|
||||
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(16));
|
||||
return await jsRuntime.InvokeAsync<UpdateResponse>("window.__TAURI__.invoke", cts.Token, "check_for_update");
|
||||
}
|
||||
|
||||
public async Task InstallUpdate(IJSRuntime jsRuntime)
|
||||
{
|
||||
var cts = new CancellationTokenSource();
|
||||
await jsRuntime.InvokeVoidAsync("window.__TAURI__.invoke", cts.Token, "install_update");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace AIStudio.Tools.Rust;
|
||||
|
||||
/// <summary>
|
||||
/// Data structure for deleting a secret response.
|
||||
/// </summary>
|
||||
/// <param name="Success">True, when the secret was successfully deleted or not found.</param>
|
||||
/// <param name="Issue">The issue, when the secret could not be deleted.</param>
|
||||
/// <param name="WasEntryFound">True, when the entry was found and deleted.</param>
|
||||
public readonly record struct DeleteSecretResponse(bool Success, string Issue, bool WasEntryFound);
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace AIStudio.Tools.Rust;
|
||||
|
||||
/// <summary>
|
||||
/// Data structure for any requested secret.
|
||||
/// </summary>
|
||||
/// <param name="Success">True, when the secret was successfully retrieved.</param>
|
||||
/// <param name="Secret">The secret, e.g., API key.</param>
|
||||
/// <param name="Issue">The issue, when the secret could not be retrieved.</param>
|
||||
public readonly record struct RequestedSecret(bool Success, EncryptedText Secret, string Issue);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace AIStudio.Tools.Rust;
|
||||
|
||||
public readonly record struct SelectSecretRequest(string Destination, string UserName);
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
namespace AIStudio.Tools;
|
||||
namespace AIStudio.Tools.Rust;
|
||||
|
||||
/// <summary>
|
||||
/// The response from the set clipboard operation.
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace AIStudio.Tools.Rust;
|
||||
|
||||
public readonly record struct StoreSecretRequest(string Destination, string UserName, EncryptedText Secret);
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace AIStudio.Tools.Rust;
|
||||
|
||||
/// <summary>
|
||||
/// Data structure for storing a secret response.
|
||||
/// </summary>
|
||||
/// <param name="Success">True, when the secret was successfully stored.</param>
|
||||
/// <param name="Issue">The issue, when the secret could not be stored.</param>
|
||||
public readonly record struct StoreSecretResponse(bool Success, string Issue);
|
||||
+4
-6
@@ -1,6 +1,4 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AIStudio.Tools;
|
||||
namespace AIStudio.Tools.Rust;
|
||||
|
||||
/// <summary>
|
||||
/// The response of the update check.
|
||||
@@ -9,8 +7,8 @@ namespace AIStudio.Tools;
|
||||
/// <param name="NewVersion">The new version, when available.</param>
|
||||
/// <param name="Changelog">The changelog of the new version, when available.</param>
|
||||
public readonly record struct UpdateResponse(
|
||||
[property:JsonPropertyName("update_is_available")] bool UpdateIsAvailable,
|
||||
[property:JsonPropertyName("error")] bool Error,
|
||||
[property:JsonPropertyName("new_version")] string NewVersion,
|
||||
bool UpdateIsAvailable,
|
||||
bool Error,
|
||||
string NewVersion,
|
||||
string Changelog
|
||||
);
|
||||
@@ -0,0 +1,331 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
|
||||
using AIStudio.Provider;
|
||||
using AIStudio.Tools.Rust;
|
||||
|
||||
// ReSharper disable NotAccessedPositionalProperty.Local
|
||||
|
||||
namespace AIStudio.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// Calling Rust functions.
|
||||
/// </summary>
|
||||
public sealed 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;
|
||||
}
|
||||
|
||||
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>();
|
||||
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(16));
|
||||
return await this.http.GetFromJsonAsync<UpdateResponse>("/updates/check", cts.Token);
|
||||
}
|
||||
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 provider.
|
||||
/// </summary>
|
||||
/// <param name="provider">The provider to get the API key for.</param>
|
||||
/// <returns>The requested secret.</returns>
|
||||
public async Task<RequestedSecret> GetAPIKey(IProvider provider)
|
||||
{
|
||||
var secretRequest = new SelectSecretRequest($"provider::{provider.Id}::{provider.InstanceName}::api_key", Environment.UserName);
|
||||
var result = await this.http.PostAsJsonAsync("/secrets/get", secretRequest, this.jsonRustSerializerOptions);
|
||||
if (!result.IsSuccessStatusCode)
|
||||
{
|
||||
this.logger!.LogError($"Failed to get the API key for provider '{provider.Id}' 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>();
|
||||
if (!secret.Success)
|
||||
this.logger!.LogError($"Failed to get the API key for provider '{provider.Id}': '{secret.Issue}'");
|
||||
|
||||
return secret;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Try to store the API key for the given provider.
|
||||
/// </summary>
|
||||
/// <param name="provider">The provider 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(IProvider provider, string key)
|
||||
{
|
||||
var encryptedKey = await this.encryptor!.Encrypt(key);
|
||||
var request = new StoreSecretRequest($"provider::{provider.Id}::{provider.InstanceName}::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 provider '{provider.Id}' 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>();
|
||||
if (!state.Success)
|
||||
this.logger!.LogError($"Failed to store the API key for provider '{provider.Id}': '{state.Issue}'");
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to delete the API key for the given provider.
|
||||
/// </summary>
|
||||
/// <param name="provider">The provider to delete the API key for.</param>
|
||||
/// <returns>The delete secret response.</returns>
|
||||
public async Task<DeleteSecretResponse> DeleteAPIKey(IProvider provider)
|
||||
{
|
||||
var request = new SelectSecretRequest($"provider::{provider.Id}::{provider.InstanceName}::api_key", Environment.UserName);
|
||||
var result = await this.http.PostAsJsonAsync("/secrets/delete", request, this.jsonRustSerializerOptions);
|
||||
if (!result.IsSuccessStatusCode)
|
||||
{
|
||||
this.logger!.LogError($"Failed to delete the API key for provider '{provider.Id}' 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>();
|
||||
if (!state.Success)
|
||||
this.logger!.LogError($"Failed to delete the API key for provider '{provider.Id}': '{state.Issue}'");
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
#region IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
this.http.Dispose();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -6,17 +6,15 @@ namespace AIStudio.Tools.Services;
|
||||
/// Wire up the clipboard service to copy Markdown to the clipboard.
|
||||
/// We use our own Rust-based clipboard service for this.
|
||||
/// </summary>
|
||||
public sealed class MarkdownClipboardService(Rust rust, IJSRuntime jsRuntime, ISnackbar snackbar) : IMudMarkdownClipboardService
|
||||
public sealed class MarkdownClipboardService(RustService rust, ISnackbar snackbar) : IMudMarkdownClipboardService
|
||||
{
|
||||
private IJSRuntime JsRuntime { get; } = jsRuntime;
|
||||
|
||||
private ISnackbar Snackbar { get; } = snackbar;
|
||||
|
||||
private Rust Rust { get; } = rust;
|
||||
private RustService Rust { get; } = rust;
|
||||
|
||||
/// <summary>
|
||||
/// Gets called when the user wants to copy the markdown to the clipboard.
|
||||
/// Gets called when the user wants to copy the Markdown to the clipboard.
|
||||
/// </summary>
|
||||
/// <param name="text">The Markdown text to copy.</param>
|
||||
public async ValueTask CopyToClipboardAsync(string text) => await this.Rust.CopyText2Clipboard(this.JsRuntime, this.Snackbar, text);
|
||||
public async ValueTask CopyToClipboardAsync(string text) => await this.Rust.CopyText2Clipboard(this.Snackbar, text);
|
||||
}
|
||||
@@ -3,11 +3,13 @@ using AIStudio.Settings.DataModel;
|
||||
|
||||
namespace AIStudio.Tools.Services;
|
||||
|
||||
public class TemporaryChatService(SettingsManager settingsManager) : BackgroundService
|
||||
public class TemporaryChatService(ILogger<TemporaryChatService> logger, SettingsManager settingsManager) : BackgroundService
|
||||
{
|
||||
private static readonly TimeSpan CHECK_INTERVAL = TimeSpan.FromDays(1);
|
||||
private static bool IS_INITIALIZED;
|
||||
|
||||
private readonly ILogger<TemporaryChatService> logger = logger;
|
||||
|
||||
#region Overrides of BackgroundService
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
@@ -15,10 +17,12 @@ public class TemporaryChatService(SettingsManager settingsManager) : BackgroundS
|
||||
while (!stoppingToken.IsCancellationRequested && !IS_INITIALIZED)
|
||||
await Task.Delay(TimeSpan.FromSeconds(3), stoppingToken);
|
||||
|
||||
this.logger.LogInformation("The temporary chat maintenance service was initialized.");
|
||||
|
||||
await settingsManager.LoadSettings();
|
||||
if(settingsManager.ConfigurationData.Workspace.StorageTemporaryMaintenancePolicy is WorkspaceStorageTemporaryMaintenancePolicy.NO_AUTOMATIC_MAINTENANCE)
|
||||
{
|
||||
Console.WriteLine("Automatic maintenance of temporary chat storage is disabled. Exiting maintenance service.");
|
||||
this.logger.LogWarning("Automatic maintenance of temporary chat storage is disabled. Exiting maintenance service.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -34,10 +38,14 @@ public class TemporaryChatService(SettingsManager settingsManager) : BackgroundS
|
||||
|
||||
private Task StartMaintenance()
|
||||
{
|
||||
this.logger.LogInformation("Starting maintenance of temporary chat storage.");
|
||||
var temporaryDirectories = Path.Join(SettingsManager.DataDirectory, "tempChats");
|
||||
if(!Directory.Exists(temporaryDirectories))
|
||||
{
|
||||
this.logger.LogWarning("Temporary chat storage directory does not exist. End maintenance.");
|
||||
return Task.CompletedTask;
|
||||
|
||||
}
|
||||
|
||||
foreach (var tempChatDirPath in Directory.EnumerateDirectories(temporaryDirectories))
|
||||
{
|
||||
var chatPath = Path.Join(tempChatDirPath, "thread.json");
|
||||
@@ -59,9 +67,13 @@ public class TemporaryChatService(SettingsManager settingsManager) : BackgroundS
|
||||
};
|
||||
|
||||
if(deleteChat)
|
||||
{
|
||||
this.logger.LogInformation($"Deleting temporary chat storage directory '{tempChatDirPath}' due to maintenance policy.");
|
||||
Directory.Delete(tempChatDirPath, true);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.LogInformation("Finished maintenance of temporary chat storage.");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,20 +7,16 @@ namespace AIStudio.Tools.Services;
|
||||
|
||||
public sealed class UpdateService : BackgroundService, IMessageBusReceiver
|
||||
{
|
||||
// We cannot inject IJSRuntime into our service. This is because
|
||||
// the service is not a Blazor component. We need to pass the IJSRuntime from
|
||||
// the MainLayout component to the service.
|
||||
private static IJSRuntime? JS_RUNTIME;
|
||||
private static bool IS_INITIALIZED;
|
||||
private static ISnackbar? SNACKBAR;
|
||||
|
||||
private readonly SettingsManager settingsManager;
|
||||
private readonly MessageBus messageBus;
|
||||
private readonly Rust rust;
|
||||
private readonly RustService rust;
|
||||
|
||||
private TimeSpan updateInterval;
|
||||
|
||||
public UpdateService(MessageBus messageBus, SettingsManager settingsManager, Rust rust)
|
||||
public UpdateService(MessageBus messageBus, SettingsManager settingsManager, RustService rust)
|
||||
{
|
||||
this.settingsManager = settingsManager;
|
||||
this.messageBus = messageBus;
|
||||
@@ -96,7 +92,7 @@ public sealed class UpdateService : BackgroundService, IMessageBusReceiver
|
||||
if(!IS_INITIALIZED)
|
||||
return;
|
||||
|
||||
var response = await this.rust.CheckForUpdate(JS_RUNTIME!);
|
||||
var response = await this.rust.CheckForUpdate();
|
||||
if (response.UpdateIsAvailable)
|
||||
{
|
||||
await this.messageBus.SendMessage(null, Event.UPDATE_AVAILABLE, response);
|
||||
@@ -115,10 +111,9 @@ public sealed class UpdateService : BackgroundService, IMessageBusReceiver
|
||||
}
|
||||
}
|
||||
|
||||
public static void SetBlazorDependencies(IJSRuntime jsRuntime, ISnackbar snackbar)
|
||||
public static void SetBlazorDependencies(ISnackbar snackbar)
|
||||
{
|
||||
SNACKBAR = snackbar;
|
||||
JS_RUNTIME = jsRuntime;
|
||||
IS_INITIALIZED = true;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
namespace AIStudio.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// Model for setting clipboard text.
|
||||
/// </summary>
|
||||
/// <param name="Text">The text to set to the clipboard.</param>
|
||||
public record SetClipboardText(string Text);
|
||||
@@ -0,0 +1,23 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Logging.Console;
|
||||
|
||||
namespace AIStudio.Tools;
|
||||
|
||||
public sealed class TerminalLogger() : ConsoleFormatter(FORMATTER_NAME)
|
||||
{
|
||||
public const string FORMATTER_NAME = "AI Studio Terminal Logger";
|
||||
|
||||
public override void Write<TState>(in LogEntry<TState> logEntry, IExternalScopeProvider? scopeProvider, TextWriter textWriter)
|
||||
{
|
||||
var message = logEntry.Formatter(logEntry.State, logEntry.Exception);
|
||||
var timestamp = DateTimeOffset.UtcNow.ToString("yyyy-MM-dd HH:mm:ss.fff");
|
||||
var logLevel = logEntry.LogLevel.ToString();
|
||||
var category = logEntry.Category;
|
||||
|
||||
textWriter.Write($"=> {timestamp} [{logLevel}] {category}: {message}");
|
||||
if (logEntry.Exception is not null)
|
||||
textWriter.Write($" Exception was = {logEntry.Exception}");
|
||||
|
||||
textWriter.WriteLine();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user