diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index 1ff0c42..0000000 --- a/.gitattributes +++ /dev/null @@ -1,63 +0,0 @@ -############################################################################### -# Set default behavior to automatically normalize line endings. -############################################################################### -* text=auto - -############################################################################### -# Set default behavior for command prompt diff. -# -# This is need for earlier builds of msysgit that does not have it on by -# default for csharp files. -# Note: This is only used by command line -############################################################################### -#*.cs diff=csharp - -############################################################################### -# Set the merge driver for project and solution files -# -# Merging from the command prompt will add diff markers to the files if there -# are conflicts (Merging from VS is not affected by the settings below, in VS -# the diff markers are never inserted). Diff markers may cause the following -# file extensions to fail to load in VS. An alternative would be to treat -# these files as binary and thus will always conflict and require user -# intervention with every merge. To do so, just uncomment the entries below -############################################################################### -#*.sln merge=binary -#*.csproj merge=binary -#*.vbproj merge=binary -#*.vcxproj merge=binary -#*.vcproj merge=binary -#*.dbproj merge=binary -#*.fsproj merge=binary -#*.lsproj merge=binary -#*.wixproj merge=binary -#*.modelproj merge=binary -#*.sqlproj merge=binary -#*.wwaproj merge=binary - -############################################################################### -# behavior for image files -# -# image files are treated as binary by default. -############################################################################### -#*.jpg binary -#*.png binary -#*.gif binary - -############################################################################### -# diff behavior for common document formats -# -# Convert binary document formats to text before diffing them. This feature -# is only available from the command line. Turn it on by uncommenting the -# entries below. -############################################################################### -#*.doc diff=astextplain -#*.DOC diff=astextplain -#*.docx diff=astextplain -#*.DOCX diff=astextplain -#*.dot diff=astextplain -#*.DOT diff=astextplain -#*.pdf diff=astextplain -#*.PDF diff=astextplain -#*.rtf diff=astextplain -#*.RTF diff=astextplain diff --git a/Legba.Engine/Enums.cs b/Legba.Engine/Enums.cs index 1cc5cec..d29425e 100644 --- a/Legba.Engine/Enums.cs +++ b/Legba.Engine/Enums.cs @@ -20,6 +20,7 @@ public enum Llm public enum Role { Unspecified, + System, User, Assistant } diff --git a/Legba.Engine/ExtensionMethods.cs b/Legba.Engine/ExtensionMethods.cs index 1f6866e..b497bb1 100644 --- a/Legba.Engine/ExtensionMethods.cs +++ b/Legba.Engine/ExtensionMethods.cs @@ -1,4 +1,6 @@ -using System.Text; +using Legba.Engine.Models; +using System.Collections.ObjectModel; +using System.Text; namespace Legba.Engine; @@ -17,4 +19,18 @@ public static void AppendLineIfNotEmpty(this StringBuilder sb, string text) sb.AppendLine(text); } } + + public static Settings.Llm LlmWithModel(this ObservableCollection llms, + Settings.Model model) + { + foreach (var llm in llms) + { + if (llm.Models.Contains(model)) + { + return llm; + } + } + + throw new ArgumentException($"Model {model} not found in the list of LLMs."); + } } \ No newline at end of file diff --git a/Legba.Engine/Legba.Engine.csproj b/Legba.Engine/Legba.Engine.csproj index 0d3acbe..ff15f3c 100644 --- a/Legba.Engine/Legba.Engine.csproj +++ b/Legba.Engine/Legba.Engine.csproj @@ -1,16 +1,25 @@  - net8.0 + net8.0-windows + true enable enable - 1.2.0.0 + 2.0.0.0 - - - + + + + + + + + + + + diff --git a/Legba.Engine/LlmConnectors/OpenAi/Message.cs b/Legba.Engine/LlmConnectors/OpenAi/Message.cs deleted file mode 100644 index 66f0f6b..0000000 --- a/Legba.Engine/LlmConnectors/OpenAi/Message.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Legba.Engine.LlmConnectors.OpenAi; - -public class Message -{ - [JsonPropertyName("role")] - public Enums.Role Role { get; set; } - [JsonPropertyName("content")] - public string Content { get; set; } = string.Empty; - [JsonIgnore] - public bool IsSentByUser { get { return Role == Enums.Role.User; } } -} \ No newline at end of file diff --git a/Legba.Engine/Models/ChatSession.cs b/Legba.Engine/Models/ChatSession.cs index 185fb3c..af52b25 100644 --- a/Legba.Engine/Models/ChatSession.cs +++ b/Legba.Engine/Models/ChatSession.cs @@ -1,81 +1,61 @@ -using Legba.Engine.LlmConnectors; -using Legba.Engine.LlmConnectors.OpenAi; -using Microsoft.Extensions.DependencyInjection; -using System.Collections.ObjectModel; +using System.Collections.ObjectModel; using System.Collections.Specialized; -using System.Text; +using Microsoft.Extensions.DependencyInjection; +using Legba.Engine.Models.OpenAi; +using Legba.Engine.Services; +using CSharpExtender.ExtensionMethods; namespace Legba.Engine.Models; public class ChatSession : ObservableObject, IDisposable { - #region Properties, Fields, Commands, and Events + #region Private fields - private readonly ILlmConnector _connection; + private readonly OpenAiConnector _llmConnector; - private Persona _persona = new(); - private Purpose _purpose = new(); - private Persuasion _persuasion = new(); - private Process _process = new(); - private string _prompt = string.Empty; private bool _disposed = false; // To detect redundant calls - public Persona Persona - { - get => _persona; - set - { - _persona = value; - OnPropertyChanged(nameof(Persona)); - } - } + private string _prompt = string.Empty; + private Personality _personality = new(); - public Purpose Purpose - { - get => _purpose; - set - { - _purpose = value; - OnPropertyChanged(nameof(Purpose)); - } - } + #endregion - public Persuasion Persuasion - { - get => _persuasion; - set - { - _persuasion = value; - OnPropertyChanged(nameof(Persuasion)); - } - } + #region Properties - public Process Process + public string Prompt { - get => _process; + get => _prompt; set { - _process = value; - OnPropertyChanged(nameof(Process)); + if (_prompt == value) + { + return; + } + + _prompt = value; + + OnPropertyChanged(nameof(Prompt)); } } - public string Prompt + public Personality Personality { - get => _prompt; + get => _personality; set { - if (_prompt != value) - { - _prompt = value; - OnPropertyChanged(nameof(Prompt)); - } + _personality = value; + OnPropertyChanged(nameof(Personality)); } } + public ObservableCollection SourceCodeFiles { get; set; } = []; + public ObservableCollection Messages { get; set; } = []; + public ObservableCollection TokenSummaries { get; set; } = []; + public string ModelName => $"{_llmConnector.Llm.Name} | {_llmConnector.Model.Name}"; + public bool HasMessages => Messages.Count > 0; public int GrandTotalRequestTokenCount => @@ -87,32 +67,70 @@ public string Prompt #endregion + #region Constructor + public ChatSession(IServiceProvider serviceProvider, Settings.Llm llm, Settings.Model model) { var factory = serviceProvider.GetRequiredService(); - _connection = factory.GetLlmConnector(llm, model); + _llmConnector = factory.GetLlmConnector(llm, model); - Messages.CollectionChanged += OnMessageCollectionChanged; + Messages.CollectionChanged += OnMessagesCollectionChanged; TokenSummaries.CollectionChanged += OnTokenUsagesCollectionChanged; } - public async Task Ask() + #endregion + + #region Eventhandlers + + private void OnMessagesCollectionChanged(object? sender, + NotifyCollectionChangedEventArgs e) + { + OnPropertyChanged(nameof(HasMessages)); + } + + private void OnTokenUsagesCollectionChanged(object? sender, + NotifyCollectionChangedEventArgs e) { - // Store prompt in a variable so we can clear it before the response is received. - // This prevents the user from spamming the ask button, - // due to the button not being enabled when the Prompt property is empty. - var completePrompt = BuildPrompt(); - Prompt = string.Empty; + OnPropertyChanged(nameof(GrandTotalRequestTokenCount)); + OnPropertyChanged(nameof(GrandTotalResponseTokenCount)); + OnPropertyChanged(nameof(GrandTotalTokenCount)); + } - // TODO: Record selected Persona, Purpose, and Process with prompt, for history. - AddMessage(Enums.Role.User, completePrompt); + #endregion + + #region Public methods + + public async Task AskAsync() + { + // On first request submission, include the personality and source code (if any) + if (Messages.None()) + { + if (Personality.Text.IsNotNullEmptyOrWhitespace()) + { + AddMessage(Enums.Role.System, Personality.Text); + } + + if (SourceCodeFiles.Any()) + { + // Consolidate source code files into a single string + string sourceCode = await FileConsolidator.GetFilesAsStringAsync(SourceCodeFiles); + + AddMessage(Enums.Role.User, sourceCode, true); + } + } + + // Add prompt to messages + AddMessage(Enums.Role.User, Prompt); + + // Clear prompt in UI + Prompt = string.Empty; var llmRequest = BuildLlmRequest(); AddMessage(Enums.Role.Assistant, "Thinking..."); - var response = await _connection.AskAsync(llmRequest); + var response = await _llmConnector.AskAsync(llmRequest); // Remove "Thinking..." message Messages.Remove(Messages.Last()); @@ -126,25 +144,31 @@ public async Task Ask() }); } - #region Private supporting methods - - private void OnMessageCollectionChanged(object? sender, - NotifyCollectionChangedEventArgs e) + public void AddSourceCodeFiles(IEnumerable filesToConsolidate) { - OnPropertyChanged(nameof(HasMessages)); + foreach (var file in filesToConsolidate) + { + if (!SourceCodeFiles.Contains(file)) + { + SourceCodeFiles.Add(file); + } + } } - private void OnTokenUsagesCollectionChanged(object? sender, - NotifyCollectionChangedEventArgs e) - { - OnPropertyChanged(nameof(GrandTotalRequestTokenCount)); - OnPropertyChanged(nameof(GrandTotalResponseTokenCount)); - OnPropertyChanged(nameof(GrandTotalTokenCount)); - } + #endregion + + #region Private supporting methods - private void AddMessage(Enums.Role role, string content) + private void AddMessage(Enums.Role role, string content, bool isInitialSourceCode = false) { - Messages.Add(new Message { Role = role, Content = content }); + var message = new Message + { + Role = role, + Content = content, + IsInitialSourceCode = isInitialSourceCode + }; + + Messages.Add(message); } private LegbaRequest BuildLlmRequest() @@ -156,25 +180,6 @@ private LegbaRequest BuildLlmRequest() }; } - private string BuildPrompt() - { - var sb = new StringBuilder(); - - // Only include the prompt prefixes if there are no messages yet, - // since the app currently always includes the prior messages in the request. - if(Messages.Count == 0) - { - sb.AppendLineIfNotEmpty(Persona.Text); - sb.AppendLineIfNotEmpty(Persuasion.Text); - sb.AppendLineIfNotEmpty(Purpose.Text); - sb.AppendLineIfNotEmpty(Process.Text); - } - - sb.Append(Prompt); - - return sb.ToString(); - } - #endregion #region Implementation of IDisposable diff --git a/Legba.Engine/Models/LegbaRequest.cs b/Legba.Engine/Models/LegbaRequest.cs index f8e0570..a7a7ae9 100644 --- a/Legba.Engine/Models/LegbaRequest.cs +++ b/Legba.Engine/Models/LegbaRequest.cs @@ -1,10 +1,10 @@ -using Legba.Engine.LlmConnectors.OpenAi; +using Legba.Engine.Models.OpenAi; namespace Legba.Engine.Models; public class LegbaRequest { public string Model { get; set; } = string.Empty; - public List Messages { get; set; } = new List(); + public List Messages { get; set; } = []; public float Temperature { get; set; } = 0.5f; } \ No newline at end of file diff --git a/Legba.Engine/LlmConnectors/OpenAi/Choice.cs b/Legba.Engine/Models/OpenAi/Choice.cs similarity index 89% rename from Legba.Engine/LlmConnectors/OpenAi/Choice.cs rename to Legba.Engine/Models/OpenAi/Choice.cs index 014da0c..714a25f 100644 --- a/Legba.Engine/LlmConnectors/OpenAi/Choice.cs +++ b/Legba.Engine/Models/OpenAi/Choice.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace Legba.Engine.LlmConnectors.OpenAi; +namespace Legba.Engine.Models.OpenAi; public class Choice { diff --git a/Legba.Engine/Models/OpenAi/Message.cs b/Legba.Engine/Models/OpenAi/Message.cs new file mode 100644 index 0000000..04a008a --- /dev/null +++ b/Legba.Engine/Models/OpenAi/Message.cs @@ -0,0 +1,70 @@ +using System.Text.Json.Serialization; + +namespace Legba.Engine.Models.OpenAi; + +public class Message +{ + [JsonPropertyName("role")] + public Enums.Role Role { get; set; } + + [JsonPropertyName("content")] + public string Content { get; set; } = string.Empty; + + [JsonIgnore] + public bool IsInitialSourceCode { get; set; } = false; + + [JsonIgnore] + public bool IsSentByUser { get { return Role == Enums.Role.User || Role == Enums.Role.System; } } + + [JsonIgnore] + public string DisplayText + { + get + { + if (Role == Enums.Role.System) + { + return "Personality sent"; + } + else if (IsInitialSourceCode) + { + return "Soure code sent"; + } + else + { + return Content; + } + } + } + + [JsonIgnore] + public System.Windows.HorizontalAlignment MessageAlignment + { + get + { + if (Role == Enums.Role.System || IsInitialSourceCode) + { + return System.Windows.HorizontalAlignment.Center; + } + + return IsSentByUser + ? System.Windows.HorizontalAlignment.Right + : System.Windows.HorizontalAlignment.Left; + } + } + + [JsonIgnore] + public System.Windows.Media.Brush MessageBackground + { + get + { + if (Role == Enums.Role.System || IsInitialSourceCode) + { + return System.Windows.Media.Brushes.Gold; + } + + return IsSentByUser + ? System.Windows.Media.Brushes.LightBlue + : System.Windows.Media.Brushes.LightGray; + } + } +} \ No newline at end of file diff --git a/Legba.Engine/LlmConnectors/OpenAi/OpenAiConnector.cs b/Legba.Engine/Models/OpenAi/OpenAiConnector.cs similarity index 72% rename from Legba.Engine/LlmConnectors/OpenAi/OpenAiConnector.cs rename to Legba.Engine/Models/OpenAi/OpenAiConnector.cs index ba64917..94553c6 100644 --- a/Legba.Engine/LlmConnectors/OpenAi/OpenAiConnector.cs +++ b/Legba.Engine/Models/OpenAi/OpenAiConnector.cs @@ -2,17 +2,14 @@ using System.Net.Http.Json; using System.Net.Http.Headers; using System.Text.Json.Serialization; -using Legba.Engine.Models; +using System.Net.Http; -namespace Legba.Engine.LlmConnectors.OpenAi; +namespace Legba.Engine.Models.OpenAi; -public class OpenAiConnector : ILlmConnector +public class OpenAiConnector(IHttpClientFactory httpClientFactory, + Settings.Llm llm, Settings.Model model) { - #region Properties and backing fields - - private readonly IHttpClientFactory _httpClientFactory; - private readonly Settings.Llm _llm; - private readonly Settings.Model _model; + #region Private fields private readonly JsonSerializerOptions _jsonSerializerOptions = new() @@ -27,13 +24,12 @@ public class OpenAiConnector : ILlmConnector #endregion - public OpenAiConnector(IHttpClientFactory httpClientFactory, - Settings.Llm llm, Settings.Model model) - { - _llm = llm; - _model = model; - _httpClientFactory = httpClientFactory; - } + #region Properties + + public Settings.Llm Llm { get; } = llm; + public Settings.Model Model { get; } = model; + + #endregion public async Task AskAsync(LegbaRequest legbaRequest) { @@ -44,14 +40,14 @@ public async Task AskAsync(LegbaRequest legbaRequest) // Send the request to OpenAI var httpClient = GetHttpClient(); - if(_llm.Name == Enums.Llm.Perplexity) + if(Llm.Name == Enums.Llm.Perplexity) { // Set the authorization header httpClient.DefaultRequestHeaders.Authorization = - new AuthenticationHeaderValue("Bearer", _llm.Keys.ApiKey); + new AuthenticationHeaderValue("Bearer", Llm.Keys.ApiKey); } - var uri = new Uri(_model.Url); + var uri = new Uri(Model.Url); var response = await httpClient .PostAsJsonAsync(uri, openAiRequest, _jsonSerializerOptions) @@ -64,7 +60,7 @@ await httpClient var openAiResponse = await response .Content.ReadFromJsonAsync(_jsonSerializerOptions) - ?? throw new Exception("Error parsing the OpenAI API response"); + ?? throw new Exception("Error parsing the API response"); var legbaResponse = MapToLegbaResponse(openAiResponse); @@ -83,13 +79,13 @@ await response private HttpClient GetHttpClient() { - var httpClient = _httpClientFactory.CreateClient(); + var httpClient = httpClientFactory.CreateClient(); - httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {_llm.Keys.ApiKey}"); + httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {Llm.Keys.ApiKey}"); - if (_llm.Keys.OrgId.IsNotNullEmptyOrWhitespace()) + if (Llm.Keys.OrgId.IsNotNullEmptyOrWhitespace()) { - httpClient.DefaultRequestHeaders.Add("OpenAI-Organization", _llm.Keys.OrgId); + httpClient.DefaultRequestHeaders.Add("OpenAI-Organization", Llm.Keys.OrgId); } return httpClient; @@ -99,7 +95,7 @@ private OpenAiRequest MapToOpenAiRequest(LegbaRequest legbaRequest) { return new OpenAiRequest() { - Model = _model.Id, + Model = Model.Id, Messages = legbaRequest.Messages, Temperature = legbaRequest.Temperature }; diff --git a/Legba.Engine/LlmConnectors/OpenAi/OpenAiRequest.cs b/Legba.Engine/Models/OpenAi/OpenAiRequest.cs similarity index 88% rename from Legba.Engine/LlmConnectors/OpenAi/OpenAiRequest.cs rename to Legba.Engine/Models/OpenAi/OpenAiRequest.cs index 9809dd6..a0d572e 100644 --- a/Legba.Engine/LlmConnectors/OpenAi/OpenAiRequest.cs +++ b/Legba.Engine/Models/OpenAi/OpenAiRequest.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace Legba.Engine.LlmConnectors.OpenAi; +namespace Legba.Engine.Models.OpenAi; public class OpenAiRequest { diff --git a/Legba.Engine/LlmConnectors/OpenAi/OpenAiResponse.cs b/Legba.Engine/Models/OpenAi/OpenAiResponse.cs similarity index 92% rename from Legba.Engine/LlmConnectors/OpenAi/OpenAiResponse.cs rename to Legba.Engine/Models/OpenAi/OpenAiResponse.cs index 40b8731..7e08d2a 100644 --- a/Legba.Engine/LlmConnectors/OpenAi/OpenAiResponse.cs +++ b/Legba.Engine/Models/OpenAi/OpenAiResponse.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace Legba.Engine.LlmConnectors.OpenAi; +namespace Legba.Engine.Models.OpenAi; public class OpenAiResponse { diff --git a/Legba.Engine/LlmConnectors/OpenAi/Usage.cs b/Legba.Engine/Models/OpenAi/Usage.cs similarity index 87% rename from Legba.Engine/LlmConnectors/OpenAi/Usage.cs rename to Legba.Engine/Models/OpenAi/Usage.cs index 793be98..e719d3d 100644 --- a/Legba.Engine/LlmConnectors/OpenAi/Usage.cs +++ b/Legba.Engine/Models/OpenAi/Usage.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace Legba.Engine.LlmConnectors.OpenAi; +namespace Legba.Engine.Models.OpenAi; public class Usage { diff --git a/Legba.Engine/Models/Persona.cs b/Legba.Engine/Models/Persona.cs deleted file mode 100644 index f0d7d01..0000000 --- a/Legba.Engine/Models/Persona.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace Legba.Engine.Models; - -public class Persona : PromptPrefix -{ -} \ No newline at end of file diff --git a/Legba.Engine/Models/Personality.cs b/Legba.Engine/Models/Personality.cs new file mode 100644 index 0000000..5ae8626 --- /dev/null +++ b/Legba.Engine/Models/Personality.cs @@ -0,0 +1,5 @@ +namespace Legba.Engine.Models; + +public class Personality : PromptPrefix +{ +} \ No newline at end of file diff --git a/Legba.Engine/Models/Persuasion.cs b/Legba.Engine/Models/Persuasion.cs deleted file mode 100644 index 17dbd4d..0000000 --- a/Legba.Engine/Models/Persuasion.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace Legba.Engine.Models; - -public class Persuasion : PromptPrefix -{ -} \ No newline at end of file diff --git a/Legba.Engine/Models/Process.cs b/Legba.Engine/Models/Process.cs deleted file mode 100644 index bb09110..0000000 --- a/Legba.Engine/Models/Process.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace Legba.Engine.Models; - -public class Process : PromptPrefix -{ -} \ No newline at end of file diff --git a/Legba.Engine/Models/PromptPrefixesExport.cs b/Legba.Engine/Models/PromptPrefixesExport.cs deleted file mode 100644 index 5dcda25..0000000 --- a/Legba.Engine/Models/PromptPrefixesExport.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Legba.Engine.Models; - -public class PromptPrefixesExport -{ - public List Personas { get; set; } = new(); - public List Purposes { get; set; } = new(); - public List Persuasions { get; set; } = new(); - public List Processes { get; set; } = new(); -} \ No newline at end of file diff --git a/Legba.Engine/Models/Purpose.cs b/Legba.Engine/Models/Purpose.cs deleted file mode 100644 index 50436ff..0000000 --- a/Legba.Engine/Models/Purpose.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace Legba.Engine.Models; - -public class Purpose : PromptPrefix -{ -} \ No newline at end of file diff --git a/Legba.Engine/Services/FileCollector.cs b/Legba.Engine/Services/FileCollector.cs new file mode 100644 index 0000000..85feab6 --- /dev/null +++ b/Legba.Engine/Services/FileCollector.cs @@ -0,0 +1,170 @@ +using Microsoft.CodeAnalysis.MSBuild; +using System.Collections.Concurrent; +using System.IO; +using System.Text.RegularExpressions; + +namespace Legba.Engine.Services; + +public class FileCollector +{ + #region Constants and Fields + + private static readonly IReadOnlyList s_fileExtensionsToInclude = [".cs", ".vb", ".xaml"]; + private static readonly IReadOnlyList s_excludedFilePatterns = + [ + new Regex(@"(AssemblyAttributes|AssemblyInfo|\.g|\.g\.i|\.Designer|\.generated)\.(cs|vb)$", + RegexOptions.IgnoreCase | RegexOptions.Compiled) + ]; + + #endregion + + #region Public Methods + + /// + /// Retrieves all code files (*.cs|*.vb) from a solution, + /// excluding files that match specific patterns (generally the automatically created files). + /// + /// Name of Visual Studio solution + /// ReadOnlyList of code files in a Visual Studio solution + /// Exception if .sln file cannot be loaded + public static async Task> GetFilesFromSolutionAsync(string solutionFileName) + { + ValidateFilePath(solutionFileName, nameof(solutionFileName)); + + using var workspace = MSBuildWorkspace.Create(); + + var solution = await workspace.OpenSolutionAsync(solutionFileName) + ?? throw new InvalidOperationException("Failed to open solution."); + + var filePaths = new ConcurrentBag(); + + foreach (var project in solution.Projects) + { + foreach (var document in project.Documents) + { + if (!IsExcludedFile(document.Name)) + { + filePaths.Add(document.FilePath ?? document.Name); + } + } + } + + return filePaths.ToList().AsReadOnly(); + } + + /// + /// Retrieves all code files (*.cs|*.vb) from a project, + /// excluding files that match specific patterns (generally the automatically created files). + /// + /// Name of the Visual Studio project + /// ReadOnlyList of code files in a Visual Studio project + /// Exception if .csproj|.vbproj file cannot be loaded + public static async Task> GetFilesFromProjectAsync(string projectFileName) + { + ValidateFilePath(projectFileName, nameof(projectFileName)); + + using var workspace = MSBuildWorkspace.Create(); + + var project = await workspace.OpenProjectAsync(projectFileName) + ?? throw new InvalidOperationException("Failed to open project."); + + var filePaths = new List(); + + foreach (var document in project.Documents) + { + if (!IsExcludedFile(document.Name)) + { + filePaths.Add(document.FilePath ?? document.Name); + } + } + + return filePaths.AsReadOnly(); + } + + /// + /// Retrieves all code files (*.cs|*.vb) from specified folders, + /// excluding files that match specific patterns (generally the automatically created files). + /// + /// List of folder paths + /// ReadOnlyList of code files + public static async Task> GetFilesFromFoldersAsync(string[] folderPaths) + { + ValidateFolderPaths(folderPaths); + + var filePaths = new List(); + + foreach (var folderPath in folderPaths) + { + foreach (var extension in s_fileExtensionsToInclude) + { + filePaths.AddRange( + Directory.GetFiles(folderPath, $"*{extension}", + SearchOption.AllDirectories)); + } + } + + return await Task.FromResult>( + filePaths.Where(f => !IsExcludedFile(f)).ToList().AsReadOnly()); + } + + public static async Task> GetFilesFromFilesAsync(string[] filePaths) + { + ValidateFilePaths(filePaths); + + return await Task.FromResult>(filePaths.AsReadOnly()); + } + + #endregion + + #region Private Helper Methods + + private static void ValidateFilePath(string filePath, string paramName) + { + if (string.IsNullOrEmpty(filePath)) + { + throw new ArgumentException($"{paramName} cannot be null or empty.", paramName); + } + + if (!File.Exists(filePath)) + { + throw new FileNotFoundException($"{paramName} not found.", filePath); + } + } + + private static void ValidateFolderPaths(string[] folderPaths) + { + if (folderPaths.Length == 0) + { + throw new ArgumentException("Folder paths cannot be null or empty.", nameof(folderPaths)); + } + + var invalidPaths = folderPaths.Where(fp => !Directory.Exists(fp)).ToArray(); + + if (invalidPaths.Any()) + { + throw new DirectoryNotFoundException("One or more folder paths do not exist: " + string.Join(", ", invalidPaths)); + } + } + + private static void ValidateFilePaths(string[] filePaths) + { + if (filePaths.Length == 0) + { + throw new ArgumentException("File paths cannot be null or empty.", nameof(filePaths)); + } + + var invalidPaths = filePaths.Where(fp => !File.Exists(fp)).ToArray(); + + if (invalidPaths.Any()) + { + throw new FileNotFoundException("One or more files do not exist: " + string.Join(", ", invalidPaths)); + } + } + + private static bool IsExcludedFile(string fileName) + { + return s_excludedFilePatterns.Any(regex => regex.IsMatch(fileName)); + } + + #endregion +} diff --git a/Legba.Engine/Services/FileConsolidator.cs b/Legba.Engine/Services/FileConsolidator.cs new file mode 100644 index 0000000..1ddcf76 --- /dev/null +++ b/Legba.Engine/Services/FileConsolidator.cs @@ -0,0 +1,40 @@ +using System.IO; +using System.Text; + +namespace Legba.Engine.Services; + +public class FileConsolidator +{ + private static readonly string _separator = new('=', 30); + + public static async Task GetFilesAsStringAsync(IReadOnlyList files) + { + if (files == null || files.Count == 0) + { + return string.Empty; + } + + // Calculate total size for StringBuilder capacity + long estimatedSize = 0; + foreach (var file in files) + { + var fileInfo = new FileInfo(file); + estimatedSize += fileInfo.Length; + estimatedSize += Path.GetFileName(file).Length + Environment.NewLine.Length; + estimatedSize += _separator.Length + Environment.NewLine.Length; + estimatedSize += Environment.NewLine.Length; + } + + // Set StringBuilder's capacity, cap at int.MaxValue + var sb = new StringBuilder(Math.Min((int)estimatedSize, int.MaxValue)); + foreach (var file in files) + { + sb.AppendLine(Path.GetFileName(file)); + sb.AppendLine(_separator); + sb.Append(await File.ReadAllTextAsync(file)); + sb.AppendLine(""); + } + + return sb.ToString(); + } +} diff --git a/Legba.Engine/LlmConnectors/ILlmConnector.cs b/Legba.Engine/Services/ILlmConnector.cs similarity index 76% rename from Legba.Engine/LlmConnectors/ILlmConnector.cs rename to Legba.Engine/Services/ILlmConnector.cs index bbb24e6..285ae24 100644 --- a/Legba.Engine/LlmConnectors/ILlmConnector.cs +++ b/Legba.Engine/Services/ILlmConnector.cs @@ -1,6 +1,6 @@ using Legba.Engine.Models; -namespace Legba.Engine.LlmConnectors; +namespace Legba.Engine.Services; public interface ILlmConnector { diff --git a/Legba.Engine/LlmConnectors/LlmConnectorFactory.cs b/Legba.Engine/Services/LlmConnectorFactory.cs similarity index 53% rename from Legba.Engine/LlmConnectors/LlmConnectorFactory.cs rename to Legba.Engine/Services/LlmConnectorFactory.cs index 5467ceb..04f6708 100644 --- a/Legba.Engine/LlmConnectors/LlmConnectorFactory.cs +++ b/Legba.Engine/Services/LlmConnectorFactory.cs @@ -1,6 +1,8 @@ using Legba.Engine.Models; +using Legba.Engine.Models.OpenAi; +using System.Net.Http; -namespace Legba.Engine.LlmConnectors; +namespace Legba.Engine.Services; public class LlmConnectorFactory { @@ -11,8 +13,8 @@ public LlmConnectorFactory(IHttpClientFactory httpClientFactory) _httpClientFactory = httpClientFactory; } - public ILlmConnector GetLlmConnector(Settings.Llm llm, Settings.Model model) + public OpenAiConnector GetLlmConnector(Settings.Llm llm, Settings.Model model) { - return new OpenAi.OpenAiConnector(_httpClientFactory, llm, model); + return new OpenAiConnector(_httpClientFactory, llm, model); } } diff --git a/Legba.Engine/Services/PromptRepository.cs b/Legba.Engine/Services/PromptRepository.cs index e7484c7..7a2c1c3 100644 --- a/Legba.Engine/Services/PromptRepository.cs +++ b/Legba.Engine/Services/PromptRepository.cs @@ -6,11 +6,17 @@ namespace Legba.Engine.Services; public class PromptRepository(string databasePath) : IDisposable { + #region Private members + // This will create a LiteDB database file in the same directory as the executable, // if one does not already exist. private readonly LiteDatabase _liteDb = new(databasePath); private bool _disposed = false; + #endregion + + #region Public methods + public static string GetCollectionName() where T : PromptPrefix { return typeof(T).Name; @@ -41,7 +47,7 @@ public bool AddOrUpdate(T item) where T : PromptPrefix var collectionName = GetCollectionName(); var collection = _liteDb.GetCollection(collectionName); - if(item.Id == Guid.Empty) + if (item.Id == Guid.Empty) { // New items Id is Guid.Empty, so we need to generate a real Id. item.Id = Guid.NewGuid(); @@ -78,17 +84,7 @@ public IEnumerable GetAll() where T : PromptPrefix .FindAll().OrderBy(pp => pp.Name); } - public PromptPrefixesExport Export() - { - var export = new PromptPrefixesExport(); - - export.Personas.AddRange(GetAll()); - export.Purposes.AddRange(GetAll()); - export.Persuasions.AddRange(GetAll()); - export.Processes.AddRange(GetAll()); - - return export; - } + #endregion #region Implementation of IDisposable diff --git a/Legba.Engine/ViewModels/AboutViewModel.cs b/Legba.Engine/ViewModels/AboutViewModel.cs index 9780234..387418d 100644 --- a/Legba.Engine/ViewModels/AboutViewModel.cs +++ b/Legba.Engine/ViewModels/AboutViewModel.cs @@ -27,7 +27,7 @@ public class AboutViewModel "This software is provided as-is, without any warranty, express or implied. " + "While every effort has been made to ensure the program functions correctly, it is used at your own risk."; public string Credits => - "This software uses the following third-party components:\n" + + "This application uses the following third-party components:\n" + "• LiteDB\n" + "• ScottLilly.CSharpExtender"; diff --git a/Legba.Engine/ViewModels/ChatSessionViewModel.cs b/Legba.Engine/ViewModels/ChatSessionViewModel.cs new file mode 100644 index 0000000..5faf572 --- /dev/null +++ b/Legba.Engine/ViewModels/ChatSessionViewModel.cs @@ -0,0 +1,102 @@ +using Legba.Engine.Models; +using Microsoft.Extensions.DependencyInjection; +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.Windows.Input; + +namespace Legba.Engine.ViewModels; + +public class ChatSessionViewModel : ObservableObject +{ + #region Private fields + + private readonly IServiceProvider _serviceProvider; + + private ChatSession? _chatSession; + + #endregion + + #region Properties and Commands + + public ObservableCollection Llms { get; } = []; + + public ChatSession? ChatSession + { + get => _chatSession; + set + { + // Early exit, if object was not changed + if (_chatSession == value) + { + return; + } + + // Change the value, with proper unsubscribe, dispose, subscribe, + // and property changed notifications. + if (_chatSession != null) + { + _chatSession.Messages.CollectionChanged -= Messages_CollectionChanged; + _chatSession.Dispose(); + } + + _chatSession = value; + + OnPropertyChanged(nameof(ChatSession)); + OnPropertyChanged(nameof(HasChatSession)); + OnPropertyChanged(nameof(HasChatMessages)); + + if (_chatSession != null) + { + _chatSession.Messages.CollectionChanged += Messages_CollectionChanged; + } + } + } + + public bool HasChatSession => ChatSession != null; + public bool HasChatMessages => ChatSession?.Messages.Count > 0; + + public ICommand SelectModelCommand { get; private set; } + public ICommand AskCommand { get; private set; } + public ICommand RemoveSourceCodeFileCommand { get; } + + #endregion + + public ChatSessionViewModel(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + + var settings = _serviceProvider.GetRequiredService(); + + foreach (var llm in settings.Llms) + { + Llms.Add(llm); + } + + SelectModelCommand = new TypedRelayCommand(SelectModel); + AskCommand = new RelayCommand(async () => await ChatSession.AskAsync()); + RemoveSourceCodeFileCommand = new TypedRelayCommand(RemoveSourceCodeFile); + } + + private void Messages_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + OnPropertyChanged(nameof(HasChatMessages)); + } + + private void SelectModel(Settings.Model model) + { + ChatSession = new ChatSession(_serviceProvider, Llms.LlmWithModel(model), model); + } + + private void RemoveSourceCodeFile(object file) + { + if (ChatSession == null) + { + return; + } + + if (file is string path) + { + ChatSession.SourceCodeFiles.Remove(path); + } + } +} diff --git a/Legba.Engine/ViewModels/ChatViewModel.cs b/Legba.Engine/ViewModels/ChatViewModel.cs deleted file mode 100644 index 0d56431..0000000 --- a/Legba.Engine/ViewModels/ChatViewModel.cs +++ /dev/null @@ -1,76 +0,0 @@ -using System.Collections.ObjectModel; -using System.Collections.Specialized; -using System.Windows.Input; -using Legba.Engine.Models; -using Microsoft.Extensions.DependencyInjection; - -namespace Legba.Engine.ViewModels; - -public class ChatViewModel : ObservableObject -{ - #region Properties, Fields, Commands, and Events - - private readonly IServiceProvider _serviceProvider; - - public ObservableCollection Llms { get; } = new(); - - private ChatSession _chatSession; - - public ChatSession ChatSession - { - get { return _chatSession; } - set - { - if (_chatSession != null) - { - _chatSession.Messages.CollectionChanged -= Messages_CollectionChanged; - } - - if (_chatSession != value) - { - _chatSession = value; - OnPropertyChanged(nameof(ChatSession)); - OnPropertyChanged(nameof(HasChatSession)); - OnPropertyChanged(nameof(HasChatMessages)); - - _chatSession.Messages.CollectionChanged += Messages_CollectionChanged; - } - } - } - - private void Messages_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) - { - OnPropertyChanged(nameof(HasChatMessages)); - } - - public bool HasChatSession => ChatSession != null; - public bool HasChatMessages => ChatSession?.Messages.Count > 0; - - public ICommand SelectModelCommand { get; private set; } - public ICommand AskCommand { get; private set; } - - #endregion - - public ChatViewModel(IServiceProvider serviceProvider) - { - _serviceProvider = serviceProvider; - - var settings = _serviceProvider.GetRequiredService(); - foreach (var llm in settings.Llms) - { - Llms.Add(llm); - } - - SelectModelCommand = new TypedRelayCommand(SelectModel); - AskCommand = new RelayCommand(async () => await ChatSession.Ask()); - } - - private void SelectModel(Settings.Model model) - { - var llm = Llms.First(l => l.Models.Contains(model)); - - ChatSession?.Dispose(); - - ChatSession = new ChatSession(_serviceProvider, llm, model); - } -} \ No newline at end of file diff --git a/Legba.Engine/ViewModels/HelpViewModel.cs b/Legba.Engine/ViewModels/HelpViewModel.cs index 0fd630f..71f7bc3 100644 --- a/Legba.Engine/ViewModels/HelpViewModel.cs +++ b/Legba.Engine/ViewModels/HelpViewModel.cs @@ -32,61 +32,62 @@ private void PopulateHelpTopics() { Topics.Clear(); - AddHelpTopic("OpenAI API Setup", - "Create an OpenAI API account at https://openai.com/blog/openai-api." + + AddHelpTopic("API Setup", + "Create an API account for any of the following LLMs: OpenAI, Grok, Perplexity, or Groq." + _doubleNewLine + - "Get an OpenAI key at https://platform.openai.com/api-keys." + + "Get your API key and (optionally) Organization ID." + _doubleNewLine + "Fund the API account. It may take a few hours for the API to recognize the account was funded."); AddHelpTopic("Legba Setup", "In the directory where Legba is installed, find the appsettings.json file and open it in a text editor." + _doubleNewLine + - "Add your OpenAI API key and (optionally) your OpenAI Organization ID."); - - AddHelpTopic("New Chat Session", - "When you want to chat with ChatGPT, select File -> New Session -> OpenAi and then the version you want to use." + + "Add your API key and (optionally) your Organization ID where appsettings has 'YOUR_API_KEY' and 'YOUR_ORG_ID'." + _doubleNewLine + - "This should display an empty message history on the left, the four prompt prefix sections (Persona, Purpose, Persuasion, and Process) on the right, and the prompt input box on the bottom right."); + "If you are not using an Organization ID, remove the text 'YOUR_ORG_ID'."); - AddHelpTopic("Prompt Prefixes", - "The prompt prefixes are used to help ChatGPT understand the context of your conversation." + - _doubleNewLine + - "They are not required. You can enter you prompt in the lower-right textbox and click the 'Ask' button to prompt ChatGPT without any prompt prefixes." + + AddHelpTopic("New LLM Session", + "When you want to chat with the LLM, select File -> New Session -> LLM Company and the name of the model you want to use." + _doubleNewLine + - "However, you can save prefixes in the database for future use - to save on typing and improve consistency in results." + + "This will display the session screen. Your conversation with the LLM will be displayed in the top section of the screen." + _doubleNewLine + - "Currently, the prefixes are only passed in with the first request to ChatGPT. So, the button to select a new prompt prefix is disabled after the initial request."); + "You can enter your request in 'User-Entered Prompt', and optionally select a Personality or source code files to include."); - AddHelpTopic("Managing Prompt Prefixes", - "Above each prompt prefix section are three buttons." + + AddHelpTopic("Personality", + "The personality is used to help the LLM understand the context of your conversation and how to respond. It is not required." + _doubleNewLine + - "The magnifying glass shows you a list of available prefixes. From there, you can add a new prefix, edit or delete an existing prefix, or select the prefix to use." + + "The magnifying glass shows you a list of available personalities. From there, you can add a new one, edit or delete an existing one, or select one to use." + _doubleNewLine + - "Since the prompt prefixes are only sent in with the first request, this button is disabled after making the initial request." + + "The green '+' button lets you save what is currently in the prompt's textbox to the database. It will pop up a window for you to enter the personality's name before saving." + _doubleNewLine + - "The green '+' button will let you save what is currently in the prompt's textbox to your library. It will pop up a window for you to enter the prompt prefix's name before saving." + + "The red 'X' button lets you clear out the prompt prefix text box. It does not affect anything in the database." + _doubleNewLine + - "The red 'X' button lets you clear out the prompt prefix text box. It does not affect anything in the database."); + "The personality is only sent with the first request. So, the Personality tab is disabled after the first request."); - AddHelpTopic("Chatting with ChatGPT", - "Enter, or select, any prompt prefixes you want to use." + + AddHelpTopic("Source Code", + "You can include C# and VB.NET (including XAML) source code files with your first request." + _doubleNewLine + - "Then, enter your prompt in the lower-right textbox and click the 'Ask' button." + + "You can use the buttons on the left to add source code files from a solution file, project file, folder(s), or just selecting the file(s) to include." + _doubleNewLine + - "Your message will be displayed in the message area on the left, along with a 'Thinking...' message while waiting for ChatGPT's response."); + "You can selectively delete files by clicking the red 'X' button next to the file in the list." + + _doubleNewLine + + "The source code files are only sent with the first request. So, the 'Source Code from Files' tab is disabled after the first request." + ); - AddHelpTopic("Saving Message History", - "You can save the chat session's message history to a text file by selecting Export -> Current Chat Messages." + + AddHelpTopic("Sending Your Request", + "Enter, or select, any (optional) personality and (optional) source code files you want to include in your request." + _doubleNewLine + - "This will pop up a window for you to select the file to save to." + + "Enter your prompt/question in the 'User-Entered Prompt' tab, then click the 'Submit Request to LLM' button." + _doubleNewLine + - "You can also save an individual message by right-clicking on it and selecting 'Save Message' from the context menu."); + "Your message will be displayed in the upper response area, along with a 'Thinking...' message while waiting for the LLM's response."); + + AddHelpTopic("Copying Responses", + "You can right-click on a response and copy its text to your clipboard, to paste into Notepad or an editor."); - AddHelpTopic("Saving Prompt Prefixes", - "You can save the prompt prefixes to a text file by selecting Export -> Prompt Prefixes." + + AddHelpTopic("Statistics", + "Clicking on the 'Statistics' tab at the top of the app will show you the number of tokens you've used during this session." + _doubleNewLine + - "This will pop up a window for you to select the file to save to."); + "It shows a list of tokens used with each request and the session's grand total at the bottom."); } private void AddHelpTopic(string title, string content) diff --git a/Legba.WPF/App.xaml.cs b/Legba.WPF/App.xaml.cs deleted file mode 100644 index ce15b2a..0000000 --- a/Legba.WPF/App.xaml.cs +++ /dev/null @@ -1,86 +0,0 @@ -using Legba.Engine.LlmConnectors; -using Legba.Engine.LlmConnectors.OpenAi; -using Legba.Engine.Models; -using Legba.Engine.Services; -using Legba.Engine.ViewModels; -using Legba.WPF.Windows; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using System.Windows; - -namespace Legba.WPF; - -public partial class App : Application -{ - private readonly IServiceProvider _serviceProvider; - - public App() - { - IServiceCollection services = new ServiceCollection(); - - // Load settings from user secrets -#if DEBUG - var builder = new ConfigurationBuilder() - .AddUserSecrets(); -#else - // Load settings from appsettings.json - var builder = - new ConfigurationBuilder() - .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true); -#endif - var config = builder.Build(); - var settings = config.Get() ?? new Settings(); - - // Register the Settings object for injection - services.AddSingleton(settings); - - // Register view and viewmodel objects for injection - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient>(); - services.AddTransient>(); - services.AddTransient>(); - services.AddTransient>(); - services.AddTransient>(); - services.AddTransient>(); - services.AddTransient>(); - services.AddTransient>(); - services.AddTransient>(); - services.AddTransient>(); - services.AddTransient>(); - services.AddTransient>(); - services.AddTransient>(); - services.AddTransient>(); - services.AddTransient>(); - services.AddTransient>(); - - // Register 'service' objects for injection - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(provider => new PromptRepository("Legba.db")); - - // Register transient classes for injection - services.AddTransient(); - - // Registers IHttpClientFactory - services.AddHttpClient(); - - _serviceProvider = services.BuildServiceProvider(); - } - - protected override void OnStartup(StartupEventArgs e) - { - base.OnStartup(e); - - // Resolve the main window and set its DataContext to the injected view model - var mainWindow = _serviceProvider.GetRequiredService(); - var viewModel = _serviceProvider.GetRequiredService(); - - mainWindow.DataContext = viewModel; - mainWindow.Show(); - } -} \ No newline at end of file diff --git a/Legba.WPF/CustomConverters/BooleanToBrushConverter.cs b/Legba.WPF/CustomConverters/BooleanToBrushConverter.cs deleted file mode 100644 index 6c7f247..0000000 --- a/Legba.WPF/CustomConverters/BooleanToBrushConverter.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.Globalization; -using System.Windows.Data; -using System.Windows.Media; - -namespace Legba.WPF.CustomConverters; - -public class BooleanToBrushConverter : IValueConverter -{ - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - return (bool)value ? Brushes.LightBlue : Brushes.LightGray; - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - { - throw new NotImplementedException(); - } -} \ No newline at end of file diff --git a/Legba.WPF/CustomConverters/BooleanToHorizontalAlignmentConverter.cs b/Legba.WPF/CustomConverters/BooleanToHorizontalAlignmentConverter.cs deleted file mode 100644 index edecc62..0000000 --- a/Legba.WPF/CustomConverters/BooleanToHorizontalAlignmentConverter.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.Globalization; -using System.Windows.Data; -using System.Windows; - -namespace Legba.WPF.CustomConverters; - -public class BooleanToHorizontalAlignmentConverter : IValueConverter -{ - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - return (bool)value ? HorizontalAlignment.Right : HorizontalAlignment.Left; - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - { - throw new NotImplementedException(); - } -} \ No newline at end of file diff --git a/Legba.WPF/Images/LegbaLogo_Transparent.png b/Legba.WPF/Images/LegbaLogo_Transparent.png deleted file mode 100644 index 98a5674..0000000 Binary files a/Legba.WPF/Images/LegbaLogo_Transparent.png and /dev/null differ diff --git a/Legba.WPF/MainWindow.xaml b/Legba.WPF/MainWindow.xaml deleted file mode 100644 index 34eef8a..0000000 --- a/Legba.WPF/MainWindow.xaml +++ /dev/null @@ -1,390 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/Legba.WPF/MainWindow.xaml.cs b/Legba.WPF/MainWindow.xaml.cs deleted file mode 100644 index 6c7a884..0000000 --- a/Legba.WPF/MainWindow.xaml.cs +++ /dev/null @@ -1,234 +0,0 @@ -using CSharpExtender.ExtensionMethods; -using Legba.Engine.LlmConnectors.OpenAi; -using Legba.Engine.Models; -using Legba.Engine.Services; -using Legba.Engine.ViewModels; -using Legba.WPF.Windows; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Win32; -using System.IO; -using System.Windows; - -namespace Legba.WPF; - -public partial class MainWindow : Window -{ - private readonly IServiceProvider _serviceProvider; - private readonly PromptRepository _promptRepository; - private HelpView? _helpView; - - private ChatViewModel? VM => DataContext as ChatViewModel; - - public MainWindow(IServiceProvider serviceProvider) - { - InitializeComponent(); - - _serviceProvider = serviceProvider; - _promptRepository = _serviceProvider.GetRequiredService(); - } - - #region Menu item click handlers - - private void MenuItemCopyToClipboard_Click(object sender, RoutedEventArgs e) - { - if (messages.SelectedIndex != -1) - { - var message = (Message)messages.SelectedItem; - - Clipboard.SetText(message.Content); - } - } - - private void ExportPromptPrefixesLibrary_Click(object sender, RoutedEventArgs e) - { - var saveFileDialog = - new SaveFileDialog - { - Filter = "JSON files (*.json)|*.json|All files (*.*)|*.*", - FilterIndex = 1, - RestoreDirectory = true - }; - - if(saveFileDialog.ShowDialog() == true) - { - var export = _promptRepository.Export(); - - File.WriteAllText(saveFileDialog.FileName, - export.AsSerializedJson().PrettyPrintJson()); - } - } - - private void ExportCurrentChatMessages_Click(object sender, RoutedEventArgs e) - { - var saveFileDialog = - new SaveFileDialog - { - Filter = "JSON files (*.json)|*.json|All files (*.*)|*.*", - FilterIndex = 1, - RestoreDirectory = true - }; - - if (saveFileDialog.ShowDialog() == true) - { - var export = VM.ChatSession.Messages; - - File.WriteAllText(saveFileDialog.FileName, - export.AsSerializedJson().PrettyPrintJson()); - } - } - - private void Exit_Click(object sender, RoutedEventArgs e) - { - Close(); - } - - private void Help_Click(object sender, RoutedEventArgs e) - { - if (_helpView == null) - { - _helpView = _serviceProvider.GetRequiredService(); - _helpView.Owner = this; - - // Set _helpView to null when it's closed - _helpView.Closed += (s, args) => _helpView = null; - } - - // Bring the window to the top and set focus - _helpView.Activate(); - - if (_helpView.WindowState == WindowState.Minimized) - { - // If the window was minimized, restore it - _helpView.WindowState = WindowState.Normal; - } - - // Show the window if it's not already visible - _helpView.Show(); - } - - private void About_Click(object sender, RoutedEventArgs e) - { - var aboutView = _serviceProvider.GetRequiredService(); - aboutView.Owner = this; - aboutView.ShowDialog(); - } - - #endregion - - #region Button click handlers - - private void ManagePersonas_Click(object sender, RoutedEventArgs e) - { - DisplayPromptPrefixSelectionPopup(); - } - - private void AddUpdatePersonaText_Click(object sender, RoutedEventArgs e) - { - DisplayPromptPrefixEditorPopup(VM.ChatSession.Persona); - } - - private void ClearPersonaText_Click(object sender, RoutedEventArgs e) - { - VM.ChatSession.Persona = new Persona(); - } - - private void ManagePurposes_Click(object sender, RoutedEventArgs e) - { - DisplayPromptPrefixSelectionPopup(); - } - - private void AddUpdatePurposeText_Click(object sender, RoutedEventArgs e) - { - DisplayPromptPrefixEditorPopup(VM.ChatSession.Purpose); - } - - private void ClearPurposeText_Click(object sender, RoutedEventArgs e) - { - VM.ChatSession.Purpose = new Purpose(); - } - - private void ManagePersuasions_Click(object sender, RoutedEventArgs e) - { - DisplayPromptPrefixSelectionPopup(); - } - - private void AddUpdatePersuasionText_Click(object sender, RoutedEventArgs e) - { - DisplayPromptPrefixEditorPopup(VM.ChatSession.Persuasion); - } - - private void ClearPersuasionText_Click(object sender, RoutedEventArgs e) - { - VM.ChatSession.Persuasion = new Persuasion(); - } - - private void ManageProcesses_Click(object sender, RoutedEventArgs e) - { - DisplayPromptPrefixSelectionPopup(); - } - - private void AddUpdateProcessText_Click(object sender, RoutedEventArgs e) - { - DisplayPromptPrefixEditorPopup(VM.ChatSession.Process); - } - - private void ClearProcessText_Click(object sender, RoutedEventArgs e) - { - VM.ChatSession.Process = new Process(); - } - - #endregion - - #region Private support methods - - private void DisplayPromptPrefixEditorPopup(T promptPrefix) where T : PromptPrefix, new() - { - var view = - _serviceProvider.GetRequiredService>(); - var dataContext = - view.DataContext as PromptPrefixEditorViewModel; - dataContext.PromptPrefixToEdit = promptPrefix; - view.Owner = this; - - view.ShowDialog(); - } - - private void DisplayPromptPrefixSelectionPopup() where T : PromptPrefix, new() - { - var view = - _serviceProvider.GetRequiredService>(); - view.Owner = this; - - view.ShowDialog(); - - // Handle the result of the dialog. - var promptPrefixSelectionViewModel = - view.DataContext as PromptPrefixSelectionViewModel; - - if(VM is null || - promptPrefixSelectionViewModel is null || - promptPrefixSelectionViewModel.SelectedPromptPrefix is null) - { - return; - } - - if(promptPrefixSelectionViewModel.SelectedPromptPrefix is Persona persona) - { - VM.ChatSession.Persona = persona; - } - else if(promptPrefixSelectionViewModel.SelectedPromptPrefix is Purpose purpose) - { - VM.ChatSession.Purpose = purpose; - } - else if(promptPrefixSelectionViewModel.SelectedPromptPrefix is Persuasion persuasion) - { - VM.ChatSession.Persuasion = persuasion; - } - else if(promptPrefixSelectionViewModel.SelectedPromptPrefix is Process process) - { - VM.ChatSession.Process = process; - } - } - - #endregion -} \ No newline at end of file diff --git a/Legba.WPF/Windows/GenericPromptPrefixSelectionViewModel.cs b/Legba.WPF/Windows/GenericPromptPrefixSelectionViewModel.cs deleted file mode 100644 index ff4e7d5..0000000 --- a/Legba.WPF/Windows/GenericPromptPrefixSelectionViewModel.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Legba.Engine.Models; - -namespace Legba.WPF.Windows -{ - internal class GenericPromptPrefixSelectionViewModel where T : PromptPrefix, new() - { - } -} \ No newline at end of file diff --git a/Legba.sln b/Legba.sln index bc625b5..00a3551 100644 --- a/Legba.sln +++ b/Legba.sln @@ -1,49 +1,50 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 -VisualStudioVersion = 17.8.34330.188 +VisualStudioVersion = 17.13.35931.197 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Legba.WPF", "Legba.WPF\Legba.WPF.csproj", "{601EDCF5-AEC7-4831-972D-58AE177694E8}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Legba.Engine", "Legba.Engine\Legba.Engine.csproj", "{E6E5A159-EB7F-4690-AFC6-E86E928B97A3}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Legba", "Legba\Legba.csproj", "{0A8C74DE-3EFD-43D4-817E-945774C90D40}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Files", "Solution Files", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}" ProjectSection(SolutionItems) = preProject CONTRIBUTORS.md = CONTRIBUTORS.md LICENSE.txt = LICENSE.txt README.md = README.md + RELEASE_NOTES.md = RELEASE_NOTES.md EndProjectSection EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".github", ".github", "{C2FC1A78-0FAC-46C1-85A5-EB1D42925C59}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".github", ".github", "{607B2C6F-548A-471F-A44A-286A716F190E}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{05E357AB-B37A-4182-ADDC-14A1391A8FF6}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{E75C9E81-F419-4D25-BA6A-092641F8E4CA}" ProjectSection(SolutionItems) = preProject .github\workflows\ci.yml = .github\workflows\ci.yml EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Legba.Engine", "Legba.Engine\Legba.Engine.csproj", "{456264EF-3C6B-4C1B-8ED5-DCBD74D2E90A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {601EDCF5-AEC7-4831-972D-58AE177694E8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {601EDCF5-AEC7-4831-972D-58AE177694E8}.Debug|Any CPU.Build.0 = Debug|Any CPU - {601EDCF5-AEC7-4831-972D-58AE177694E8}.Release|Any CPU.ActiveCfg = Release|Any CPU - {601EDCF5-AEC7-4831-972D-58AE177694E8}.Release|Any CPU.Build.0 = Release|Any CPU - {E6E5A159-EB7F-4690-AFC6-E86E928B97A3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {E6E5A159-EB7F-4690-AFC6-E86E928B97A3}.Debug|Any CPU.Build.0 = Debug|Any CPU - {E6E5A159-EB7F-4690-AFC6-E86E928B97A3}.Release|Any CPU.ActiveCfg = Release|Any CPU - {E6E5A159-EB7F-4690-AFC6-E86E928B97A3}.Release|Any CPU.Build.0 = Release|Any CPU + {0A8C74DE-3EFD-43D4-817E-945774C90D40}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0A8C74DE-3EFD-43D4-817E-945774C90D40}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0A8C74DE-3EFD-43D4-817E-945774C90D40}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0A8C74DE-3EFD-43D4-817E-945774C90D40}.Release|Any CPU.Build.0 = Release|Any CPU + {456264EF-3C6B-4C1B-8ED5-DCBD74D2E90A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {456264EF-3C6B-4C1B-8ED5-DCBD74D2E90A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {456264EF-3C6B-4C1B-8ED5-DCBD74D2E90A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {456264EF-3C6B-4C1B-8ED5-DCBD74D2E90A}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution - {C2FC1A78-0FAC-46C1-85A5-EB1D42925C59} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} - {05E357AB-B37A-4182-ADDC-14A1391A8FF6} = {C2FC1A78-0FAC-46C1-85A5-EB1D42925C59} + {607B2C6F-548A-471F-A44A-286A716F190E} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {E75C9E81-F419-4D25-BA6A-092641F8E4CA} = {607B2C6F-548A-471F-A44A-286A716F190E} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {B180EB06-0C0F-47C5-8708-BD88E4C9E7CB} + SolutionGuid = {90DDCE93-D53E-4B90-BE29-66A038CC2B88} EndGlobalSection EndGlobal diff --git a/Legba.WPF/App.xaml b/Legba/App.xaml similarity index 64% rename from Legba.WPF/App.xaml rename to Legba/App.xaml index 05d395b..312614c 100644 --- a/Legba.WPF/App.xaml +++ b/Legba/App.xaml @@ -1,22 +1,15 @@ - - + xmlns:local="clr-namespace:Legba" + xmlns:customConverters="clr-namespace:Legba.CustomConverters"> + - - - - - - - - + @@ -26,7 +19,7 @@ - + + - - - \ No newline at end of file + + + + + + + + + + + + diff --git a/Legba/App.xaml.cs b/Legba/App.xaml.cs new file mode 100644 index 0000000..ebd2a56 --- /dev/null +++ b/Legba/App.xaml.cs @@ -0,0 +1,71 @@ +using Legba.Engine.Models; +using Legba.Engine.Models.OpenAi; +using Legba.Engine.Services; +using Legba.Engine.ViewModels; +using Legba.Windows; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using System.IO; +using System.Windows; + +namespace Legba; + +public partial class App : System.Windows.Application +{ + private readonly IServiceProvider _serviceProvider; + + public App() + { + // Setup the application data directory for the app + string appDataPath = + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Legba"); + Directory.CreateDirectory(appDataPath); + + // Setup the services for the app + IServiceCollection services = new ServiceCollection(); + + // Load settings from user secrets +#if DEBUG + var builder = new ConfigurationBuilder() + .AddUserSecrets(); +#else + // Load settings from appsettings.json + var builder = + new ConfigurationBuilder() + .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true); +#endif + var config = builder.Build(); + var settings = config.Get() ?? new Settings(); + + // Register the Settings object for injection + services.AddSingleton(settings); + + // Register Views for injection + services.AddTransient(); + services.AddTransient(); + + // Add services for injection + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(provider => new PromptRepository(Path.Combine(appDataPath, "Legba.db"))); + + // Register transient classes for injection + services.AddTransient(); + services.AddTransient>(); + services.AddTransient>(); + services.AddTransient>(); + services.AddTransient>(); + + // Registers IHttpClientFactory + services.AddHttpClient(); + + _serviceProvider = services.BuildServiceProvider(); + } + + protected override void OnStartup(StartupEventArgs e) + { + base.OnStartup(e); + + _serviceProvider.GetRequiredService().Show(); + } +} diff --git a/Legba.WPF/AssemblyInfo.cs b/Legba/AssemblyInfo.cs similarity index 100% rename from Legba.WPF/AssemblyInfo.cs rename to Legba/AssemblyInfo.cs diff --git a/Legba/CustomConverters/BooleanInverterConverter.cs b/Legba/CustomConverters/BooleanInverterConverter.cs new file mode 100644 index 0000000..f28a5fd --- /dev/null +++ b/Legba/CustomConverters/BooleanInverterConverter.cs @@ -0,0 +1,25 @@ +using System.Globalization; +using System.Windows.Data; + +namespace Legba.CustomConverters; + +public class BooleanInverterConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is bool booleanValue) + { + return !booleanValue; // Invert the boolean value + } + return value; // Return the original value if it's not a boolean + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is bool booleanValue) + { + return !booleanValue; // Invert back the boolean value + } + return value; // Return the original value if it's not a boolean + } +} diff --git a/Legba.WPF/CustomConverters/NotNullToVisibilityConverter.cs b/Legba/CustomConverters/NotNullToVisibilityConverter.cs similarity index 95% rename from Legba.WPF/CustomConverters/NotNullToVisibilityConverter.cs rename to Legba/CustomConverters/NotNullToVisibilityConverter.cs index f5ed5d9..0d80761 100644 --- a/Legba.WPF/CustomConverters/NotNullToVisibilityConverter.cs +++ b/Legba/CustomConverters/NotNullToVisibilityConverter.cs @@ -2,7 +2,7 @@ using System.Windows; using System.Windows.Data; -namespace Legba.WPF.CustomConverters; +namespace Legba.CustomConverters; public class NotNullToVisibilityConverter : IValueConverter { diff --git a/Legba.WPF/CustomConverters/NullToVisibilityConverter.cs b/Legba/CustomConverters/NullToVisibilityConverter.cs similarity index 95% rename from Legba.WPF/CustomConverters/NullToVisibilityConverter.cs rename to Legba/CustomConverters/NullToVisibilityConverter.cs index 2a9d75a..1b06fb9 100644 --- a/Legba.WPF/CustomConverters/NullToVisibilityConverter.cs +++ b/Legba/CustomConverters/NullToVisibilityConverter.cs @@ -2,7 +2,7 @@ using System.Windows; using System.Windows.Data; -namespace Legba.WPF.CustomConverters; +namespace Legba.CustomConverters; public class NullToVisibilityConverter : IValueConverter { diff --git a/Legba.WPF/CustomConverters/RelativeWidthConverter.cs b/Legba/CustomConverters/RelativeWidthConverter.cs similarity index 94% rename from Legba.WPF/CustomConverters/RelativeWidthConverter.cs rename to Legba/CustomConverters/RelativeWidthConverter.cs index 2182529..8fd3f99 100644 --- a/Legba.WPF/CustomConverters/RelativeWidthConverter.cs +++ b/Legba/CustomConverters/RelativeWidthConverter.cs @@ -1,7 +1,7 @@ using System.Globalization; using System.Windows.Data; -namespace Legba.WPF.CustomConverters; +namespace Legba.CustomConverters; public class RelativeWidthConverter : IMultiValueConverter { diff --git a/Legba.WPF/CustomConverters/StringNotEmptyToBooleanConverter.cs b/Legba/CustomConverters/StringNotEmptyToBooleanConverter.cs similarity index 93% rename from Legba.WPF/CustomConverters/StringNotEmptyToBooleanConverter.cs rename to Legba/CustomConverters/StringNotEmptyToBooleanConverter.cs index 6bc9b59..636d1ae 100644 --- a/Legba.WPF/CustomConverters/StringNotEmptyToBooleanConverter.cs +++ b/Legba/CustomConverters/StringNotEmptyToBooleanConverter.cs @@ -1,7 +1,7 @@ using System.Globalization; using System.Windows.Data; -namespace Legba.WPF.CustomConverters; +namespace Legba.CustomConverters; public class StringNotEmptyToBooleanConverter : IValueConverter { diff --git a/Legba.WPF/Images/ClearIcon.png b/Legba/Images/ClearIcon.png similarity index 100% rename from Legba.WPF/Images/ClearIcon.png rename to Legba/Images/ClearIcon.png diff --git a/Legba.WPF/Images/LegbaIcon.ico b/Legba/Images/LegbaIcon.ico similarity index 98% rename from Legba.WPF/Images/LegbaIcon.ico rename to Legba/Images/LegbaIcon.ico index 6d9b4f1..28c67cc 100644 Binary files a/Legba.WPF/Images/LegbaIcon.ico and b/Legba/Images/LegbaIcon.ico differ diff --git a/Legba.WPF/Images/LegbaLogo.png b/Legba/Images/LegbaLogo.png similarity index 100% rename from Legba.WPF/Images/LegbaLogo.png rename to Legba/Images/LegbaLogo.png diff --git a/Legba/Images/LegbaLogoFilled_Transparent.png b/Legba/Images/LegbaLogoFilled_Transparent.png new file mode 100644 index 0000000..0b4fe23 Binary files /dev/null and b/Legba/Images/LegbaLogoFilled_Transparent.png differ diff --git a/Legba/Images/LegbaLogo_Transparent.png b/Legba/Images/LegbaLogo_Transparent.png new file mode 100644 index 0000000..f939ac4 Binary files /dev/null and b/Legba/Images/LegbaLogo_Transparent.png differ diff --git a/Legba.WPF/Images/MagnifyingGlass.png b/Legba/Images/MagnifyingGlass.png similarity index 100% rename from Legba.WPF/Images/MagnifyingGlass.png rename to Legba/Images/MagnifyingGlass.png diff --git a/Legba.WPF/Images/PlusCircle.png b/Legba/Images/PlusCircle.png similarity index 100% rename from Legba.WPF/Images/PlusCircle.png rename to Legba/Images/PlusCircle.png diff --git a/Legba.WPF/Legba.WPF.csproj b/Legba/Legba.csproj similarity index 69% rename from Legba.WPF/Legba.WPF.csproj rename to Legba/Legba.csproj index d180cca..e376f98 100644 --- a/Legba.WPF/Legba.WPF.csproj +++ b/Legba/Legba.csproj @@ -6,10 +6,10 @@ enable enable true - 3be61677-fcf9-4588-bc35-5383dcd0d62e + True Images\LegbaIcon.ico - Legba - 1.2.0.0 + 2.0.0.0 + f63a3566-54ef-4629-9a95-4254f119e9a0 @@ -24,28 +24,23 @@ - - - + + + + + + - - - Always - - - Always - - Never - + Always @@ -54,4 +49,10 @@ + + + Always + + + diff --git a/Legba/MainWindow.xaml b/Legba/MainWindow.xaml new file mode 100644 index 0000000..d04be1e --- /dev/null +++ b/Legba/MainWindow.xaml @@ -0,0 +1,440 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +