diff --git a/.gitignore b/.gitignore index 1bfc3db..c92abfc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,36 +1,146 @@ -# ---> Go -# If you prefer the allow list template instead of the deny list, see community template: -# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore -# -# Binaries for programs and plugins -*.exe -*.exe~ -*.dll -*.so -*.dylib +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Original source: https://github.com/github/gitignore/blob/main/VisualStudio.gitignore -# Test binary, built with `go test -c` -*.test +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates -# Code coverage profiles and other test artifacts -*.out -coverage.* -*.coverprofile -profile.cov +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs -# Dependency directories (remove the comment below to include it) -# vendor/ +# Mono auto generated files +mono_crash.* -# Go workspace file -go.work -go.work.sum +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Iinit][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ -# env file -.env +# Visual Studio 2015/2017/2019/2022 cache/options directory +.vs/ +# Uncomment if you use Visual Studio Code +.vscode/ -# Editor/IDE -# .idea/ -# .vscode/ +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-agent.log + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.vspscc +*.vssscc +.buildlog +*.aps +*.ncb +*.opendb +*.sdf +*.cachefile +*.nosync +*.dbmdl +*.dbproj.schemaview +*.vshost.exe +*.vshost.exe.config +*.vshost.exe.manifest +*.manifest +*.spec +*.coverage +*.coveragexml +*_UnitTests.coverage +*.orderedtest +*.trx +*.sur +*.ipch +*.vcmeta +*.VC.db +*.VC.VC.opendb + +# JetBrains Resharper +_ReSharper*/ +*.[Rr]e[Ss]harper.user + +# JustMock +*.jmconfig + +# TeamCity +_TeamCity* + +# Octopus Deploy +*.nupkg + +# Cake +Tools/ +!tools/ + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if using NuGet Config to restore to a custom location +# !NuGet.Config + +# Rider +.idea/ +*.sln.iml + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db TODO /binaries/ diff --git a/App.axaml b/App.axaml new file mode 100644 index 0000000..3c7a5f4 --- /dev/null +++ b/App.axaml @@ -0,0 +1,15 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/App.axaml.cs b/App.axaml.cs new file mode 100644 index 0000000..add4615 --- /dev/null +++ b/App.axaml.cs @@ -0,0 +1,31 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Data.Core; +using Avalonia.Data.Core.Plugins; +using System.Linq; +using Avalonia.Markup.Xaml; +using Finder2eFoundryConverterCS.ViewModels; +using Finder2eFoundryConverterCS.Views; + +namespace Finder2eFoundryConverterCS; + +public partial class App : Application +{ + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + } + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + desktop.MainWindow = new MainWindow + { + DataContext = new MainWindowViewModel(), + }; + } + + base.OnFrameworkInitializationCompleted(); + } +} \ No newline at end of file diff --git a/Assets/avalonia-logo.ico b/Assets/avalonia-logo.ico new file mode 100644 index 0000000..f7da8bb Binary files /dev/null and b/Assets/avalonia-logo.ico differ diff --git a/Finder2eFoundryConverterCS.csproj b/Finder2eFoundryConverterCS.csproj new file mode 100644 index 0000000..7dbfc62 --- /dev/null +++ b/Finder2eFoundryConverterCS.csproj @@ -0,0 +1,26 @@ + + + WinExe + net10.0 + enable + app.manifest + true + + + + + + + + + + + + + + None + All + + + + diff --git a/Models/ImageItem.cs b/Models/ImageItem.cs new file mode 100644 index 0000000..d4ea071 --- /dev/null +++ b/Models/ImageItem.cs @@ -0,0 +1,10 @@ +using Avalonia.Media.Imaging; + +namespace Finder2eFoundryConverterCS.Models +{ + public class ImageItem + { + public string Path { get; set; } = string.Empty; + public Bitmap? Thumbnail { get; set; } + } +} diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..20d7bef --- /dev/null +++ b/Program.cs @@ -0,0 +1,20 @@ +using Avalonia; +using System; + +namespace Finder2eFoundryConverterCS; + +sealed class Program +{ + [STAThread] + public static void Main(string[] args) => BuildAvaloniaApp() + .StartWithClassicDesktopLifetime(args); + + public static AppBuilder BuildAvaloniaApp() + => AppBuilder.Configure() + .UsePlatformDetect() +#if DEBUG + .WithDeveloperTools() +#endif + .WithInterFont() + .LogToTrace(); +} diff --git a/Services/LlmService.cs b/Services/LlmService.cs new file mode 100644 index 0000000..0674587 --- /dev/null +++ b/Services/LlmService.cs @@ -0,0 +1,108 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json; +using System.Threading.Tasks; +using System.Text.Json.Serialization; + +namespace Finder2eFoundryConverterCS.Services +{ + public class LlmService + { + private readonly HttpClient _httpClient = new HttpClient(); + + public async Task> GetModelsAsync(string baseUrl) + { + try + { + var response = await _httpClient.GetFromJsonAsync($"{baseUrl.TrimEnd('/')}/v1/models"); + if (response?.Data == null) return new List(); + + var models = new List(); + foreach (var model in response.Data) + { + models.Add(model.Id); + } + return models; + } + catch + { + return new List { "Error: LM Studio not found" }; + } + } + + public async Task GenerateResponseAsync(string baseUrl, string model, string text, List imagePaths) + { + try + { + var contents = new List + { + new { type = "text", text = text } + }; + + foreach (var path in imagePaths) + { + if (string.IsNullOrWhiteSpace(path)) continue; + + var bytes = await System.IO.File.ReadAllBytesAsync(path); + var base64 = Convert.ToBase64String(bytes); + var extension = System.IO.Path.GetExtension(path).ToLower(); + var mimeType = extension switch + { + ".jpg" or ".jpeg" => "image/jpeg", + ".webp" => "image/webp", + _ => "image/png" + }; + + contents.Add(new + { + type = "image_url", + image_url = new { url = $"data:{mimeType};base64,{base64}" } + }); + } + + var requestBody = new + { + model = model, + messages = new[] + { + new { role = "user", content = contents } + } + }; + + var response = await _httpClient.PostAsJsonAsync($"{baseUrl.TrimEnd('/')}/v1/chat/completions", requestBody); + var body = await response.Content.ReadAsStringAsync(); + + using var doc = JsonDocument.Parse(body); + if (doc.RootElement.TryGetProperty("error", out var error)) + { + return $"Error: {error.GetProperty("message").GetString()}"; + } + + if (doc.RootElement.TryGetProperty("choices", out var choices) && choices.GetArrayLength() > 0) + { + return choices[0].GetProperty("message").GetProperty("content").GetString() ?? "No content returned"; + } + + return "No response choices returned."; + } + catch (Exception ex) + { + return $"Error: {ex.Message}"; + } + } + + private class ModelsResponse + { + [JsonPropertyName("data")] + public List? Data { get; set; } + } + + private class ModelData + { + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + } + } +} diff --git a/ViewLocator.cs b/ViewLocator.cs new file mode 100644 index 0000000..4e09cc4 --- /dev/null +++ b/ViewLocator.cs @@ -0,0 +1,34 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using Avalonia.Controls; +using Avalonia.Controls.Templates; +using Finder2eFoundryConverterCS.ViewModels; + +namespace Finder2eFoundryConverterCS; + +[RequiresUnreferencedCode( + "Default implementation of ViewLocator involves reflection which may be trimmed away.", + Url = "https://docs.avaloniaui.net/docs/concepts/view-locator")] +public class ViewLocator : IDataTemplate +{ + public Control? Build(object? param) + { + if (param is null) + return null; + + var name = param.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal); + var type = Type.GetType(name); + + if (type != null) + { + return (Control)Activator.CreateInstance(type)!; + } + + return new TextBlock { Text = "Not Found: " + name }; + } + + public bool Match(object? data) + { + return data is ViewModelBase; + } +} diff --git a/ViewModels/MainWindowViewModel.cs b/ViewModels/MainWindowViewModel.cs new file mode 100644 index 0000000..bd25de6 --- /dev/null +++ b/ViewModels/MainWindowViewModel.cs @@ -0,0 +1,145 @@ +using System; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using System.Windows.Input; +using Avalonia.Media.Imaging; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Finder2eFoundryConverterCS.Models; +using Finder2eFoundryConverterCS.Services; + +namespace Finder2eFoundryConverterCS.ViewModels +{ + public partial class MainWindowViewModel : ViewModelBase + { + private readonly LlmService _llmService = new LlmService(); + + [ObservableProperty] + private string _lmStudioAddress = "http://localhost:1234"; + + [ObservableProperty] + private bool _isAddressVisible = false; + + [ObservableProperty] + private string _selectedProvider = "LM Studio"; + + public ObservableCollection Providers { get; } = new() { "LM Studio" }; + + [ObservableProperty] + private string? _selectedModel; + + public ObservableCollection Models { get; } = new(); + + [ObservableProperty] + private string _description = string.Empty; + + public ObservableCollection ImageItems { get; } = new(); + + [ObservableProperty] + private string _statusMessage = string.Empty; + + [ObservableProperty] + private bool _isErrorVisible = false; + + [ObservableProperty] + private string _errorText = string.Empty; + + public MainWindowViewModel() + { + // Load saved preferences would go here if we had a settings service + _ = StartModelRefreshLoop(); + } + + [RelayCommand] + private void ToggleAddress() + { + IsAddressVisible = !IsAddressVisible; + } + + [RelayCommand] + private async Task RefreshModels() + { + if (SelectedProvider != "LM Studio") + { + IsErrorVisible = false; + return; + } + + var models = await _llmService.GetModelsAsync(LmStudioAddress); + + Models.Clear(); + foreach (var m in models) + { + Models.Add(m); + } + + if (models.Count > 0 && !models[0].StartsWith("Error")) + { + IsErrorVisible = false; + if (SelectedModel == null || !Models.Contains(SelectedModel)) + { + SelectedModel = Models.FirstOrDefault(); + } + } + else + { + ErrorText = models.FirstOrDefault() ?? "Error connecting to LM Studio"; + IsErrorVisible = true; + IsAddressVisible = true; // Auto-show on failure + } + } + + [RelayCommand] + private async Task Generate() + { + if (string.IsNullOrEmpty(SelectedModel)) + { + StatusMessage = "Please select a model first."; + return; + } + + StatusMessage = "Generating..."; + var result = await _llmService.GenerateResponseAsync(LmStudioAddress, SelectedModel, Description, ImageItems.Select(i => i.Path).ToList()); + StatusMessage = result; + } + + [RelayCommand] + private void AddImagePath(string path) + { + if (!string.IsNullOrWhiteSpace(path) && File.Exists(path)) + { + if (ImageItems.Any(i => i.Path == path)) return; + + try + { + // Load thumbnail + using var stream = File.OpenRead(path); + var bitmap = new Bitmap(stream); + // We could resize it here for efficiency if needed, but for now let's just use it + ImageItems.Add(new ImageItem { Path = path, Thumbnail = bitmap }); + } + catch (Exception ex) + { + StatusMessage = $"Error loading image: {ex.Message}"; + } + } + } + + [RelayCommand] + private void RemoveImage(ImageItem item) + { + ImageItems.Remove(item); + } + + private async Task StartModelRefreshLoop() + { + while (true) + { + await RefreshModels(); + await Task.Delay(IsErrorVisible ? 3000 : 10000); + } + } + } +} diff --git a/ViewModels/ViewModelBase.cs b/ViewModels/ViewModelBase.cs new file mode 100644 index 0000000..63b2b7f --- /dev/null +++ b/ViewModels/ViewModelBase.cs @@ -0,0 +1,7 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace Finder2eFoundryConverterCS.ViewModels; + +public abstract class ViewModelBase : ObservableObject +{ +} diff --git a/Views/MainWindow.axaml b/Views/MainWindow.axaml new file mode 100644 index 0000000..dea2869 --- /dev/null +++ b/Views/MainWindow.axaml @@ -0,0 +1,83 @@ + + + + + + + + + +