diff --git a/global.json b/global.json index a40d106..d6c191f 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "8.0.100-rc.2", + "version": "8.0.100", "rollForward": "latestFeature", "allowPrerelease": true } diff --git a/src/ChatPrisma/App.xaml b/src/ChatPrisma/App.xaml index e23678a..2e42563 100644 --- a/src/ChatPrisma/App.xaml +++ b/src/ChatPrisma/App.xaml @@ -16,6 +16,7 @@ + diff --git a/src/ChatPrisma/App.xaml.cs b/src/ChatPrisma/App.xaml.cs index 4bf04b7..1e86e28 100644 --- a/src/ChatPrisma/App.xaml.cs +++ b/src/ChatPrisma/App.xaml.cs @@ -10,6 +10,7 @@ using ChatPrisma.Services.KeyboardHooks; using ChatPrisma.Services.TextExtractor; using ChatPrisma.Services.TextWriter; +using ChatPrisma.Services.UpdateOptions; using ChatPrisma.Services.ViewModels; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -98,7 +99,7 @@ private IHostBuilder CreateHostBuilder(string[] args) => Microsoft.Extensions.Ho o.AppShutdownHeader = "Beenden"; }); services.AddOptions() - .BindConfiguration("OpenAI") + .BindConfiguration(OpenAIOptions.Section) .ValidateDataAnnotations() .ValidateOnStart(); services.AddOptions() @@ -123,7 +124,7 @@ private IHostBuilder CreateHostBuilder(string[] args) => Microsoft.Extensions.Ho o.CheckForUpdatesInBackground = true; o.MinutesBetweenUpdateChecks = 30; }) - .BindConfiguration("Updater") + .BindConfiguration(UpdaterOptions.Section) .ValidateDataAnnotations() .ValidateOnStart(); services.AddOptions() @@ -134,7 +135,7 @@ private IHostBuilder CreateHostBuilder(string[] args) => Microsoft.Extensions.Ho o.HotkeyDelayInMilliseconds = 500; o.ClipboardDelayInMilliseconds = 500; }) - .BindConfiguration("Hotkey") + .BindConfiguration(HotkeyOptions.Section) .ValidateDataAnnotations() .ValidateOnStart(); services.AddOptions() @@ -142,7 +143,7 @@ private IHostBuilder CreateHostBuilder(string[] args) => Microsoft.Extensions.Ho { o.TextSize = 12; }) - .BindConfiguration("TextEnhancement") + .BindConfiguration(TextEnhancementOptions.Section) .ValidateDataAnnotations() .ValidateOnStart(); @@ -166,6 +167,7 @@ private IHostBuilder CreateHostBuilder(string[] args) => Microsoft.Extensions.Ho services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); // Hosted Services services.AddHostedService(); diff --git a/src/ChatPrisma/ChatPrisma.csproj b/src/ChatPrisma/ChatPrisma.csproj index af8382e..2e49fc3 100644 --- a/src/ChatPrisma/ChatPrisma.csproj +++ b/src/ChatPrisma/ChatPrisma.csproj @@ -7,6 +7,8 @@ true Themes\Images\AppIcon.ico Chat-Prisma-9c00c175-581f-4d2e-a2f2-5e67274af4d4 + app.manifest + $(NoWarn);WFAC010 @@ -28,6 +30,19 @@ + + + $([System.IO.File]::ReadAllText('app.manifest')) + $([System.Text.RegularExpressions.Regex]::Replace($(AppManifestContents), '\d+\.\d+\.\d+\.\d+', $(AssemblyVersion))) + + + + diff --git a/src/ChatPrisma/Common/KeyboardHelper.cs b/src/ChatPrisma/Common/KeyboardHelper.cs new file mode 100644 index 0000000..ddac09f --- /dev/null +++ b/src/ChatPrisma/Common/KeyboardHelper.cs @@ -0,0 +1,23 @@ +using System.Windows.Input; + +namespace ChatPrisma.Common; + +public static class KeyboardHelper +{ + private static readonly Key[] s_allKeys = Enum.GetValues(); + + public static bool AnyKeyPressed() + { + foreach (var key in s_allKeys) + { + // Skip the None key + if (key == Key.None) + continue; + + if (Keyboard.IsKeyDown(key)) + return true; + } + + return false; + } +} diff --git a/src/ChatPrisma/Common/TaskHelper.cs b/src/ChatPrisma/Common/TaskHelper.cs new file mode 100644 index 0000000..9414962 --- /dev/null +++ b/src/ChatPrisma/Common/TaskHelper.cs @@ -0,0 +1,33 @@ +namespace ChatPrisma.Common; + +public static class TaskHelper +{ + public static async Task WaitUntil(Func condition, int timeoutInMilliseconds) + { + var result = await WaitUntil(() => (condition(), string.Empty), timeoutInMilliseconds); + return result.Success; + } + public static async Task<(bool Success, T? Data)> WaitUntil(Func<(bool, T)> condition, int timeoutInMilliseconds) + { + using var cancellationTokenSource = new CancellationTokenSource(timeoutInMilliseconds); + var cancellationToken = cancellationTokenSource.Token; + + try + { + // Wait until we reach the timeout or condition is true + while (true) + { + var (success, data) = condition(); + if (success) + return (true, data); + + // This delay is cancellable and will throw an exception if the token is cancelled. + await Task.Delay(10, cancellationToken); + } + } + catch (TaskCanceledException) + { + return (Success: false, Data: default); + } + } +} diff --git a/src/ChatPrisma/Options/ApplicationOptions.cs b/src/ChatPrisma/Options/ApplicationOptions.cs index df38791..7242a4e 100644 --- a/src/ChatPrisma/Options/ApplicationOptions.cs +++ b/src/ChatPrisma/Options/ApplicationOptions.cs @@ -2,7 +2,7 @@ namespace ChatPrisma.Options; -public class ApplicationOptions +public record ApplicationOptions { [Required] public string ApplicationName { get; set; } = default!; diff --git a/src/ChatPrisma/Options/HotkeyOptions.cs b/src/ChatPrisma/Options/HotkeyOptions.cs index f0c94e0..217ef29 100644 --- a/src/ChatPrisma/Options/HotkeyOptions.cs +++ b/src/ChatPrisma/Options/HotkeyOptions.cs @@ -1,14 +1,15 @@ using System.ComponentModel.DataAnnotations; -namespace ChatPrisma.Options +namespace ChatPrisma.Options; + +public record HotkeyOptions { - public class HotkeyOptions - { - [Required] - public string Key { get; set; } = default!; - [Required] - public string KeyModifiers { get; set; } = default!; - public int HotkeyDelayInMilliseconds { get; set; } - public int ClipboardDelayInMilliseconds { get; set; } - } + public const string Section = "Hotkey"; + + [Required] + public string Key { get; set; } = default!; + [Required] + public string KeyModifiers { get; set; } = default!; + public int HotkeyDelayInMilliseconds { get; set; } + public int ClipboardDelayInMilliseconds { get; set; } } diff --git a/src/ChatPrisma/Options/OpenAIOptions.cs b/src/ChatPrisma/Options/OpenAIOptions.cs index aa89e8f..b291fae 100644 --- a/src/ChatPrisma/Options/OpenAIOptions.cs +++ b/src/ChatPrisma/Options/OpenAIOptions.cs @@ -2,11 +2,11 @@ namespace ChatPrisma.Options; -public class OpenAIOptions +public record OpenAIOptions { - [Required] - public string Model { get; set; } = default!; + public const string Section = "OpenAI"; - [Required] - public string ApiKey { get; set; } = default!; + public string? Model { get; set; } + + public string? ApiKey { get; set; } } diff --git a/src/ChatPrisma/Options/TextEnhancementOptions.cs b/src/ChatPrisma/Options/TextEnhancementOptions.cs index 03a4b6f..c95f34d 100644 --- a/src/ChatPrisma/Options/TextEnhancementOptions.cs +++ b/src/ChatPrisma/Options/TextEnhancementOptions.cs @@ -1,7 +1,9 @@ namespace ChatPrisma.Options; -public class TextEnhancementOptions +public record TextEnhancementOptions { + public const string Section = "TextEnhancement"; + public int TextSize { get; set; } public string? CustomInstructions { get; set; } } diff --git a/src/ChatPrisma/Options/UpdaterOptions.cs b/src/ChatPrisma/Options/UpdaterOptions.cs index 06666f9..048f83d 100644 --- a/src/ChatPrisma/Options/UpdaterOptions.cs +++ b/src/ChatPrisma/Options/UpdaterOptions.cs @@ -2,8 +2,10 @@ namespace ChatPrisma.Options; -public class UpdaterOptions +public record UpdaterOptions { + public const string Section = "Updater"; + [Required] public string GitHubUsername { get; set; } = default!; [Required] diff --git a/src/ChatPrisma/Services/ChatBot/OpenAIChatBotService.cs b/src/ChatPrisma/Services/ChatBot/OpenAIChatBotService.cs index d3168ac..a88a5e1 100644 --- a/src/ChatPrisma/Services/ChatBot/OpenAIChatBotService.cs +++ b/src/ChatPrisma/Services/ChatBot/OpenAIChatBotService.cs @@ -8,10 +8,21 @@ namespace ChatPrisma.Services.ChatBot; public class OpenAIChatBotService(IOptionsMonitor openAiConfig, ILogger logger) : IChatBotService { - private readonly OpenAIClient _client = new(openAiConfig.CurrentValue.ApiKey ?? throw new PrismaException("OpenAI API Key is missing")); - public async IAsyncEnumerable GetResponse(List messages, [EnumeratorCancellation] CancellationToken token = default) { + var client = this.GetClient(); + if (client is null) + { + yield return "Bitte tragen Sie einen OpenAI API-Key in den Einstellungen ein."; + yield break; + } + + if (string.IsNullOrWhiteSpace(openAiConfig.CurrentValue.Model)) + { + yield return "Bitte tragen Sie ein OpenAI Model in den Einstellungen ein."; + yield break; + } + var chatCompletionsOptions = new ChatCompletionsOptions(); foreach (var message in messages) { @@ -20,7 +31,7 @@ public async IAsyncEnumerable GetResponse(List messag logger.LogInformation("Calling ChatGPT model {Model}", openAiConfig.CurrentValue.Model); - var response = await this._client.GetChatCompletionsStreamingAsync(openAiConfig.CurrentValue.Model, chatCompletionsOptions, token); + var response = await client.GetChatCompletionsStreamingAsync(openAiConfig.CurrentValue.Model, chatCompletionsOptions, token); using var completions = response.Value; @@ -46,4 +57,17 @@ private ChatMessage ConvertChatMessage(PrismaChatMessage message) return new ChatMessage(openAiChatRole, message.Content); } + + private (OpenAIClient Client, string ApiKey)? _lastClient; + private OpenAIClient? GetClient() + { + if (this._lastClient is null || this._lastClient.Value.ApiKey != openAiConfig.CurrentValue.ApiKey) + { + _lastClient = string.IsNullOrWhiteSpace(openAiConfig.CurrentValue.ApiKey) is false + ? (new OpenAIClient(openAiConfig.CurrentValue.ApiKey), openAiConfig.CurrentValue.ApiKey) + : null; + } + + return _lastClient?.Client; + } } diff --git a/src/ChatPrisma/Services/Dialogs/DialogService.cs b/src/ChatPrisma/Services/Dialogs/DialogService.cs index ff6b022..c6c92f8 100644 --- a/src/ChatPrisma/Services/Dialogs/DialogService.cs +++ b/src/ChatPrisma/Services/Dialogs/DialogService.cs @@ -54,7 +54,14 @@ public class DialogService(IServiceProvider serviceProvider, IOptionsMonitor hotkeyOptions private async Task WaitUntilNoKeyPressed() { - var task = Task.Delay(TimeSpan.FromMilliseconds(hotkeyOptions.CurrentValue.HotkeyDelayInMilliseconds)); var watch = Stopwatch.StartNew(); - // Either wait until the task is completed or the user releases all keys - while (task.IsCompleted is false) + var success = await TaskHelper.WaitUntil(() => KeyboardHelper.AnyKeyPressed() is false, hotkeyOptions.CurrentValue.HotkeyDelayInMilliseconds); + if (success) { - if (AnyKeyPressed() is false) - { - logger.LogInformation("Early exit from WaitUntilNoKeyPressed because no key is pressed anymore (after {Time} ms)", watch.Elapsed.TotalMilliseconds); - return; - } - - await Task.Delay(10); + logger.LogInformation("Early exit from WaitUntilNoKeyPressed because no key is pressed anymore (after {Time} ms)", watch.Elapsed.TotalMilliseconds); } - - logger.LogInformation("Sadly the user did not release all keys in time (after {Time} ms)", watch.Elapsed.TotalMilliseconds); - } - - private static readonly Key[] s_allKeys = Enum.GetValues(); - private static bool AnyKeyPressed() - { - foreach (var key in s_allKeys) + else { - // Skip the None key - if (key == Key.None) - continue; - - if (Keyboard.IsKeyDown(key)) - return true; + logger.LogInformation("Sadly the user did not release all keys in time (after {Time} ms)", watch.Elapsed.TotalMilliseconds); } - - return false; } private async Task WaitUntilClipboardTextIsAvailable() { - var task = Task.Delay(TimeSpan.FromMilliseconds(hotkeyOptions.CurrentValue.ClipboardDelayInMilliseconds)); var watch = Stopwatch.StartNew(); - // Either wait until the task is completed or we got some text in the clipboard - while (task.IsCompleted is false) + var (success, text) = await TaskHelper.WaitUntil(ClipboardHasText, hotkeyOptions.CurrentValue.ClipboardDelayInMilliseconds); + if (success) { - var dataObject = Clipboard.GetDataObject(); - if (dataObject?.GetData(DataFormats.Text) is string text) + logger.LogInformation("Early exit from WaitUntilClipboardIsFilled because we got some text from the clipboard (after {Time} ms)", watch.Elapsed.TotalMilliseconds); + } + else + { + logger.LogInformation("Sadly no text available in clipboard (after {Time} ms)", watch.Elapsed.TotalMilliseconds); + } + + return text; + + (bool, string?) ClipboardHasText() + { + try { - logger.LogInformation("Early exit from WaitUntilClipboardIsFilled because we got some text from the clipboard (after {Time} ms)", watch.Elapsed.TotalMilliseconds); - return text; + var dataObject = Clipboard.GetDataObject(); + return dataObject?.GetData(DataFormats.Text) is string s + ? (true, s) + : (false, null); + } +#pragma warning disable CA1031 + catch (Exception e) +#pragma warning restore CA1031 + { + logger.LogError(e, "An error occurred when accessing the clipboard contents."); + return (false, null); } - - await Task.Delay(10); } - - logger.LogInformation("Sadly no text available in clipboard (after {Time} ms)", watch.Elapsed.TotalMilliseconds); - return null; } } diff --git a/src/ChatPrisma/Services/TextWriter/SendKeysClipboardTextWriter.cs b/src/ChatPrisma/Services/TextWriter/SendKeysClipboardTextWriter.cs index d1e11bc..d247363 100644 --- a/src/ChatPrisma/Services/TextWriter/SendKeysClipboardTextWriter.cs +++ b/src/ChatPrisma/Services/TextWriter/SendKeysClipboardTextWriter.cs @@ -1,8 +1,13 @@ +using System.Diagnostics; using System.Windows.Forms; +using ChatPrisma.Common; +using ChatPrisma.Options; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; namespace ChatPrisma.Services.TextWriter; -public class SendKeysClipboardTextWriter : IClipboardTextWriter +public class SendKeysClipboardTextWriter(IOptionsMonitor hotkeyOptions, ILogger logger) : IClipboardTextWriter { public async Task CopyTextAsync(string text, bool autoPaste) { @@ -10,9 +15,25 @@ public async Task CopyTextAsync(string text, bool autoPaste) if (autoPaste) { + await this.WaitUntilNoKeyPressed(); SendKeys.SendWait("^v"); } await Task.CompletedTask; } + + private async Task WaitUntilNoKeyPressed() + { + var watch = Stopwatch.StartNew(); + + var success = await TaskHelper.WaitUntil(() => KeyboardHelper.AnyKeyPressed() is false, hotkeyOptions.CurrentValue.HotkeyDelayInMilliseconds); + if (success) + { + logger.LogInformation("Early exit from WaitUntilNoKeyPressed because no key is pressed anymore (after {Time} ms)", watch.Elapsed.TotalMilliseconds); + } + else + { + logger.LogInformation("Sadly the user did not release all keys in time (after {Time} ms)", watch.Elapsed.TotalMilliseconds); + } + } } diff --git a/src/ChatPrisma/Services/UpdateOptions/IUpdateOptionsService.cs b/src/ChatPrisma/Services/UpdateOptions/IUpdateOptionsService.cs new file mode 100644 index 0000000..cbcfbb9 --- /dev/null +++ b/src/ChatPrisma/Services/UpdateOptions/IUpdateOptionsService.cs @@ -0,0 +1,8 @@ +using ChatPrisma.Options; + +namespace ChatPrisma.Services.UpdateOptions; + +public interface IUpdateOptionsService +{ + Task Update(OpenAIOptions options); +} diff --git a/src/ChatPrisma/Services/UpdateOptions/UpdateOptionsService.cs b/src/ChatPrisma/Services/UpdateOptions/UpdateOptionsService.cs new file mode 100644 index 0000000..217387d --- /dev/null +++ b/src/ChatPrisma/Services/UpdateOptions/UpdateOptionsService.cs @@ -0,0 +1,44 @@ +using System.IO; +using System.Text.Json; +using System.Text.Json.Nodes; +using ChatPrisma.Options; +using Microsoft.Extensions.Hosting; + +namespace ChatPrisma.Services.UpdateOptions; + +public class UpdateOptionsService(IHostEnvironment hostEnvironment) : IUpdateOptionsService +{ + public async Task Update(OpenAIOptions options) + { + await this.UpdateSettings(settings => + { + var newJson = JsonSerializer.Serialize(options); + settings[OpenAIOptions.Section] = JsonNode.Parse(newJson); + }); + } + + private async Task UpdateSettings(Action updateAction) + { + var path = Path.Combine(AppContext.BaseDirectory, $"appsettings.{hostEnvironment.EnvironmentName}.json"); + + string settingsContent; + try + { + settingsContent = await File.ReadAllTextAsync(path); + } + catch (FileNotFoundException) + { + settingsContent = "{}"; + } + + var json = JsonNode.Parse(settingsContent) ?? new JsonObject(); + + updateAction(json); + + await using var writer = File.CreateText(path); + await writer.WriteAsync(json.ToJsonString(new JsonSerializerOptions + { + WriteIndented = true + })); + } +} diff --git a/src/ChatPrisma/Themes/ButtonStyles.xaml b/src/ChatPrisma/Themes/ButtonStyles.xaml index 7b53cd6..1b36f46 100644 --- a/src/ChatPrisma/Themes/ButtonStyles.xaml +++ b/src/ChatPrisma/Themes/ButtonStyles.xaml @@ -3,7 +3,7 @@ + \ No newline at end of file diff --git a/src/ChatPrisma/Themes/Images/AppIcon.ico b/src/ChatPrisma/Themes/Images/AppIcon.ico index 4040c6b..18930a1 100644 Binary files a/src/ChatPrisma/Themes/Images/AppIcon.ico and b/src/ChatPrisma/Themes/Images/AppIcon.ico differ diff --git a/src/ChatPrisma/Themes/Images/AppIcon_Source.png b/src/ChatPrisma/Themes/Images/AppIcon_Source.png new file mode 100644 index 0000000..d9b44a9 Binary files /dev/null and b/src/ChatPrisma/Themes/Images/AppIcon_Source.png differ diff --git a/src/ChatPrisma/Views/Settings/SettingsView.xaml b/src/ChatPrisma/Views/Settings/SettingsView.xaml index 2571732..b354e9e 100644 --- a/src/ChatPrisma/Views/Settings/SettingsView.xaml +++ b/src/ChatPrisma/Views/Settings/SettingsView.xaml @@ -1,8 +1,6 @@ - - - - - - + Width="400"> + + - - - - - - - - - - - + + @@ -72,7 +92,7 @@ diff --git a/src/ChatPrisma/Views/TextEnhancement/TextEnhancementView.xaml.cs b/src/ChatPrisma/Views/TextEnhancement/TextEnhancementView.xaml.cs index d9b9b92..030ee2c 100644 --- a/src/ChatPrisma/Views/TextEnhancement/TextEnhancementView.xaml.cs +++ b/src/ChatPrisma/Views/TextEnhancement/TextEnhancementView.xaml.cs @@ -1,6 +1,7 @@ -using System.Windows; +using System.Windows; using System.Windows.Forms; using System.Windows.Interop; +using System.Windows.Media; using System.Windows.Threading; namespace ChatPrisma.Views.TextEnhancement; @@ -16,15 +17,47 @@ private void TextEnhancementView_OnLoaded(object sender, RoutedEventArgs e) // Ensure we are scrolled to the bottom window.Dispatcher.BeginInvoke(DispatcherPriority.Render, this.ScrollToBottom); - // Place window slightly to the top + // Setup dimensions and starting position window.Dispatcher.BeginInvoke(DispatcherPriority.Render, () => { var helper = new WindowInteropHelper(window); var currentScreen = Screen.FromHandle(helper.Handle); - var currentScreenHeight = currentScreen.Bounds.Height; + var dpi = VisualTreeHelper.GetDpi(window); + var currentScreenWorkingAreaDpiAdjusted = new Rectangle( + (int)(currentScreen.WorkingArea.X / dpi.DpiScaleX), + (int)(currentScreen.WorkingArea.Y / dpi.DpiScaleY), + (int)(currentScreen.WorkingArea.Width / dpi.DpiScaleX), + (int)(currentScreen.WorkingArea.Height / dpi.DpiScaleY)); - // Place the window a bit moved to the top, so it is perfectly centered if we reach this.MaxHeight - window.Top = Math.Max((currentScreenHeight - this.MaxHeight) / 2, 0); + this.Width = currentScreenWorkingAreaDpiAdjusted.Width switch + { + > 1400 => 800, + _ => 600 + }; + + this.MinHeight = 200; + + // Set window max-height, so we don't have to think about the window-shell when calculating the starting position + window.MaxHeight = currentScreenWorkingAreaDpiAdjusted.Height switch + { + > 1200 => 1000, + > 1000 => 800, + > 700 => 600, + _ => 400, + }; + + // Place the window a bit moved to the top, so it is perfectly centered if we reach window.MaxHeight + window.Top = currentScreenWorkingAreaDpiAdjusted.Y + Math.Max((currentScreenWorkingAreaDpiAdjusted.Height - window.MaxHeight) / 2, 0); + // And horizontally perfectly centered, because we have a fixed width + window.Left = currentScreenWorkingAreaDpiAdjusted.X + Math.Max((currentScreenWorkingAreaDpiAdjusted.Width - this.Width) / 2, 0); + }); + + // Hide the window until the previous call has positioned it correctly + // Don't use Visibility here, as that will not just hide the window, but also deactivate it and make it "not be a dialog anymore" + window.Opacity = 0; + window.Dispatcher.BeginInvoke(DispatcherPriority.Render, () => + { + window.Opacity = 1; }); // Ensure window is shown above all other windows diff --git a/src/ChatPrisma/app.manifest b/src/ChatPrisma/app.manifest new file mode 100644 index 0000000..93f2383 --- /dev/null +++ b/src/ChatPrisma/app.manifest @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + PerMonitor + true + + true + + + +