diff --git a/LM-Kit-Maestro/App.xaml.cs b/LM-Kit-Maestro/App.xaml.cs index c40ac4d2..cbb7f64a 100644 --- a/LM-Kit-Maestro/App.xaml.cs +++ b/LM-Kit-Maestro/App.xaml.cs @@ -5,9 +5,9 @@ using LMKit.Maestro.UI; +using LMKit.Maestro.UI.Pages; using LMKit.Maestro.ViewModels; using Microsoft.AspNetCore.Components.WebView.Maui; -using LMKit.Maestro.UI.Pages; #if MACCATALYST using UIKit; using WebKit; diff --git a/LM-Kit-Maestro/AppConstants.cs b/LM-Kit-Maestro/AppConstants.cs index a01bbba9..3417f148 100644 --- a/LM-Kit-Maestro/AppConstants.cs +++ b/LM-Kit-Maestro/AppConstants.cs @@ -2,7 +2,7 @@ internal static class AppConstants { - public const string AppVersion = "0.1.5"; + public const string AppVersion = "2016.1.1"; public const string AppName = "LM-Kit Maestro"; public static string AppNameWithVersion => $"{AppName} {AppVersion}"; diff --git a/LM-Kit-Maestro/Data/MaestroDatabase.cs b/LM-Kit-Maestro/Data/MaestroDatabase.cs index 87db3737..6434bb77 100644 --- a/LM-Kit-Maestro/Data/MaestroDatabase.cs +++ b/LM-Kit-Maestro/Data/MaestroDatabase.cs @@ -1,14 +1,27 @@ -using LMKit.Maestro.Models; +using LMKit.Maestro.Models; +using LMKit.Maestro.Services; using SQLite; namespace LMKit.Maestro.Data { public sealed class MaestroDatabase : IMaestroDatabase { - public static string DatabasePath => Path.Combine(FileSystem.AppDataDirectory, "MaestroSQLite.db3"); + // Static fallback for legacy access + public static string DefaultDatabasePath => Path.Combine(FileSystem.AppDataDirectory, "MaestroSQLite.db3"); + + private readonly string _databasePath; + + public string DatabasePath => _databasePath; private SQLiteAsyncConnection? _sqlDatabase; + public MaestroDatabase(IAppSettingsService appSettingsService) + { + // Read path once at construction - changes require restart + var historyDir = appSettingsService.ChatHistoryDirectory; + _databasePath = Path.Combine(historyDir, "MaestroSQLite.db3"); + } + private async Task Init() { if (_sqlDatabase is not null) @@ -18,7 +31,14 @@ private async Task Init() try { - _sqlDatabase = new SQLiteAsyncConnection(DatabasePath, SQLiteOpenFlags.ReadWrite | SQLiteOpenFlags.Create | SQLiteOpenFlags.SharedCache); + // Ensure directory exists + var directory = Path.GetDirectoryName(_databasePath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + + _sqlDatabase = new SQLiteAsyncConnection(_databasePath, SQLiteOpenFlags.ReadWrite | SQLiteOpenFlags.Create | SQLiteOpenFlags.SharedCache); await _sqlDatabase.CreateTableAsync(); } diff --git a/LM-Kit-Maestro/LM-Kit-Maestro.csproj b/LM-Kit-Maestro/LM-Kit-Maestro.csproj index c82dab67..a2eaa16a 100644 --- a/LM-Kit-Maestro/LM-Kit-Maestro.csproj +++ b/LM-Kit-Maestro/LM-Kit-Maestro.csproj @@ -76,7 +76,6 @@ - @@ -86,7 +85,6 @@ - diff --git a/LM-Kit-Maestro/LM-Kit-Maestro.dev.csproj b/LM-Kit-Maestro/LM-Kit-Maestro.dev.csproj index 1cf5ee3e..c08e5a95 100644 --- a/LM-Kit-Maestro/LM-Kit-Maestro.dev.csproj +++ b/LM-Kit-Maestro/LM-Kit-Maestro.dev.csproj @@ -96,11 +96,10 @@ - - - - - + + + + @@ -108,7 +107,6 @@ - diff --git a/LM-Kit-Maestro/MainPage.xaml.cs b/LM-Kit-Maestro/MainPage.xaml.cs index f548b6d0..77f9fc35 100644 --- a/LM-Kit-Maestro/MainPage.xaml.cs +++ b/LM-Kit-Maestro/MainPage.xaml.cs @@ -2,8 +2,8 @@ namespace LMKit.Maestro.UI.Pages; public partial class MainPage : ContentPage { - public MainPage() - { - InitializeComponent(); - } + public MainPage() + { + InitializeComponent(); + } } \ No newline at end of file diff --git a/LM-Kit-Maestro/MauiProgram.cs b/LM-Kit-Maestro/MauiProgram.cs index 253d446c..ad25c9b7 100644 --- a/LM-Kit-Maestro/MauiProgram.cs +++ b/LM-Kit-Maestro/MauiProgram.cs @@ -1,4 +1,4 @@ -using CommunityToolkit.Maui; +using CommunityToolkit.Maui; using CommunityToolkit.Maui.Storage; using LMKit.Maestro.Data; using LMKit.Maestro.Services; @@ -74,8 +74,6 @@ private static void RegisterViewModels(this MauiAppBuilder builder) builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); - builder.Services.AddTransient(); - builder.Services.AddSingleton(); } private static void RegisterViews(this MauiAppBuilder builder) @@ -85,20 +83,33 @@ private static void RegisterViews(this MauiAppBuilder builder) private static void RegisterServices(this MauiAppBuilder builder) { + // Core services (register first as they have no dependencies) + builder.Services.AddSingleton(Preferences.Default); + builder.Services.AddSingleton(Launcher.Default); + builder.Services.AddSingleton(); + + // Settings service (depends on Preferences) builder.Services.AddSingleton(); + builder.Services.AddSingleton(sp => sp.GetRequiredService()); + + // Database (depends on IAppSettingsService) builder.Services.AddSingleton(); + + // Folder picker service (platform-specific) +#if WINDOWS + builder.Services.AddSingleton(); +#else + builder.Services.AddSingleton(); +#endif + + // Other services builder.Services.AddSingleton(); - builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); - + builder.Services.AddSingleton(); builder.Services.AddSingleton(FolderPicker.Default); - - builder.Services.AddSingleton(Launcher.Default); - builder.Services.AddSingleton(Preferences.Default); builder.Services.AddSingleton(); builder.Services.AddSingleton(); - builder.Services.AddSingleton(); } private static void OnUnhandledException(object sender, UnhandledExceptionEventArgs e) @@ -138,4 +149,4 @@ public static void ConfigureLogger(this MauiAppBuilder builder) builder.Services.AddSingleton(LogOperatorRetriever.Instance); } } -} \ No newline at end of file +} diff --git a/LM-Kit-Maestro/Models/ChatAttachment.cs b/LM-Kit-Maestro/Models/ChatAttachment.cs new file mode 100644 index 00000000..8280a357 --- /dev/null +++ b/LM-Kit-Maestro/Models/ChatAttachment.cs @@ -0,0 +1,166 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; + +namespace LMKit.Maestro.Models; + +/// +/// Represents a file attachment for chat messages (images or PDFs). +/// +public class ChatAttachment : INotifyPropertyChanged +{ + private string _fileName = string.Empty; + private string _mimeType = string.Empty; + private byte[] _content = Array.Empty(); + private string? _thumbnailBase64; + private bool _isImage; + private bool _isPdf; + + public event PropertyChangedEventHandler? PropertyChanged; + + /// + /// The original file name. + /// + public string FileName + { + get => _fileName; + set { _fileName = value; OnPropertyChanged(); } + } + + /// + /// The MIME type of the attachment (e.g., "image/png", "application/pdf"). + /// + public string MimeType + { + get => _mimeType; + set + { + _mimeType = value; + IsImage = value?.StartsWith("image/") == true; + IsPdf = value == "application/pdf"; + OnPropertyChanged(); + } + } + + /// + /// The raw file content as bytes. + /// + public byte[] Content + { + get => _content; + set { _content = value; OnPropertyChanged(); } + } + + /// + /// Base64-encoded thumbnail for display in the UI. + /// For images, this is the image itself (possibly resized). + /// For PDFs, this could be a PDF icon or first page preview. + /// + public string? ThumbnailBase64 + { + get => _thumbnailBase64; + set { _thumbnailBase64 = value; OnPropertyChanged(); } + } + + /// + /// Whether this attachment is an image. + /// + public bool IsImage + { + get => _isImage; + private set { _isImage = value; OnPropertyChanged(); } + } + + /// + /// Whether this attachment is a PDF. + /// + public bool IsPdf + { + get => _isPdf; + private set { _isPdf = value; OnPropertyChanged(); } + } + + /// + /// File size in bytes. + /// + public long FileSize => Content?.Length ?? 0; + + /// + /// Human-readable file size. + /// + public string FileSizeDisplay + { + get + { + var size = FileSize; + if (size < 1024) + { + return $"{size} B"; + } + + if (size < 1024 * 1024) + { + return $"{size / 1024.0:F1} KB"; + } + + return $"{size / (1024.0 * 1024.0):F1} MB"; + } + } + + protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + + /// + /// Creates a ChatAttachment from file bytes. + /// + public static ChatAttachment FromBytes(string fileName, string mimeType, byte[] content) + { + var attachment = new ChatAttachment + { + FileName = fileName, + MimeType = mimeType, + Content = content + }; + + // Generate thumbnail + if (attachment.IsImage) + { + // For images, use the image itself as thumbnail (base64) + attachment.ThumbnailBase64 = Convert.ToBase64String(content); + } + // For PDFs, ThumbnailBase64 remains null (PDF icon handled in UI) + + return attachment; + } + + /// + /// Gets the accepted file types for vision models. + /// + public static string AcceptedFileTypes => ".png,.jpg,.jpeg,.gif,.webp,.bmp,.tiff,.pdf"; + + /// + /// Validates if the given MIME type is supported for vision attachments. + /// + public static bool IsSupportedMimeType(string mimeType) + { + if (string.IsNullOrEmpty(mimeType)) + { + return false; + } + + var supportedTypes = new[] + { + "image/png", + "image/jpeg", + "image/jpg", + "image/gif", + "image/webp", + "image/bmp", + "image/tiff", + "application/pdf" + }; + + return supportedTypes.Contains(mimeType.ToLowerInvariant()); + } +} diff --git a/LM-Kit-Maestro/Models/ConversationLog.cs b/LM-Kit-Maestro/Models/ConversationLog.cs index d6507f6d..63ea810d 100644 --- a/LM-Kit-Maestro/Models/ConversationLog.cs +++ b/LM-Kit-Maestro/Models/ConversationLog.cs @@ -15,6 +15,8 @@ public sealed class ConversationLog public Uri? LastUsedModel { get; set; } + public bool IsStarred { get; set; } + public ConversationLog() { } diff --git a/LM-Kit-Maestro/Platforms/Windows/FolderPickerHelper.cs b/LM-Kit-Maestro/Platforms/Windows/FolderPickerHelper.cs new file mode 100644 index 00000000..1186f590 --- /dev/null +++ b/LM-Kit-Maestro/Platforms/Windows/FolderPickerHelper.cs @@ -0,0 +1,65 @@ +#if WINDOWS +using System.Runtime.InteropServices; +using Windows.Storage; +using Windows.Storage.Pickers; +using WinRT.Interop; + +namespace LMKit.Maestro.Platforms.Windows; + +public static class FolderPickerHelper +{ + public static async Task PickFolderAsync(string? initialPath = null) + { + try + { + var picker = new FolderPicker(); + picker.FileTypeFilter.Add("*"); + + // Get the window handle + var hwnd = GetActiveWindowHandle(); + InitializeWithWindow.Initialize(picker, hwnd); + + // Set suggested start location based on initial path + if (!string.IsNullOrEmpty(initialPath) && Directory.Exists(initialPath)) + { + // Try to suggest the location - this is a hint, not guaranteed + picker.SuggestedStartLocation = PickerLocationId.ComputerFolder; + + // Alternative: Use IInitializeWithWindow to set the initial folder + try + { + var folder = await StorageFolder.GetFolderFromPathAsync(initialPath); + // Unfortunately, FolderPicker doesn't have a way to set initial folder directly + // The best we can do is use SuggestedStartLocation + } + catch + { + // Ignore if folder can't be accessed + } + } + else + { + picker.SuggestedStartLocation = PickerLocationId.ComputerFolder; + } + + var result = await picker.PickSingleFolderAsync(); + return result?.Path; + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"FolderPickerHelper error: {ex.Message}"); + return null; + } + } + + private static IntPtr GetActiveWindowHandle() + { + var window = Application.Current?.Windows.FirstOrDefault(); + if (window?.Handler?.PlatformView is Microsoft.UI.Xaml.Window winUIWindow) + { + return WindowNative.GetWindowHandle(winUIWindow); + } + return IntPtr.Zero; + } +} +#endif diff --git a/LM-Kit-Maestro/Services/AppSettingsService.cs b/LM-Kit-Maestro/Services/AppSettingsService.cs index 238ed804..d9ffda86 100644 --- a/LM-Kit-Maestro/Services/AppSettingsService.cs +++ b/LM-Kit-Maestro/Services/AppSettingsService.cs @@ -1,12 +1,9 @@ -using CommunityToolkit.Mvvm.ComponentModel; -using LMKit.TextGeneration.Chat; -using System.ComponentModel; using System.Text.Json; namespace LMKit.Maestro.Services; public partial class AppSettingsService : IAppSettingsService -{ +{ protected IPreferences Settings { get; } public AppSettingsService(IPreferences settings) @@ -20,18 +17,18 @@ public Uri? LastLoadedModelUri { string? uriString = Settings.Get(nameof(LastLoadedModelUri), default(string?)); - if (!string.IsNullOrWhiteSpace(uriString)) - { - return new Uri(uriString); + if (!string.IsNullOrWhiteSpace(uriString)) + { + return new Uri(uriString); } - else - { - return null; + else + { + return null; } } set { - Settings.Set(nameof(LastLoadedModelUri), value?.ToString()); + Settings.Set(nameof(LastLoadedModelUri), value?.ToString()); } } @@ -39,13 +36,13 @@ public string ModelStorageDirectory { get { - string directory = Settings.Get(nameof(ModelStorageDirectory), LMKitDefaultSettings.DefaultModelStorageDirectory); - - if (directory != LMKit.Global.Configuration.ModelStorageDirectory) - { - LMKit.Global.Configuration.ModelStorageDirectory = directory; - } - + string directory = Settings.Get(nameof(ModelStorageDirectory), LMKitDefaultSettings.DefaultModelStorageDirectory); + + if (directory != LMKit.Global.Configuration.ModelStorageDirectory) + { + LMKit.Global.Configuration.ModelStorageDirectory = directory; + } + return directory; } set @@ -54,22 +51,34 @@ public string ModelStorageDirectory } } + public string ChatHistoryDirectory + { + get + { + return Settings.Get(nameof(ChatHistoryDirectory), LMKitDefaultSettings.DefaultChatHistoryDirectory); + } + set + { + Settings.Set(nameof(ChatHistoryDirectory), value); + } + } + public bool EnableLowPerformanceModels { get - { + { return Settings.Get(nameof(EnableLowPerformanceModels), LMKitDefaultSettings.DefaultEnableLowPerformanceModels); } set { Settings.Set(nameof(EnableLowPerformanceModels), value); } - } - + } + public bool EnablePredefinedModels { get - { + { return Settings.Get(nameof(EnablePredefinedModels), LMKitDefaultSettings.DefaultEnablePredefinedModels); } set @@ -174,7 +183,7 @@ public RandomSamplingConfig RandomSamplingConfig if (!string.IsNullOrEmpty(json)) { - Settings.Set(nameof(RandomSamplingConfig), json); + Settings.Set(nameof(RandomSamplingConfig), json); } } } @@ -215,7 +224,7 @@ public TopNSigmaSamplingConfig TopNSigmaSamplingConfig if (!string.IsNullOrEmpty(json)) { - Settings.Set(nameof(TopNSigmaSamplingConfig), json); + Settings.Set(nameof(TopNSigmaSamplingConfig), json); } } } @@ -256,8 +265,8 @@ public Mirostat2SamplingConfig Mirostat2SamplingConfig if (!string.IsNullOrEmpty(json)) { - Settings.Set(nameof(Mirostat2SamplingConfig), json); + Settings.Set(nameof(Mirostat2SamplingConfig), json); } } - } -} \ No newline at end of file + } +} diff --git a/LM-Kit-Maestro/Services/DefaultFolderPickerService.cs b/LM-Kit-Maestro/Services/DefaultFolderPickerService.cs new file mode 100644 index 00000000..bc5b7e52 --- /dev/null +++ b/LM-Kit-Maestro/Services/DefaultFolderPickerService.cs @@ -0,0 +1,19 @@ +using CommunityToolkit.Maui.Storage; + +namespace LMKit.Maestro.Services; + +public class DefaultFolderPickerService : IFolderPickerService +{ + public async Task PickFolderAsync(string? initialPath = null, string? title = null) + { + try + { + var result = await FolderPicker.Default.PickAsync(initialPath ?? string.Empty, CancellationToken.None); + return result.IsSuccessful ? result.Folder?.Path : null; + } + catch + { + return null; + } + } +} diff --git a/LM-Kit-Maestro/Services/Interfaces/IAppSettingsService.cs b/LM-Kit-Maestro/Services/Interfaces/IAppSettingsService.cs index e1347ff1..8830d041 100644 --- a/LM-Kit-Maestro/Services/Interfaces/IAppSettingsService.cs +++ b/LM-Kit-Maestro/Services/Interfaces/IAppSettingsService.cs @@ -1,11 +1,10 @@ -using System.ComponentModel; - namespace LMKit.Maestro.Services; public interface IAppSettingsService { Uri? LastLoadedModelUri { get; set; } string ModelStorageDirectory { get; set; } + string ChatHistoryDirectory { get; set; } string SystemPrompt { get; set; } int MaximumCompletionTokens { get; set; } int RequestTimeout { get; set; } diff --git a/LM-Kit-Maestro/Services/Interfaces/IFolderPickerService.cs b/LM-Kit-Maestro/Services/Interfaces/IFolderPickerService.cs new file mode 100644 index 00000000..f81456c1 --- /dev/null +++ b/LM-Kit-Maestro/Services/Interfaces/IFolderPickerService.cs @@ -0,0 +1,6 @@ +namespace LMKit.Maestro.Services; + +public interface IFolderPickerService +{ + Task PickFolderAsync(string? initialPath = null, string? title = null); +} diff --git a/LM-Kit-Maestro/Services/LLMFileManager.FileDownloader.cs b/LM-Kit-Maestro/Services/LLMFileManager.FileDownloader.cs index fd9a8f8b..baa15e67 100644 --- a/LM-Kit-Maestro/Services/LLMFileManager.FileDownloader.cs +++ b/LM-Kit-Maestro/Services/LLMFileManager.FileDownloader.cs @@ -1,4 +1,4 @@ -using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.ComponentModel; namespace LMKit.Maestro.Services; @@ -6,133 +6,8 @@ public partial class LLMFileManager : ObservableObject { internal sealed class FileDownloader : IDisposable { -#if BETA_DOWNLOAD - private readonly HttpClient _httpClient; - private readonly Uri _downloadUrl; - private readonly string _filePath; - private readonly ManualResetEvent _manualResetEvent; - private readonly CancellationTokenSource _cancellationTokenSource; - - private bool _paused; - - public delegate void DownloadProgressDelegate(Uri downloadUrl, long? totalDownloadSize, long totalBytesRead); - public delegate void DownloadStateChangeDelegate(Uri downloadUrl); - public delegate void DownloadErrorDelegate(Uri downloadUrl, Exception exception); - - public DownloadStateChangeDelegate? DownloadStartedEventHandler; - public DownloadStateChangeDelegate? DownloadPausedEventHandler; - public DownloadStateChangeDelegate? DownloadCompletedEventHandler; - public DownloadProgressDelegate? DownloadProgressedEventHandler; - public DownloadErrorDelegate? ErrorEventHandler; - - public FileDownloader(HttpClient client, Uri downloadUrl, string filePath) - { - _httpClient = client; - _downloadUrl = downloadUrl; - _filePath = filePath; - _manualResetEvent = new ManualResetEvent(false); - _cancellationTokenSource = new CancellationTokenSource(); - } - - public void Start() - { - Task.Run(DownloadFile); - } - - public void Pause() - { - if (_manualResetEvent.Reset()) - { - _paused = true; - } - } - - public void Resume() - { - if (_manualResetEvent.Set()) - { - _paused = false; - } - } - - public void Stop() - { - if (_paused) - { - Resume(); - } - - _cancellationTokenSource.Cancel(); - } - - private async Task DownloadFile() - { - try - { - const int downloadChunkSize = 8192; - - string destinationFolder = Path.GetDirectoryName(_filePath)!; - - if (!Directory.Exists(destinationFolder)) - { - Directory.CreateDirectory(destinationFolder); - } - - var downloadFilePath = _filePath + ".download"; - using HttpResponseMessage httpResponse = await _httpClient.GetAsync(_downloadUrl, HttpCompletionOption.ResponseHeadersRead); - using FileStream destination = new(downloadFilePath, FileMode.Create, FileAccess.Write, FileShare.None, 8192, true); - _ = httpResponse.EnsureSuccessStatusCode(); - long? totalBytes = httpResponse.Content.Headers.ContentLength; - - using Stream contentStream = await httpResponse.Content.ReadAsStreamAsync(); - - long totalBytesRead = 0L; - byte[] buffer = new byte[downloadChunkSize]; - - while (true) - { - if (_paused) - { - _manualResetEvent.WaitOne(); - } - - _cancellationTokenSource.Token.ThrowIfCancellationRequested(); - - int bytesRead = await contentStream.ReadAsync(buffer, 0, buffer.Length); - - if (bytesRead == 0) - { - break; - } - - await destination.WriteAsync(buffer, 0, bytesRead); - - totalBytesRead += bytesRead; - - DownloadProgressedEventHandler?.Invoke(_downloadUrl, totalBytes, totalBytesRead); - } - - destination.Dispose(); - - if (File.Exists(_filePath)) - { - File.Delete(_filePath); - } - - File.Move(downloadFilePath, _filePath); - - DownloadCompletedEventHandler?.Invoke(_downloadUrl); - } - catch (Exception exception) - { - ErrorEventHandler?.Invoke(_downloadUrl, exception); - } - } -#endif - public void Dispose() { - //_cancellationTokenSource?.Dispose(); } } } diff --git a/LM-Kit-Maestro/Services/LLMFileManager.FileSystemEntryRecorder.cs b/LM-Kit-Maestro/Services/LLMFileManager.FileSystemEntryRecorder.cs index 24808e26..7d2a6dca 100644 --- a/LM-Kit-Maestro/Services/LLMFileManager.FileSystemEntryRecorder.cs +++ b/LM-Kit-Maestro/Services/LLMFileManager.FileSystemEntryRecorder.cs @@ -1,4 +1,4 @@ -using LMKit.Maestro.Helpers; +using LMKit.Maestro.Helpers; using System.Diagnostics; namespace LMKit.Maestro.Services; @@ -8,32 +8,32 @@ public partial class LLMFileManager private sealed partial class FileSystemEntryRecorder { private DirectoryRecord? _rootDirectoryRecord; - private Uri _rootDirectoryUri; - - public Uri RootDirectory - { - get => _rootDirectoryUri; - set - { - if (value == _rootDirectoryUri) - { - return; - } - - if (_rootDirectoryRecord != null) - { - Clear(); - } - - _rootDirectoryUri = value; + private Uri _rootDirectoryUri = null!; + + public Uri RootDirectory + { + get => _rootDirectoryUri; + set + { + if (value == _rootDirectoryUri) + { + return; + } + + if (_rootDirectoryRecord != null) + { + Clear(); + } + + _rootDirectoryUri = value; _rootDirectoryRecord = new DirectoryRecord(value.LocalPath, null); - - } + + } } - public FileSystemEntryRecorder(Uri rootDirectoryUri) - { - RootDirectory = rootDirectoryUri; + public FileSystemEntryRecorder(Uri rootDirectoryUri) + { + RootDirectory = rootDirectoryUri; } public void Clear() @@ -44,8 +44,14 @@ public void Clear() public FileRecord? RecordFile(Uri fileUri) { string fileBaseName = FileHelpers.GetFileBaseName(fileUri); - DirectoryRecord? directParentDirectory = TryGetDirectParentDirectory(fileUri, true)!; - FileRecord? file = directParentDirectory != null ? directParentDirectory.TryGetChildFile(fileBaseName) : null; + DirectoryRecord? directParentDirectory = TryGetDirectParentDirectory(fileUri, true); + + if (directParentDirectory == null) + { + return null; + } + + FileRecord? file = directParentDirectory.TryGetChildFile(fileBaseName); if (file != null) { @@ -76,9 +82,6 @@ public void Clear() public FileSystemEntryRecord? TryGetExistingEntry(Uri fileUri) { - int depth = fileUri.Segments.Length - _rootDirectoryUri!.Segments.Length; - DirectoryRecord current = _rootDirectoryRecord!; - DirectoryRecord? parentDirectory = TryGetDirectParentDirectory(fileUri); if (parentDirectory != null) @@ -174,4 +177,4 @@ private void DumpRecords(DirectoryRecord directory, int level = 0) #endif } -} \ No newline at end of file +} diff --git a/LM-Kit-Maestro/Services/LLMFileManager.cs b/LM-Kit-Maestro/Services/LLMFileManager.cs index 9d054943..d2feaa9e 100644 --- a/LM-Kit-Maestro/Services/LLMFileManager.cs +++ b/LM-Kit-Maestro/Services/LLMFileManager.cs @@ -1,824 +1,675 @@ -using CommunityToolkit.Mvvm.ComponentModel; -using LMKit.Maestro.Helpers; -using LMKit.Maestro.UI; -using LMKit.Model; -using System.Collections.ObjectModel; -using System.Collections.Specialized; -using System.ComponentModel; - -namespace LMKit.Maestro.Services; - -/// -/// This service is intended to be used as a singleton via Dependency Injection. -/// Please register with services.AddSingleton<LLMFileManager>(). -/// -public partial class LLMFileManager : ObservableObject, ILLMFileManager -{ -#if DEBUG - private static int InstanceCount = 0; -#endif - - private readonly object _modelsLock = new(); - private readonly FileSystemEntryRecorder _fileSystemEntryRecorder; - private readonly HttpClient _httpClient; - - private readonly Dictionary _fileDownloads = []; - - private delegate bool ModelDownloadingProgressCallback(string path, long? contentLength, long bytesRead); - public event NotifyCollectionChangedEventHandler? ModelsCollectionChanged; - -#if WINDOWS - private FileSystemWatcher? _fileSystemWatcher; -#endif - - private CancellationTokenSource? _cancellationTokenSource; - private Task? _collectModelFilesTask; - - public ReadOnlyObservableCollection Models { get; } - public ReadOnlyObservableCollection UnsortedModels { get; } - - private ObservableCollection _models { get; } = []; - private ObservableCollection _unsortedModels { get; } = []; - - [ObservableProperty] - private long _totalModelSize; - - [ObservableProperty] - private int _localModelsCount; - - [ObservableProperty] - private bool _fileCollectingInProgress; - - public LLMFileManagerConfig Config { get; } = new LLMFileManagerConfig(); - - public event EventHandler? FileCollectingCompleted; -#if BETA_DOWNLOAD_MODELS - public event EventHandler? ModelDownloadingProgressed; - public event EventHandler? ModelDownloadingCompleted; -#endif - - public LLMFileManager(IAppSettingsService appSettingsService, HttpClient httpClient) - { -#if DEBUG - if (InstanceCount > 0) - { - throw new InvalidProgramException("Invalid operation: Only one instance of this class should be created, and it must be instantiated through dependency injection."); - } - - InstanceCount++; -#endif - Models = new ReadOnlyObservableCollection(_models); - UnsortedModels = new ReadOnlyObservableCollection(_unsortedModels); - _httpClient = httpClient; - _models.CollectionChanged += OnModelCollectionChanged; - _unsortedModels.CollectionChanged += OnUnsortedModelCollectionChanged; - Config.ModelsDirectory = appSettingsService.ModelStorageDirectory; - Config.PropertyChanged += OnConfigPropertyChanged; - _fileSystemEntryRecorder = new FileSystemEntryRecorder(new Uri(Config.ModelsDirectory)); - - OnModelStorageDirectorySet(); - } - - -#if WINDOWS - //todo: move code to Windows specific service and implement one for Mac as well. - private void InitializeFileSystemWatcher(string directoryPath) - { - _fileSystemWatcher = new FileSystemWatcher - { - Path = Config.ModelsDirectory, - IncludeSubdirectories = true, - EnableRaisingEvents = true - }; - - _fileSystemWatcher.Changed += OnFileChanged; - _fileSystemWatcher.Deleted += OnFileDeleted; - _fileSystemWatcher.Renamed += OnFileRenamed; - _fileSystemWatcher.Created += OnFileCreated; - } - - private void DisposeFileSystemWatcher() - { - _fileSystemWatcher!.EnableRaisingEvents = false; - - _fileSystemWatcher.Changed -= OnFileChanged; - _fileSystemWatcher.Deleted -= OnFileDeleted; - _fileSystemWatcher.Renamed -= OnFileRenamed; - _fileSystemWatcher.Created -= OnFileCreated; - - _fileSystemWatcher.Dispose(); - _fileSystemWatcher = null; - } -#endif - -#if BETA_DOWNLOAD_MODELS - public void DownloadModel(ModelCard modelCard) - { - var filePath = Path.Combine(ModelStorageDirectory, modelCard.Publisher, modelCard.Repository, modelCard.FileName); - - if (!_fileDownloads.ContainsKey(modelCard.Metadata.DownloadUrl!)) - { - FileDownloader fileDownloader = new FileDownloader(_httpClient, modelCard.Metadata.DownloadUrl!, filePath); - - fileDownloader.ErrorEventHandler += OnDownloadExceptionThrown; - fileDownloader.DownloadProgressedEventHandler += OnDownloadProgressed; - fileDownloader.DownloadCompletedEventHandler += OnDownloadCompleted; - - if (_fileDownloads.TryAdd(modelCard.Metadata.DownloadUrl!, fileDownloader)) - { - fileDownloader.Start(); - } - } - } - - private void ReleaseFileDownloader(Uri downloadUrl) - { - if (_fileDownloads.ContainsKey(downloadUrl) && _fileDownloads.Remove(downloadUrl, out FileDownloader? fileDownloader)) - { - fileDownloader.ErrorEventHandler -= OnDownloadExceptionThrown; - fileDownloader.DownloadProgressedEventHandler -= OnDownloadProgressed; - fileDownloader.DownloadCompletedEventHandler -= OnDownloadCompleted; - - fileDownloader.Dispose(); - } - } - - private void OnDownloadExceptionThrown(Uri downloadUrl, Exception exception) - { - ReleaseFileDownloader(downloadUrl); - - if (exception is OperationCanceledException) - { - ModelDownloadingCompleted?.Invoke(this, new DownloadOperationStateChangedEventArgs(downloadUrl, DownloadOperationStateChangedEventArgs.DownloadOperationStateChangedType.Canceled)); - } - else - { - ModelDownloadingCompleted?.Invoke(this, new DownloadOperationStateChangedEventArgs(downloadUrl, exception)); - } - } - - private void OnDownloadProgressed(Uri downloadUrl, long? totalDownloadSize, long byteRead) - { - double progress = 0; - - if (totalDownloadSize.HasValue) - { - progress = (double)byteRead / totalDownloadSize.Value; - } - - ModelDownloadingProgressed?.Invoke(this, new DownloadOperationStateChangedEventArgs(downloadUrl, byteRead, totalDownloadSize, progress)); - } - - private void OnDownloadCompleted(Uri downloadUrl) - { - ReleaseFileDownloader(downloadUrl); - - ModelDownloadingCompleted?.Invoke(this, new DownloadOperationStateChangedEventArgs(downloadUrl, DownloadOperationStateChangedEventArgs.DownloadOperationStateChangedType.Completed)); - } - - public void CancelModelDownload(ModelCard modelCard) - { - if (_fileDownloads.TryGetValue(modelCard.Metadata.DownloadUrl!, out FileDownloader? fileDownloader)) - { - fileDownloader!.Stop(); - } - } - - public void PauseModelDownload(ModelCard modelCard) - { - if (_fileDownloads.TryGetValue(modelCard.Metadata.DownloadUrl!, out FileDownloader? fileDownloader)) - { - fileDownloader!.Pause(); - } - } - - public void ResumeModelDownload(ModelCard modelCard) - { - if (_fileDownloads.TryGetValue(modelCard.Metadata.DownloadUrl!, out FileDownloader? fileDownloader)) - { - fileDownloader!.Resume(); - } - } -#endif - - public void OnModelDownloaded(ModelCard modelCard) - { - TotalModelSize += modelCard.FileSize; - LocalModelsCount++; - } - - public void DeleteModel(ModelCard modelCard) - { - if (modelCard.IsLocallyAvailable) - { - File.Delete(modelCard.LocalPath); - LocalModelsCount--; - TotalModelSize -= modelCard.FileSize; - -#if !WINDOWS - if (!modelCard.IsPredefined) - { - if (_unsortedModels.Contains(modelCard)) - { - _unsortedModels.Remove(modelCard); - } - - if (_models.Contains(modelCard)) - { - _models.Remove(modelCard); - } - } -#endif - } - else - { - throw new Exception(Locales.ModelFileNotAvailableLocally); - } - } - - private async Task CollectCustomModelsAsync() - { - FileCollectingInProgress = true; - - if (FileCollectingInProgress && _cancellationTokenSource != null) - { - await CancelOngoingFileCollecting(); - } - - _cancellationTokenSource = new CancellationTokenSource(); - - Exception? exception = null; - - bool cancelled = false; - - try - { - await (_collectModelFilesTask = Task.Run(() => CollectCustomModels())); - } - catch (OperationCanceledException) - { - cancelled = true; - } - catch (Exception ex) - { - exception = ex; - } - - if (!cancelled) - { - TerminateFileCollectingOperation(); - } - - FileCollectingCompleted?.Invoke(this, new FileCollectingCompletedEventArgs(exception == null, exception)); - } - - private async Task CancelOngoingFileCollecting() - { - try - { - _cancellationTokenSource!.Cancel(); - await _collectModelFilesTask!.ConfigureAwait(false); - } - catch - { - return; - } - } - - private void TerminateFileCollectingOperation() - { - FileCollectingInProgress = false; - _cancellationTokenSource?.Dispose(); - _cancellationTokenSource = null; - _collectModelFilesTask = null; - } - - private void UpdatePredefinedModelCards() - { - lock (_modelsLock) - { - var predefinedModels = ModelCard.GetPredefinedModelCards(dropSmallerModels: !Config.EnableLowPerformanceModels); - - if (_models.Count > 0) - { - for (int index = 0; index < _models.Count; index++) - { - if (_models[index].IsPredefined && !_models[index].IsLocallyAvailable) - { - _models.RemoveAt(index); - index--; - } - } - } - - if (Config.EnablePredefinedModels) - { - foreach (var modelCard in predefinedModels) - { - if (!string.IsNullOrWhiteSpace(modelCard.ReplacementModel) && - !modelCard.IsLocallyAvailable) - {//ignoring models marked as legacy. - continue; - } - - TryRegisterChatModel(modelCard, isSorted: true); - } - } - } - } - - private void CollectCustomModels() - { - var files = Directory.GetFileSystemEntries(Config.ModelsDirectory, "*", SearchOption.AllDirectories); - - foreach (var filePath in files) - { - lock (_modelsLock) - { - if (ContainsModel(_models, filePath)) - { - continue; - } - - if (ShouldCheckFile(filePath)) - { - HandleFile(filePath); - } - } - - _cancellationTokenSource!.Token.ThrowIfCancellationRequested(); - } - } - - private bool TryRegisterChatModel(ModelCard? modelCard, bool isSorted) - { - if (modelCard != null) - { - bool hasAnyFilteredCap = false; - - foreach (var cap in Config.FilteredCapabilities) - { - if (modelCard.Capabilities.HasFlag(cap)) - { - hasAnyFilteredCap = true; - break; - } - } - - if (!hasAnyFilteredCap) - { - return false; - } - - bool isSlowModel = Hardware.DeviceConfiguration.GetPerformanceScore(modelCard) < 0.3; - - if (!ContainsModel(_models, modelCard)) - { - if (isSlowModel && !Config.EnableLowPerformanceModels) - { - return false; - } - - _models.Add(modelCard); - - if (!isSorted) - { - _unsortedModels.Add(modelCard); - } - - return true; - } - else if (isSlowModel && !Config.EnableLowPerformanceModels) - { - _models.Remove(modelCard); - } - } - - return false; - } - - private void HandleFile(string filePath) - { - if (!TryValidateModelFile(filePath, Config.ModelsDirectory, out ModelCard? modelCard, out bool isSorted)) - { - //todo: Add feedback to indicate that a model of a supported format could not be loaded - return; - } - - TryRegisterChatModel(modelCard, isSorted); - } - - private void HandleFileRecording(Uri fileUri) - { - var fileRecord = _fileSystemEntryRecorder.RecordFile(fileUri); - - if (fileRecord != null) - { - fileRecord.FilePathChanged += OnFileRecordPathChanged; - } - } - - private void HandleFileRecordDeletion(Uri fileUri) - { - var fileRecord = _fileSystemEntryRecorder.DeleteFileRecord(fileUri!); - - if (fileRecord != null) - { - fileRecord.FilePathChanged -= OnFileRecordPathChanged; - } - } - - #region Event handlers - - private void OnModelStorageDirectorySet() - { - if (FileCollectingInProgress) - { - _cancellationTokenSource?.Cancel(); - } - - _fileSystemEntryRecorder.RootDirectory = new Uri(Config.ModelsDirectory); - - _unsortedModels.Clear(); - _models.Clear(); - - UpdatePredefinedModelCards(); - - if (Config.EnablePredefinedModels) - { - Task.Run(CollectCustomModelsAsync); - } - } - - private void OnConfigPropertyChanged(object? sender, PropertyChangedEventArgs e) - { - if (e.PropertyName == nameof(LLMFileManagerConfig.ModelsDirectory)) - { -#if WINDOWS - if (_fileSystemWatcher != null) - { - DisposeFileSystemWatcher(); - } - - InitializeFileSystemWatcher(Config.ModelsDirectory); -#endif - OnModelStorageDirectorySet(); - } - else if (e.PropertyName == nameof(LLMFileManagerConfig.EnableLowPerformanceModels)) - { - UpdatePredefinedModelCards(); - } - else if (e.PropertyName == nameof(LLMFileManagerConfig.EnablePredefinedModels)) - { - UpdatePredefinedModelCards(); - } - } - -#if WINDOWS - private void OnFileDeleted(object sender, FileSystemEventArgs e) - { - Uri fileUri = new Uri(e.FullPath); - - if (ContainsModel(_models, fileUri, out int index)) - { - if (!_models[index].IsPredefined) - { - var model = _models[index]; - _models.Remove(model); - - if (_unsortedModels.Contains(model)) - { - _unsortedModels.Remove(model); - } - } - } - } - - private void OnFileChanged(object sender, FileSystemEventArgs e) - { - if (e.Name != null) - { - bool shouldCheckFile = ShouldCheckFile(e.FullPath); - - if (shouldCheckFile) - { - bool accessGranted = WaitFileReadAccessGranted(e.FullPath); - - if (accessGranted) - { - HandleFile(e.FullPath); - } - } - } - } - - private void OnFileCreated(object sender, FileSystemEventArgs e) - { - if (ShouldCheckFile(e.FullPath) && WaitFileReadAccessGranted(e.FullPath)) - { - HandleFile(e.FullPath); - } - } - - private void OnFileRenamed(object sender, RenamedEventArgs e) - { - var entryRecord = _fileSystemEntryRecorder.TryGetExistingEntry(new Uri(e.OldFullPath)); - - if (entryRecord != null) - { - entryRecord.Rename(FileHelpers.GetFileBaseName(new Uri(e.FullPath))); - } - else - { - if (ShouldCheckFile(e.FullPath)) - { - HandleFile(e.FullPath); - } - } - - } -#endif - -#if BETA_DOWNLOAD_MODELS - private void OnModelDownloadingProgressed(string path, long? contentLength, long bytesRead) - { - double progress = 0; - - if (contentLength.HasValue) - { - double progressPercentage = Math.Round((double)bytesRead / contentLength.Value * 100, 2); - - progress = (double)bytesRead / contentLength.Value; - //Console.Write($"\rDownloading model {progressPercentage:0.00}%"); - } - else - { - //Console.Write($"\rDownloading model {bytesRead} bytes"); - } - - if (ModelDownloadingProgressed != null) - { - ModelDownloadingProgressedEventArgs eventArgs = new ModelDownloadingProgressedEventArgs(path, bytesRead, contentLength, progress); - ModelDownloadingProgressed.Invoke(this, eventArgs); - } - } -#endif - - - private void OnModelCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) - { - if (e.Action == NotifyCollectionChangedAction.Add) - { - foreach (var item in e.NewItems!) - { - var card = (ModelCard)item; - - if (card.IsLocallyAvailable) - { - TotalModelSize += card.FileSize; - LocalModelsCount++; - } - - HandleFileRecording(card.ModelUri!); - } - } - else if (e.Action == NotifyCollectionChangedAction.Remove) - { - foreach (var item in e.OldItems!) - { - var card = (ModelCard)item; - - if (card.IsLocallyAvailable) - { - TotalModelSize -= card.FileSize; - LocalModelsCount--; - } - - HandleFileRecordDeletion(card.ModelUri!); - } - } - else if (e.Action == NotifyCollectionChangedAction.Reset) - { - TotalModelSize = 0; - LocalModelsCount = 0; - } - - ModelsCollectionChanged?.Invoke(sender, e); - } - - private void OnUnsortedModelCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) - { - if (e.Action == NotifyCollectionChangedAction.Add) - { - foreach (var item in e.NewItems!) - { - HandleFileRecording(((ModelCard)item).ModelUri!); - } - } - else if (e.Action == NotifyCollectionChangedAction.Remove) - { - foreach (var item in e.OldItems!) - { - HandleFileRecordDeletion(((ModelCard)item).ModelUri!); - } - } - } - - private void OnFileRecordPathChanged(object? sender, EventArgs e) - { - var fileRecordPathChangedEventArgs = (FileSystemEntryRecorder.FileRecordPathChangedEventArgs)e; - - - if (ContainsModel(_models, fileRecordPathChangedEventArgs.OldPath, out int index) && - FileHelpers.GetModelInfoFromFileUri(fileRecordPathChangedEventArgs.NewPath, - Config.ModelsDirectory, - out string publisher, - out string repository, - out string fileName)) - { - var prevModel = _models[index]; - _models[index] = new ModelCard(fileRecordPathChangedEventArgs.NewPath) - { - Publisher = publisher, - Repository = repository, - }; - - if (_unsortedModels.Contains(prevModel)) - { - _unsortedModels[_unsortedModels.IndexOf(prevModel)] = _models[index]; - } - } - } - #endregion - - #region Static methods - - private static bool ContainsModel(IList models, ModelCard modelCard) - { - if (models.Contains(modelCard)) - { - return true; - } - - //In this scope, we are essentially searching for duplicate model files.. - foreach (var model in models) - { - /*if (model.SHA256 == modelCard.SHA256) //Loïc: commented. This is too slow. - { - return true; - }*/ - - if (model.ModelUri == modelCard.ModelUri || - (model.FileName == modelCard.FileName && model.FileSize == modelCard.FileSize)) - { - if (model.LocalPath == modelCard.LocalPath) - { - return true; - } - else if (model.SHA256 == modelCard.SHA256) - { - //todo: propagate feedback indicating that a duplicate file exists. - return true; - } - } - } - - return false; - } - - private static bool ContainsModel(IList models, string filePath) - { - foreach (var model in models) - { - if (model.LocalPath == filePath) - { - return true; - } - } - - return false; - } - - private static bool ContainsModel(IList models, Uri uri, out int index) - { - index = 0; - - foreach (var model in models) - { - if (model.ModelUri == uri) - { - return true; - } - - index++; - } - - index = -1; - - return false; - } - - private static bool TryValidateModelFile(string filePath, string modelFolderPath, out ModelCard? modelCard, out bool isSorted) - { - isSorted = false; - modelCard = null; - - if (LM.ValidateFormat(filePath)) - { - try - { - modelCard = ModelCard.CreateFromFile(filePath); - } - catch - { - return false; - } - - if (FileHelpers.GetModelInfoFromPath(filePath, modelFolderPath, - out string publisher, out string repository, out string fileName)) - { - isSorted = true; -#if BETA_DOWNLOAD_MODELS - modelCard = TryGetExistingModelInfo(fileName, repository, publisher); - if (modelCard == null) - { - modelCard = new ModelInfo(publisher, repository, fileName); - modelCard.Metadata.FileSize = FileHelpers.GetFileSize(filePath); - } - - modelCard.Metadata.FileUri = new Uri(filePath); - -#else - modelCard.Publisher = publisher; - modelCard.Repository = repository; - -#endif - } - else - { - modelCard.Publisher = "unknown publisher"; - modelCard.Repository = "unknown repository"; - } - - return true; - } - - return false; - } - -#if BETA_DOWNLOAD_MODELS - private static ModelInfo? TryGetExistingModelInfo(string fileName, string repository, string publisher) - { - foreach (var modelCard in AppConstants.AvailableModels) - { - if (string.CompareOrdinal(modelCard.FileName, fileName) == 0 && - string.CompareOrdinal(modelCard.Repository, repository) == 0 && - string.CompareOrdinal(modelCard.Publisher, publisher) == 0) - { - return modelCard; - } - } - - return null; - } -#endif - - private static bool ShouldCheckFile(string filePath) - { - bool isFileDirectory = FileHelpers.IsFileDirectory(filePath); - - if (isFileDirectory) - { - return false; - } - else - { - filePath = filePath.ToLower(); - return !filePath.EndsWith(".lmk.gguf") && - !filePath.EndsWith(".download") && - !filePath.EndsWith(".origin"); - } - } - - private static bool WaitFileReadAccessGranted(string fileName, int maxRetryCount = 3) - { - for (int retryCount = 0; retryCount < maxRetryCount; retryCount++) - { - if (!FileHelpers.IsFileLocked(fileName)) - { - return true; - } - else - { - if (retryCount + 1 < maxRetryCount) - { - Thread.Sleep(2000); - } - } - } - - return false; - } - - #endregion -} +using CommunityToolkit.Mvvm.ComponentModel; +using LMKit.Maestro.Helpers; +using LMKit.Maestro.UI; +using LMKit.Model; +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; + +namespace LMKit.Maestro.Services; + +/// +/// This service is intended to be used as a singleton via Dependency Injection. +/// Please register with services.AddSingleton<LLMFileManager>(). +/// +public partial class LLMFileManager : ObservableObject, ILLMFileManager +{ +#if DEBUG + private static int InstanceCount = 0; +#endif + + private readonly object _modelsLock = new(); + private readonly FileSystemEntryRecorder _fileSystemEntryRecorder; + private readonly HttpClient _httpClient; + + private readonly Dictionary _fileDownloads = []; + + private delegate bool ModelDownloadingProgressCallback(string path, long? contentLength, long bytesRead); + public event NotifyCollectionChangedEventHandler? ModelsCollectionChanged; + +#if WINDOWS + private FileSystemWatcher? _fileSystemWatcher; +#endif + + private CancellationTokenSource? _cancellationTokenSource; + private Task? _collectModelFilesTask; + + public ReadOnlyObservableCollection Models { get; } + public ReadOnlyObservableCollection UnsortedModels { get; } + + private ObservableCollection _models { get; } = []; + private ObservableCollection _unsortedModels { get; } = []; + + [ObservableProperty] + private long _totalModelSize; + + [ObservableProperty] + private int _localModelsCount; + + [ObservableProperty] + private bool _fileCollectingInProgress; + + public LLMFileManagerConfig Config { get; } = new LLMFileManagerConfig(); + + public event EventHandler? FileCollectingCompleted; + + public LLMFileManager(IAppSettingsService appSettingsService, HttpClient httpClient) + { +#if DEBUG + if (InstanceCount > 0) + { + throw new InvalidProgramException("Invalid operation: Only one instance of this class should be created, and it must be instantiated through dependency injection."); + } + + InstanceCount++; +#endif + Models = new ReadOnlyObservableCollection(_models); + UnsortedModels = new ReadOnlyObservableCollection(_unsortedModels); + _httpClient = httpClient; + _models.CollectionChanged += OnModelCollectionChanged; + _unsortedModels.CollectionChanged += OnUnsortedModelCollectionChanged; + Config.ModelsDirectory = appSettingsService.ModelStorageDirectory; + Config.PropertyChanged += OnConfigPropertyChanged; + _fileSystemEntryRecorder = new FileSystemEntryRecorder(new Uri(Config.ModelsDirectory)); + + OnModelStorageDirectorySet(); + } + + +#if WINDOWS + //todo: move code to Windows specific service and implement one for Mac as well. + private void InitializeFileSystemWatcher(string directoryPath) + { + _fileSystemWatcher = new FileSystemWatcher + { + Path = Config.ModelsDirectory, + IncludeSubdirectories = true, + EnableRaisingEvents = true + }; + + _fileSystemWatcher.Changed += OnFileChanged; + _fileSystemWatcher.Deleted += OnFileDeleted; + _fileSystemWatcher.Renamed += OnFileRenamed; + _fileSystemWatcher.Created += OnFileCreated; + } + + private void DisposeFileSystemWatcher() + { + _fileSystemWatcher!.EnableRaisingEvents = false; + + _fileSystemWatcher.Changed -= OnFileChanged; + _fileSystemWatcher.Deleted -= OnFileDeleted; + _fileSystemWatcher.Renamed -= OnFileRenamed; + _fileSystemWatcher.Created -= OnFileCreated; + + _fileSystemWatcher.Dispose(); + _fileSystemWatcher = null; + } +#endif + + public void OnModelDownloaded(ModelCard modelCard) + { + TotalModelSize += modelCard.FileSize; + LocalModelsCount++; + } + + public void DeleteModel(ModelCard modelCard) + { + if (modelCard.IsLocallyAvailable) + { + File.Delete(modelCard.LocalPath); + LocalModelsCount--; + TotalModelSize -= modelCard.FileSize; + +#if !WINDOWS + if (!modelCard.IsPredefined) + { + if (_unsortedModels.Contains(modelCard)) + { + _unsortedModels.Remove(modelCard); + } + + if (_models.Contains(modelCard)) + { + _models.Remove(modelCard); + } + } +#endif + } + else + { + throw new Exception(Locales.ModelFileNotAvailableLocally); + } + } + + private async Task CollectCustomModelsAsync() + { + FileCollectingInProgress = true; + + if (FileCollectingInProgress && _cancellationTokenSource != null) + { + await CancelOngoingFileCollecting(); + } + + _cancellationTokenSource = new CancellationTokenSource(); + + Exception? exception = null; + + bool cancelled = false; + + try + { + await (_collectModelFilesTask = Task.Run(() => CollectCustomModels())); + } + catch (OperationCanceledException) + { + cancelled = true; + } + catch (Exception ex) + { + exception = ex; + } + + if (!cancelled) + { + TerminateFileCollectingOperation(); + } + + FileCollectingCompleted?.Invoke(this, new FileCollectingCompletedEventArgs(exception == null, exception)); + } + + private async Task CancelOngoingFileCollecting() + { + try + { + _cancellationTokenSource!.Cancel(); + await _collectModelFilesTask!.ConfigureAwait(false); + } + catch + { + return; + } + } + + private void TerminateFileCollectingOperation() + { + FileCollectingInProgress = false; + _cancellationTokenSource?.Dispose(); + _cancellationTokenSource = null; + _collectModelFilesTask = null; + } + + private void UpdatePredefinedModelCards() + { + lock (_modelsLock) + { + var predefinedModels = ModelCard.GetPredefinedModelCards(dropSmallerModels: !Config.EnableLowPerformanceModels); + + if (_models.Count > 0) + { + for (int index = 0; index < _models.Count; index++) + { + if (_models[index].IsPredefined && !_models[index].IsLocallyAvailable) + { + _models.RemoveAt(index); + index--; + } + } + } + + if (Config.EnablePredefinedModels) + { + foreach (var modelCard in predefinedModels) + { + if (!string.IsNullOrWhiteSpace(modelCard.ReplacementModel) && + !modelCard.IsLocallyAvailable) + {//ignoring models marked as legacy. + continue; + } + + TryRegisterChatModel(modelCard, isSorted: true); + } + } + } + } + + private void CollectCustomModels() + { + var files = Directory.GetFileSystemEntries(Config.ModelsDirectory, "*", SearchOption.AllDirectories); + + foreach (var filePath in files) + { + lock (_modelsLock) + { + if (ContainsModel(_models, filePath)) + { + continue; + } + + if (ShouldCheckFile(filePath)) + { + HandleFile(filePath); + } + } + + _cancellationTokenSource!.Token.ThrowIfCancellationRequested(); + } + } + + private bool TryRegisterChatModel(ModelCard? modelCard, bool isSorted) + { + if (modelCard != null) + { + bool hasAnyFilteredCap = false; + + foreach (var cap in Config.FilteredCapabilities) + { + if (modelCard.Capabilities.HasFlag(cap)) + { + hasAnyFilteredCap = true; + break; + } + } + + if (!hasAnyFilteredCap) + { + return false; + } + + bool isSlowModel = Hardware.DeviceConfiguration.GetPerformanceScore(modelCard) < 0.3; + + if (!ContainsModel(_models, modelCard)) + { + if (isSlowModel && !Config.EnableLowPerformanceModels) + { + return false; + } + + _models.Add(modelCard); + + if (!isSorted) + { + _unsortedModels.Add(modelCard); + } + + return true; + } + else if (isSlowModel && !Config.EnableLowPerformanceModels) + { + _models.Remove(modelCard); + } + } + + return false; + } + + private void HandleFile(string filePath) + { + if (!TryValidateModelFile(filePath, Config.ModelsDirectory, out ModelCard? modelCard, out bool isSorted)) + { + //todo: Add feedback to indicate that a model of a supported format could not be loaded + return; + } + + TryRegisterChatModel(modelCard, isSorted); + } + + private void HandleFileRecording(Uri fileUri) + { + var fileRecord = _fileSystemEntryRecorder.RecordFile(fileUri); + + if (fileRecord != null) + { + fileRecord.FilePathChanged += OnFileRecordPathChanged; + } + } + + private void HandleFileRecordDeletion(Uri fileUri) + { + var fileRecord = _fileSystemEntryRecorder.DeleteFileRecord(fileUri!); + + if (fileRecord != null) + { + fileRecord.FilePathChanged -= OnFileRecordPathChanged; + } + } + + #region Event handlers + + private void OnModelStorageDirectorySet() + { + if (FileCollectingInProgress) + { + _cancellationTokenSource?.Cancel(); + } + + _fileSystemEntryRecorder.RootDirectory = new Uri(Config.ModelsDirectory); + + _unsortedModels.Clear(); + _models.Clear(); + + UpdatePredefinedModelCards(); + + if (Config.EnablePredefinedModels) + { + Task.Run(CollectCustomModelsAsync); + } + } + + private void OnConfigPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(LLMFileManagerConfig.ModelsDirectory)) + { +#if WINDOWS + if (_fileSystemWatcher != null) + { + DisposeFileSystemWatcher(); + } + + InitializeFileSystemWatcher(Config.ModelsDirectory); +#endif + OnModelStorageDirectorySet(); + } + else if (e.PropertyName == nameof(LLMFileManagerConfig.EnableLowPerformanceModels)) + { + UpdatePredefinedModelCards(); + } + else if (e.PropertyName == nameof(LLMFileManagerConfig.EnablePredefinedModels)) + { + UpdatePredefinedModelCards(); + } + } + +#if WINDOWS + private void OnFileDeleted(object sender, FileSystemEventArgs e) + { + Uri fileUri = new Uri(e.FullPath); + + if (ContainsModel(_models, fileUri, out int index)) + { + if (!_models[index].IsPredefined) + { + var model = _models[index]; + _models.Remove(model); + + if (_unsortedModels.Contains(model)) + { + _unsortedModels.Remove(model); + } + } + } + } + + private void OnFileChanged(object sender, FileSystemEventArgs e) + { + if (e.Name != null) + { + bool shouldCheckFile = ShouldCheckFile(e.FullPath); + + if (shouldCheckFile) + { + bool accessGranted = WaitFileReadAccessGranted(e.FullPath); + + if (accessGranted) + { + HandleFile(e.FullPath); + } + } + } + } + + private void OnFileCreated(object sender, FileSystemEventArgs e) + { + if (ShouldCheckFile(e.FullPath) && WaitFileReadAccessGranted(e.FullPath)) + { + HandleFile(e.FullPath); + } + } + + private void OnFileRenamed(object sender, RenamedEventArgs e) + { + var entryRecord = _fileSystemEntryRecorder.TryGetExistingEntry(new Uri(e.OldFullPath)); + + if (entryRecord != null) + { + entryRecord.Rename(FileHelpers.GetFileBaseName(new Uri(e.FullPath))); + } + else + { + if (ShouldCheckFile(e.FullPath)) + { + HandleFile(e.FullPath); + } + } + + } +#endif + + + private void OnModelCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + if (e.Action == NotifyCollectionChangedAction.Add) + { + foreach (var item in e.NewItems!) + { + var card = (ModelCard)item; + + if (card.IsLocallyAvailable) + { + TotalModelSize += card.FileSize; + LocalModelsCount++; + } + + HandleFileRecording(card.ModelUri!); + } + } + else if (e.Action == NotifyCollectionChangedAction.Remove) + { + foreach (var item in e.OldItems!) + { + var card = (ModelCard)item; + + if (card.IsLocallyAvailable) + { + TotalModelSize -= card.FileSize; + LocalModelsCount--; + } + + HandleFileRecordDeletion(card.ModelUri!); + } + } + else if (e.Action == NotifyCollectionChangedAction.Reset) + { + TotalModelSize = 0; + LocalModelsCount = 0; + } + + ModelsCollectionChanged?.Invoke(sender, e); + } + + private void OnUnsortedModelCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + if (e.Action == NotifyCollectionChangedAction.Add) + { + foreach (var item in e.NewItems!) + { + HandleFileRecording(((ModelCard)item).ModelUri!); + } + } + else if (e.Action == NotifyCollectionChangedAction.Remove) + { + foreach (var item in e.OldItems!) + { + HandleFileRecordDeletion(((ModelCard)item).ModelUri!); + } + } + } + + private void OnFileRecordPathChanged(object? sender, EventArgs e) + { + var fileRecordPathChangedEventArgs = (FileSystemEntryRecorder.FileRecordPathChangedEventArgs)e; + + + if (ContainsModel(_models, fileRecordPathChangedEventArgs.OldPath, out int index) && + FileHelpers.GetModelInfoFromFileUri(fileRecordPathChangedEventArgs.NewPath, + Config.ModelsDirectory, + out string publisher, + out string repository, + out string fileName)) + { + var prevModel = _models[index]; + _models[index] = new ModelCard(fileRecordPathChangedEventArgs.NewPath) + { + Publisher = publisher, + Repository = repository, + }; + + if (_unsortedModels.Contains(prevModel)) + { + _unsortedModels[_unsortedModels.IndexOf(prevModel)] = _models[index]; + } + } + } + #endregion + + #region Static methods + + private static bool ContainsModel(IList models, ModelCard modelCard) + { + if (models.Contains(modelCard)) + { + return true; + } + + //In this scope, we are essentially searching for duplicate model files.. + foreach (var model in models) + { + /*if (model.SHA256 == modelCard.SHA256) //Loïc: commented. This is too slow. + { + return true; + }*/ + + if (model.ModelUri == modelCard.ModelUri || + (model.FileName == modelCard.FileName && model.FileSize == modelCard.FileSize)) + { + if (model.LocalPath == modelCard.LocalPath) + { + return true; + } + else if (model.SHA256 == modelCard.SHA256) + { + //todo: propagate feedback indicating that a duplicate file exists. + return true; + } + } + } + + return false; + } + + private static bool ContainsModel(IList models, string filePath) + { + foreach (var model in models) + { + if (model.LocalPath == filePath) + { + return true; + } + } + + return false; + } + + private static bool ContainsModel(IList models, Uri uri, out int index) + { + index = 0; + + foreach (var model in models) + { + if (model.ModelUri == uri) + { + return true; + } + + index++; + } + + index = -1; + + return false; + } + + private static bool TryValidateModelFile(string filePath, string modelFolderPath, out ModelCard? modelCard, out bool isSorted) + { + isSorted = false; + modelCard = null; + + if (LM.ValidateFormat(filePath)) + { + try + { + modelCard = ModelCard.CreateFromFile(filePath); + } + catch + { + return false; + } + + if (FileHelpers.GetModelInfoFromPath(filePath, modelFolderPath, + out string publisher, out string repository, out string fileName)) + { + isSorted = true; + modelCard.Publisher = publisher; + modelCard.Repository = repository; + } + else + { + modelCard.Publisher = "unknown publisher"; + modelCard.Repository = "unknown repository"; + } + + return true; + } + + return false; + } + + private static bool ShouldCheckFile(string filePath) + { + bool isFileDirectory = FileHelpers.IsFileDirectory(filePath); + + if (isFileDirectory) + { + return false; + } + else + { + filePath = filePath.ToLower(); + return !filePath.EndsWith(".lmk.gguf") && + !filePath.EndsWith(".download") && + !filePath.EndsWith(".origin"); + } + } + + private static bool WaitFileReadAccessGranted(string fileName, int maxRetryCount = 3) + { + for (int retryCount = 0; retryCount < maxRetryCount; retryCount++) + { + if (!FileHelpers.IsFileLocked(fileName)) + { + return true; + } + else + { + if (retryCount + 1 < maxRetryCount) + { + Thread.Sleep(2000); + } + } + } + + return false; + } + + #endregion +} diff --git a/LM-Kit-Maestro/Services/LMKitDefaultSettings.cs b/LM-Kit-Maestro/Services/LMKitDefaultSettings.cs index 2df4499f..fa4b4004 100644 --- a/LM-Kit-Maestro/Services/LMKitDefaultSettings.cs +++ b/LM-Kit-Maestro/Services/LMKitDefaultSettings.cs @@ -1,8 +1,9 @@ -namespace LMKit.Maestro.Services; +namespace LMKit.Maestro.Services; public static class LMKitDefaultSettings { public static readonly string DefaultModelStorageDirectory = Global.Configuration.ModelStorageDirectory; + public static readonly string DefaultChatHistoryDirectory = FileSystem.AppDataDirectory; public const string DefaultSystemPrompt = "You are Maestro, a chatbot designed to provide prompt, helpful, and accurate responses to user requests in a friendly and professional manner."; public const int DefaultMaximumCompletionTokens = 2048; // TODO: Evan, consider setting this to -1 to indicate no limitation. Ensure the option to configure the chat with a predefined limit remains available. @@ -11,7 +12,7 @@ public static class LMKitDefaultSettings public const SamplingMode DefaultSamplingMode = SamplingMode.Random; public const bool DefaultEnableLowPerformanceModels = false; - public const bool DefaultEnablePredefinedModels = true; + public const bool DefaultEnablePredefinedModels = true; public const bool DefaultEnableCustomModels = true; public static SamplingMode[] AvailableSamplingModes { get; } = (SamplingMode[])Enum.GetValues(typeof(SamplingMode)); @@ -20,9 +21,9 @@ public static class LMKitDefaultSettings public const float DefaultRandomSamplingDynamicTemperatureRange = 0f; public const float DefaultRandomSamplingTopP = 0.95f; public const float DefaultRandomSamplingMinP = 0.05f; - public const int DefaultRandomSamplingTopK = 40; - // public const float DefaultRandomSamplingLocallyTypical = 1; - + public const int DefaultRandomSamplingTopK = 40; + // public const float DefaultRandomSamplingLocallyTypical = 1; + public const float DefaultTopNSigmaSampling = 1f; public const float DefaultMirostat2SamplingTemperature = 0.8f; diff --git a/LM-Kit-Maestro/Services/LMKitService.Chat.cs b/LM-Kit-Maestro/Services/LMKitService.Chat.cs index e8c6bbc4..e610cfd3 100644 --- a/LM-Kit-Maestro/Services/LMKitService.Chat.cs +++ b/LM-Kit-Maestro/Services/LMKitService.Chat.cs @@ -1,4 +1,6 @@ -using LMKit.TextGeneration; +using LMKit.Data; +using LMKit.Maestro.Models; +using LMKit.TextGeneration; using LMKit.TextGeneration.Chat; using System.ComponentModel; @@ -14,6 +16,11 @@ public partial class LMKitChat : INotifyPropertyChanged public event PropertyChangedEventHandler? PropertyChanged; + protected virtual void OnPropertyChanged(string? propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + private readonly LMKitServiceState _state; private MultiTurnConversation? _multiTurnConversation; @@ -24,10 +31,22 @@ public LMKitChat(LMKitServiceState state) _state = state; } + /// + /// Submits a text-only prompt. + /// public async Task SubmitPrompt(Conversation conversation, string prompt) { + return await SubmitPrompt(conversation, prompt, null); + } + + /// + /// Submits a prompt with optional attachments (images, PDFs). + /// + public async Task SubmitPrompt(Conversation conversation, string prompt, IEnumerable? attachments) + { + var submission = new PromptSubmission(prompt, attachments); var promptRequest = new ChatRequest(conversation, ChatRequest.ChatRequestType.Prompt, - prompt, _state.Config.RequestTimeout); + submission, _state.Config.RequestTimeout); ScheduleRequest(promptRequest); @@ -167,7 +186,29 @@ private async Task SubmitPrompt(ChatRequest request) { if (request.RequestType == ChatRequest.ChatRequestType.Prompt) { - result.Result = await _multiTurnConversation!.SubmitAsync((string)request.Parameters!, request.CancellationTokenSource.Token); + var submission = (PromptSubmission)request.Parameters!; + + if (submission.HasAttachments) + { + // Create a message with attachments + List attachments = new List(); + foreach (var chatAttachment in submission.Attachments) + { + // Convert our ChatAttachment to LMKit.Data.Attachment + var lmkitAttachment = new Attachment( + chatAttachment.Content, + chatAttachment.FileName + ); + attachments.Add(lmkitAttachment); + } + var message = new ChatHistory.Message(AuthorRole.User, submission.Text, attachments); + result.Result = await _multiTurnConversation!.SubmitAsync(message, request.CancellationTokenSource.Token); + } + else + { + // Text-only prompt + result.Result = await _multiTurnConversation!.SubmitAsync(submission.Text, request.CancellationTokenSource.Token); + } } else if (request.RequestType == ChatRequest.ChatRequestType.RegenerateResponse) { @@ -216,8 +257,18 @@ private async Task SubmitPrompt(ChatRequest request) private void GenerateConversationSummaryTitle(Conversation conversation) { - string firstMessage = conversation.ChatHistory!.Messages.First(message => message.AuthorRole == AuthorRole.User).Text; - ChatRequest titleGenerationRequest = new ChatRequest(conversation, ChatRequest.ChatRequestType.GenerateTitle, firstMessage, 60); + var firstUserMessage = conversation.ChatHistory!.Messages.First(message => message.AuthorRole == AuthorRole.User); + + // Skip title generation if there's no content at all + bool hasText = !string.IsNullOrWhiteSpace(firstUserMessage.Text); + bool hasAttachments = firstUserMessage.Attachments != null && firstUserMessage.Attachments.Count > 0; + + if (!hasText && !hasAttachments) + { + return; + } + + ChatRequest titleGenerationRequest = new ChatRequest(conversation, ChatRequest.ChatRequestType.GenerateTitle, firstUserMessage, 60); _titleGenerationSchedule.Schedule(titleGenerationRequest); @@ -232,7 +283,7 @@ private void GenerateConversationSummaryTitle(Conversation conversation) { Summarizer summarizer = new Summarizer(_state.LoadedModel) { - MaximumContextLength = 512, + MaximumContextLength = hasText ? 512: 2048, GenerateContent = false, GenerateTitle = true, MaxTitleWords = 10, @@ -244,7 +295,16 @@ private void GenerateConversationSummaryTitle(Conversation conversation) try { - promptResult.Result = await summarizer.SummarizeAsync(firstMessage, titleGenerationRequest.CancellationTokenSource.Token); + if (hasText) + { + // Use text for summarization + promptResult.Result = await summarizer.SummarizeAsync(firstUserMessage.Text, titleGenerationRequest.CancellationTokenSource.Token); + } + else if (hasAttachments) + { + // Use first attachment for summarization (image-only message) + promptResult.Result = await summarizer.SummarizeAsync(firstUserMessage.Attachments!.First().Target, titleGenerationRequest.CancellationTokenSource.Token); + } } catch (Exception exception) { @@ -264,50 +324,50 @@ private void BeforeSubmittingPrompt(Conversation conversation) { if (conversation != _lastConversationUsed && _multiTurnConversation != null) - { - _multiTurnConversation.AfterTokenSampling -= conversation.AfterTokenSampling; - _multiTurnConversation.Dispose(); + { + _multiTurnConversation.AfterTokenSampling -= conversation.AfterTokenSampling; + _multiTurnConversation.Dispose(); _multiTurnConversation = null; } - + if (_multiTurnConversation == null) - { - // Latest chat history of this conversation was generated with a different model + { + // Latest chat history of this conversation was generated with a different model bool lastUsedDifferentModel = _state.Config.LoadedModelUri != conversation.LastUsedModelUri; bool shouldUseCurrentChatHistory = !lastUsedDifferentModel && conversation.ChatHistory != null; bool shouldDeserializeChatHistoryData = (lastUsedDifferentModel && conversation.LatestChatHistoryData != null) || (!lastUsedDifferentModel && conversation.ChatHistory == null); - if (shouldUseCurrentChatHistory) - { - _multiTurnConversation = new MultiTurnConversation(_state.LoadedModel, conversation.ChatHistory, _state.Config.ContextSize); - } - else if (shouldDeserializeChatHistoryData) - { - var chatHistory = ChatHistory.Deserialize(conversation.LatestChatHistoryData, _state.LoadedModel); - _multiTurnConversation = new MultiTurnConversation(_state.LoadedModel, chatHistory, _state.Config.ContextSize); - } - else - { + if (shouldUseCurrentChatHistory) + { + _multiTurnConversation = new MultiTurnConversation(_state.LoadedModel, conversation.ChatHistory, _state.Config.ContextSize); + } + else if (shouldDeserializeChatHistoryData) + { + var chatHistory = ChatHistory.Deserialize(conversation.LatestChatHistoryData, _state.LoadedModel); + _multiTurnConversation = new MultiTurnConversation(_state.LoadedModel, chatHistory, _state.Config.ContextSize); + } + else + { _multiTurnConversation = new MultiTurnConversation(_state.LoadedModel, _state.Config.ContextSize); } _multiTurnConversation.AfterTokenSampling += conversation.AfterTokenSampling; } - - // Binding everything - conversation.ChatHistory = _multiTurnConversation.ChatHistory; - conversation.LastUsedModelUri = _state.Config.LoadedModelUri; - _lastConversationUsed = conversation; - - // Update conversation - if (_multiTurnConversation.ChatHistory.MessageCount == 0 || - _multiTurnConversation.ChatHistory.Messages.Last().AuthorRole == AuthorRole.System) - { - _multiTurnConversation.SystemPrompt = _state.Config.SystemPrompt; - } - - _multiTurnConversation.SamplingMode = GetTokenSampling(_state.Config); - _multiTurnConversation.MaximumCompletionTokens = _state.Config.MaximumCompletionTokens; + + // Binding everything + conversation.ChatHistory = _multiTurnConversation.ChatHistory; + conversation.LastUsedModelUri = _state.Config.LoadedModelUri; + _lastConversationUsed = conversation; + + // Update conversation + if (_multiTurnConversation.ChatHistory.MessageCount == 0 || + _multiTurnConversation.ChatHistory.Messages.Last().AuthorRole == AuthorRole.System) + { + _multiTurnConversation.SystemPrompt = _state.Config.SystemPrompt; + } + + _multiTurnConversation.SamplingMode = GetTokenSampling(_state.Config); + _multiTurnConversation.MaximumCompletionTokens = _state.Config.MaximumCompletionTokens; // conversation.InTextCompletion = true; @@ -318,4 +378,4 @@ private void AfterSubmittingPrompt(Conversation conversation) conversation.InTextCompletion = false; } } -} \ No newline at end of file +} diff --git a/LM-Kit-Maestro/Services/LMKitService.LMKitServiceState.cs b/LM-Kit-Maestro/Services/LMKitService.LMKitServiceState.cs index 796435ac..bfa89430 100644 --- a/LM-Kit-Maestro/Services/LMKitService.LMKitServiceState.cs +++ b/LM-Kit-Maestro/Services/LMKitService.LMKitServiceState.cs @@ -6,14 +6,16 @@ public partial class LMKitService { public partial class LMKitServiceState { - public LMKitConfig Config { get; } = new LMKitConfig(); - - public SemaphoreSlim Semaphore { get; } = new SemaphoreSlim(1); - - public LM? LoadedModel { get; set; } - + public LMKitConfig Config { get; } = new LMKitConfig(); + + public SemaphoreSlim Semaphore { get; } = new SemaphoreSlim(1); + + public LM? LoadedModel { get; set; } + public Uri? LoadedModelUri { get; set; } public LMKitModelLoadingState ModelLoadingState { get; set; } + + public CancellationTokenSource? ModelLoadingCancellation { get; set; } } } \ No newline at end of file diff --git a/LM-Kit-Maestro/Services/LMKitService.Translation.cs b/LM-Kit-Maestro/Services/LMKitService.Translation.cs deleted file mode 100644 index 2ccae7ac..00000000 --- a/LM-Kit-Maestro/Services/LMKitService.Translation.cs +++ /dev/null @@ -1,31 +0,0 @@ -using LMKit.TextGeneration; -using LMKit.Translation; - -namespace LMKit.Maestro.Services; - -public partial class LMKitService -{ - public partial class LMKitTranslation - { - private readonly LMKitServiceState _state; - - public LMKitTranslation(LMKitServiceState state) - { - _state = state; - } - - public async Task Translate(string translation, Language outputLanguage) - { - var textTranslation = new TextTranslation(_state.LoadedModel!); - - return await textTranslation.TranslateAsync(translation, outputLanguage); - } - - public async Task DetectLanguage(string text) - { - var textTranslation = new TextTranslation(_state.LoadedModel!); - - return await textTranslation.DetectLanguageAsync(text); - } - } -} \ No newline at end of file diff --git a/LM-Kit-Maestro/Services/LMKitService.cs b/LM-Kit-Maestro/Services/LMKitService.cs index 4fda0a7f..9b24827b 100644 --- a/LM-Kit-Maestro/Services/LMKitService.cs +++ b/LM-Kit-Maestro/Services/LMKitService.cs @@ -1,191 +1,220 @@ -using LMKit.Model; -using LMKit.TextGeneration.Sampling; -using System.ComponentModel; - -namespace LMKit.Maestro.Services; - -/// -/// This service is intended to be used as a singleton via Dependency Injection. -/// Please register with services.AddSingleton<LMKitService>(). -/// -public partial class LMKitService : INotifyPropertyChanged -{ - private readonly LMKitServiceState _state; - - public LMKitConfig LMKitConfig => _state.Config; - - public LMKitTranslation Translation { get; } - - public LMKitChat Chat { get; } - - public LMKitModelLoadingState ModelLoadingState - { - get => _state.ModelLoadingState; - set - { - if (_state.ModelLoadingState != value) - { - _state.ModelLoadingState = value; - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ModelLoadingState))); - } - } - } - - public Uri? LoadedModelUri - { - get => _state.LoadedModelUri; - set - { - if (_state.LoadedModelUri != value) - { - _state.LoadedModelUri = value; - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(LoadedModelUri))); - } - } - } - - public event NotifyModelStateChangedEventHandler? ModelLoadingProgressed; - public event NotifyModelStateChangedEventHandler? ModelDownloadingProgressed; - public event NotifyModelStateChangedEventHandler? ModelLoaded; - public event NotifyModelStateChangedEventHandler? ModelLoadingFailed; - public event NotifyModelStateChangedEventHandler? ModelUnloaded; - - public event PropertyChangedEventHandler? PropertyChanged; - - public delegate void NotifyModelStateChangedEventHandler(object? sender, NotifyModelStateChangedEventArgs notifyModelStateChangedEventArgs); - - - public LMKitService() - { - _state = new LMKitServiceState(); - Chat = new LMKitChat(_state); - Translation = new LMKitTranslation(_state); - } - - public void LoadModel(Uri modelUri, string? localFilePath = null) - { - if (_state.LoadedModel != null) - { - UnloadModel(); - } - - _state.Semaphore.Wait(); - LoadedModelUri = modelUri; - ModelLoadingState = LMKitModelLoadingState.Loading; - - var modelLoadingTask = new Task(() => - { - bool modelLoadingSuccess; - - try - { - _state.LoadedModel = new LM(modelUri, downloadingProgress: OnModelDownloadingProgressed, loadingProgress: OnModelLoadingProgressed); - - modelLoadingSuccess = true; - } - catch (Exception exception) - { - modelLoadingSuccess = false; - ModelLoadingFailed?.Invoke(this, new ModelLoadingFailedEventArgs(modelUri, exception)); - } - finally - { - LoadedModelUri = null; - _state.Semaphore.Release(); - } - - if (modelLoadingSuccess) - { - LMKitConfig.LoadedModelUri = modelUri!; - - ModelLoaded?.Invoke(this, new NotifyModelStateChangedEventArgs(LMKitConfig.LoadedModelUri)); - ModelLoadingState = LMKitModelLoadingState.Loaded; - } - else - { - ModelLoadingState = LMKitModelLoadingState.Unloaded; - } - - }); - - modelLoadingTask.Start(); - } - - public void UnloadModel() - { - // Ensuring we don't clean things up while a model is already being loaded, - // or while the currently loaded model instance should not be touched - // (while we are getting Lm-Kit objects ready to process a newly submitted prompt for instance). - _state.Semaphore.Wait(); - - var unloadedModelUri = LMKitConfig.LoadedModelUri!; - - Chat.TerminateChatService(); - - if (_state.LoadedModel != null) - { - _state.LoadedModel.Dispose(); - _state.LoadedModel = null; - } - - _state.Semaphore.Release(); - - ModelLoadingState = LMKitModelLoadingState.Unloaded; - LMKitConfig.LoadedModelUri = null; - - ModelUnloaded?.Invoke(this, new NotifyModelStateChangedEventArgs(unloadedModelUri)); - } - - - - private bool OnModelLoadingProgressed(float progress) - { - ModelLoadingProgressed?.Invoke(this, new ModelLoadingProgressedEventArgs(_state.LoadedModelUri!, progress)); - - return true; - } - - private bool OnModelDownloadingProgressed(string path, long? contentLength, long bytesRead) - { - ModelDownloadingProgressed?.Invoke(this, new ModelDownloadingProgressedEventArgs(_state.LoadedModelUri!, path, contentLength, bytesRead)); - - return true; - } - - private static TokenSampling GetTokenSampling(LMKitConfig config) - { - switch (config.SamplingMode) - { - default: - case SamplingMode.Random: - return new RandomSampling() - { - Temperature = config.RandomSamplingConfig.Temperature, - DynamicTemperatureRange = config.RandomSamplingConfig.DynamicTemperatureRange, - TopP = config.RandomSamplingConfig.TopP, - TopK = config.RandomSamplingConfig.TopK, - MinP = config.RandomSamplingConfig.MinP, - //LocallyTypical = config.RandomSamplingConfig.LocallyTypical - }; - - case SamplingMode.Greedy: - return new GreedyDecoding(); - - case SamplingMode.Mirostat2: - return new Mirostat2Sampling() - { - Temperature = config.Mirostat2SamplingConfig.Temperature, - LearningRate = config.Mirostat2SamplingConfig.LearningRate, - TargetEntropy = config.Mirostat2SamplingConfig.TargetEntropy - }; - - case SamplingMode.TopNSigma: - return new TopNSigmaSampling() - { - Temperature = config.TopNSigmaSamplingConfig.Temperature, - TopK = config.TopNSigmaSamplingConfig.TopK, - TopNSigma = config.TopNSigmaSamplingConfig.TopNSigma, - }; - } - } +using LMKit.Model; +using LMKit.TextGeneration.Sampling; +using System.ComponentModel; + +namespace LMKit.Maestro.Services; + +/// +/// This service is intended to be used as a singleton via Dependency Injection. +/// Please register with services.AddSingleton<LMKitService>(). +/// +public partial class LMKitService : INotifyPropertyChanged +{ + private readonly LMKitServiceState _state; + + public LMKitConfig LMKitConfig => _state.Config; + + public LMKitChat Chat { get; } + + public LMKitModelLoadingState ModelLoadingState + { + get => _state.ModelLoadingState; + set + { + if (_state.ModelLoadingState != value) + { + _state.ModelLoadingState = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ModelLoadingState))); + } + } + } + + public Uri? LoadedModelUri + { + get => _state.LoadedModelUri; + set + { + if (_state.LoadedModelUri != value) + { + _state.LoadedModelUri = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(LoadedModelUri))); + } + } + } + + /// + /// Whether the currently loaded model supports vision (image analysis). + /// + public bool SupportsVision => _state.LoadedModel != null && _state.LoadedModel.HasVision; + + public event NotifyModelStateChangedEventHandler? ModelLoadingProgressed; + public event NotifyModelStateChangedEventHandler? ModelDownloadingProgressed; + public event NotifyModelStateChangedEventHandler? ModelLoaded; + public event NotifyModelStateChangedEventHandler? ModelLoadingFailed; + public event NotifyModelStateChangedEventHandler? ModelUnloaded; + + public event PropertyChangedEventHandler? PropertyChanged; + + public delegate void NotifyModelStateChangedEventHandler(object? sender, NotifyModelStateChangedEventArgs notifyModelStateChangedEventArgs); + + + public LMKitService() + { + _state = new LMKitServiceState(); + Chat = new LMKitChat(_state); + } + + public void LoadModel(Uri modelUri, string? localFilePath = null) + { + if (_state.LoadedModel != null) + { + UnloadModel(); + } + + + _state.Semaphore.Wait(); + LoadedModelUri = modelUri; + ModelLoadingState = LMKitModelLoadingState.Loading; + WasLoadingCancelled = false; + + var modelLoadingTask = new Task(() => + { + bool modelLoadingSuccess; + + try + { + _state.LoadedModel = new LM(modelUri, downloadingProgress: OnModelDownloadingProgressed, loadingProgress: OnModelLoadingProgressed); + + modelLoadingSuccess = true; + } + catch (Exception exception) + { + modelLoadingSuccess = false; + ModelLoadingFailed?.Invoke(this, new ModelLoadingFailedEventArgs(modelUri, exception)); + } + finally + { + LoadedModelUri = null; + _state.Semaphore.Release(); + } + + if (modelLoadingSuccess) + { + LMKitConfig.LoadedModelUri = modelUri!; + + ModelLoaded?.Invoke(this, new NotifyModelStateChangedEventArgs(LMKitConfig.LoadedModelUri)); + ModelLoadingState = LMKitModelLoadingState.Loaded; + } + else + { + ModelLoadingState = LMKitModelLoadingState.Unloaded; + } + + }); + + modelLoadingTask.Start(); + } + + public void UnloadModel() + { + // Ensuring we don't clean things up while a model is already being loaded, + // or while the currently loaded model instance should not be touched + // (while we are getting Lm-Kit objects ready to process a newly submitted prompt for instance). + _state.Semaphore.Wait(); + + var unloadedModelUri = LMKitConfig.LoadedModelUri!; + + Chat.TerminateChatService(); + + if (_state.LoadedModel != null) + { + _state.LoadedModel.Dispose(); + _state.LoadedModel = null; + } + + _state.Semaphore.Release(); + + ModelLoadingState = LMKitModelLoadingState.Unloaded; + LMKitConfig.LoadedModelUri = null; + + ModelUnloaded?.Invoke(this, new NotifyModelStateChangedEventArgs(unloadedModelUri)); + } + + private bool _cancelModelLoading; + + public bool WasLoadingCancelled { get; private set; } + + public void CancelModelLoading() + { + // Cancel if model is currently being loaded (includes downloading) + if (ModelLoadingState == LMKitModelLoadingState.Loading) + { + _cancelModelLoading = true; + WasLoadingCancelled = true; + } + } + + + private bool OnModelLoadingProgressed(float progress) + { + if (_cancelModelLoading) + { + _cancelModelLoading = false; + return false; + } + + ModelLoadingProgressed?.Invoke(this, new ModelLoadingProgressedEventArgs(_state.LoadedModelUri!, progress)); + + return true; + } + + private bool OnModelDownloadingProgressed(string path, long? contentLength, long bytesRead) + { + if (_cancelModelLoading) + { + _cancelModelLoading = false; + return false; + } + + ModelDownloadingProgressed?.Invoke(this, new ModelDownloadingProgressedEventArgs(_state.LoadedModelUri!, path, contentLength, bytesRead)); + + return true; + } + + private static TokenSampling GetTokenSampling(LMKitConfig config) + { + switch (config.SamplingMode) + { + default: + case SamplingMode.Random: + return new RandomSampling() + { + Temperature = config.RandomSamplingConfig.Temperature, + DynamicTemperatureRange = config.RandomSamplingConfig.DynamicTemperatureRange, + TopP = config.RandomSamplingConfig.TopP, + TopK = config.RandomSamplingConfig.TopK, + MinP = config.RandomSamplingConfig.MinP, + //LocallyTypical = config.RandomSamplingConfig.LocallyTypical + }; + + case SamplingMode.Greedy: + return new GreedyDecoding(); + + case SamplingMode.Mirostat2: + return new Mirostat2Sampling() + { + Temperature = config.Mirostat2SamplingConfig.Temperature, + LearningRate = config.Mirostat2SamplingConfig.LearningRate, + TargetEntropy = config.Mirostat2SamplingConfig.TargetEntropy + }; + + case SamplingMode.TopNSigma: + return new TopNSigmaSampling() + { + Temperature = config.TopNSigmaSamplingConfig.Temperature, + TopK = config.TopNSigmaSamplingConfig.TopK, + TopNSigma = config.TopNSigmaSamplingConfig.TopNSigma, + }; + } + } } \ No newline at end of file diff --git a/LM-Kit-Maestro/Services/PromptSubmission.cs b/LM-Kit-Maestro/Services/PromptSubmission.cs new file mode 100644 index 00000000..327758de --- /dev/null +++ b/LM-Kit-Maestro/Services/PromptSubmission.cs @@ -0,0 +1,30 @@ +using LMKit.Maestro.Models; + +namespace LMKit.Maestro.Services; + +/// +/// Represents a prompt submission with optional attachments. +/// +public class PromptSubmission +{ + /// + /// The text content of the prompt. + /// + public string Text { get; } + + /// + /// Optional attachments (images, PDFs) to include with the prompt. + /// + public IReadOnlyList Attachments { get; } + + /// + /// Whether this submission has any attachments. + /// + public bool HasAttachments => Attachments != null && Attachments.Count > 0; + + public PromptSubmission(string text, IEnumerable? attachments = null) + { + Text = text ?? string.Empty; + Attachments = attachments?.ToList() ?? new List(); + } +} diff --git a/LM-Kit-Maestro/Services/ThemeService.cs b/LM-Kit-Maestro/Services/ThemeService.cs new file mode 100644 index 00000000..8bf1fb1a --- /dev/null +++ b/LM-Kit-Maestro/Services/ThemeService.cs @@ -0,0 +1,74 @@ +using MudBlazor; + +namespace LMKit.Maestro.Services; + +public class ThemeService +{ + public event Action? OnThemeChanged; + + private ThemePreset _currentTheme = ThemePresets.Emerald; + + public ThemePreset CurrentTheme => _currentTheme; + + public MudTheme MudTheme => CreateMudTheme(_currentTheme); + + public void SetTheme(ThemePreset theme) + { + _currentTheme = theme; + OnThemeChanged?.Invoke(); + } + + public void SetTheme(string themeName) + { + var theme = ThemePresets.All.FirstOrDefault(t => t.Name == themeName); + if (theme != null) + { + SetTheme(theme); + } + } + + private static MudTheme CreateMudTheme(ThemePreset theme) + { + return new MudTheme() + { + PaletteDark = new PaletteDark() + { + Primary = theme.Primary, + Secondary = theme.Primary, + Surface = "#171717", + Background = "#212121", + TextPrimary = "#ECECEC", + TextSecondary = "#8E8E8E", + Divider = "#3A3A3A", + BackgroundGray = "#3A3A3A", + Error = "#EF4444", + }, + Typography = new Typography() + { + Overline = new OverlineTypography() + { + LineHeight = "1.5", + } + } + }; + } +} + +public record ThemePreset(string Name, string Primary, string Accent); + +public static class ThemePresets +{ + public static readonly ThemePreset Emerald = new("Emerald", "#10A37F", "#0D8A6F"); + public static readonly ThemePreset Ocean = new("Ocean", "#0EA5E9", "#0284C7"); + public static readonly ThemePreset Violet = new("Violet", "#8B5CF6", "#7C3AED"); + public static readonly ThemePreset Rose = new("Rose", "#F43F5E", "#E11D48"); + public static readonly ThemePreset Amber = new("Amber", "#F59E0B", "#D97706"); + public static readonly ThemePreset Cyan = new("Cyan", "#06B6D4", "#0891B2"); + public static readonly ThemePreset Pink = new("Pink", "#EC4899", "#DB2777"); + public static readonly ThemePreset Indigo = new("Indigo", "#6366F1", "#4F46E5"); + + public static readonly ThemePreset[] All = new[] + { + Emerald, Ocean, Violet, Rose, Amber, Cyan, Pink, Indigo + }; +} diff --git a/LM-Kit-Maestro/Services/WindowsFolderPickerService.cs b/LM-Kit-Maestro/Services/WindowsFolderPickerService.cs new file mode 100644 index 00000000..ffdeee7b --- /dev/null +++ b/LM-Kit-Maestro/Services/WindowsFolderPickerService.cs @@ -0,0 +1,154 @@ +#if WINDOWS +using System.Runtime.InteropServices; + +namespace LMKit.Maestro.Services; + +public class WindowsFolderPickerService : IFolderPickerService +{ + private const int BIF_RETURNONLYFSDIRS = 0x0001; + private const int BIF_NEWDIALOGSTYLE = 0x0040; + private const int BFFM_INITIALIZED = 1; + private const int BFFM_SETSELECTION = 0x0467; + + [DllImport("shell32.dll", CharSet = CharSet.Unicode)] + private static extern IntPtr SHBrowseForFolder(ref BROWSEINFO lpbi); + + [DllImport("shell32.dll", CharSet = CharSet.Unicode)] + private static extern bool SHGetPathFromIDList(IntPtr pidl, IntPtr pszPath); + + [DllImport("ole32.dll")] + private static extern void CoTaskMemFree(IntPtr ptr); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam); + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct BROWSEINFO + { + public IntPtr hwndOwner; + public IntPtr pidlRoot; + public IntPtr pszDisplayName; + public string lpszTitle; + public int ulFlags; + public BrowseCallbackProc lpfn; + public IntPtr lParam; + public int iImage; + } + + private delegate int BrowseCallbackProc(IntPtr hwnd, int uMsg, IntPtr lParam, IntPtr lpData); + + [ThreadStatic] + private static string? _initialPath; + + public Task PickFolderAsync(string? initialPath = null, string? title = null) + { + var tcs = new TaskCompletionSource(); + + // Must run on UI thread for shell dialogs + var dispatcher = Application.Current?.Dispatcher; + if (dispatcher == null) + { + tcs.SetResult(null); + return tcs.Task; + } + + dispatcher.Dispatch(() => + { + try + { + var result = ShowDialog(initialPath, title); + tcs.SetResult(result); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Folder picker error: {ex.Message}"); + tcs.SetResult(null); + } + }); + + return tcs.Task; + } + + private static string? ShowDialog(string? initialPath, string? title) + { + _initialPath = initialPath; + + var bi = new BROWSEINFO + { + hwndOwner = GetActiveWindowHandle(), + pidlRoot = IntPtr.Zero, + pszDisplayName = Marshal.AllocHGlobal(260 * 2), + lpszTitle = title ?? "Select Folder", + ulFlags = BIF_RETURNONLYFSDIRS | BIF_NEWDIALOGSTYLE, + lpfn = BrowseCallback, + lParam = IntPtr.Zero, + iImage = 0 + }; + + try + { + IntPtr pidl = SHBrowseForFolder(ref bi); + if (pidl != IntPtr.Zero) + { + try + { + IntPtr pathPtr = Marshal.AllocHGlobal(260 * 2); + try + { + if (SHGetPathFromIDList(pidl, pathPtr)) + { + return Marshal.PtrToStringUni(pathPtr); + } + } + finally + { + Marshal.FreeHGlobal(pathPtr); + } + } + finally + { + CoTaskMemFree(pidl); + } + } + } + finally + { + Marshal.FreeHGlobal(bi.pszDisplayName); + _initialPath = null; + } + + return null; + } + + private static int BrowseCallback(IntPtr hwnd, int uMsg, IntPtr lParam, IntPtr lpData) + { + if (uMsg == BFFM_INITIALIZED && !string.IsNullOrEmpty(_initialPath)) + { + IntPtr pathPtr = Marshal.StringToHGlobalUni(_initialPath); + try + { + SendMessage(hwnd, BFFM_SETSELECTION, (IntPtr)1, pathPtr); + } + finally + { + Marshal.FreeHGlobal(pathPtr); + } + } + return 0; + } + + private static IntPtr GetActiveWindowHandle() + { + try + { + var window = Application.Current?.Windows.FirstOrDefault(); + if (window?.Handler?.PlatformView is Microsoft.UI.Xaml.Window winUIWindow) + { + return WinRT.Interop.WindowNative.GetWindowHandle(winUIWindow); + } + } + catch { } + return IntPtr.Zero; + } +} +#endif diff --git a/LM-Kit-Maestro/UI/Components/ChatInput.razor b/LM-Kit-Maestro/UI/Components/ChatInput.razor index 21472b42..d2df3d8f 100644 --- a/LM-Kit-Maestro/UI/Components/ChatInput.razor +++ b/LM-Kit-Maestro/UI/Components/ChatInput.razor @@ -1,58 +1,111 @@ -@inject IJSRuntime JS +@using LMKit.Maestro.Models +@inject IJSRuntime JS @inject Maestro.Services.ISnackbarService SnackbarService -
-
+
+ @* Attachment Preview Area *@ + @if (HasAttachments) + { +
+ @foreach (var attachment in ViewModel.PendingAttachments) + { +
+ @if (attachment.IsImage && !string.IsNullOrEmpty(attachment.ThumbnailBase64)) + { + @attachment.FileName + } + else if (attachment.IsPdf) + { +
+ +
+ } +
+ @TruncateFileName(attachment.FileName) + @attachment.FileSizeDisplay +
+ +
+ } +
+ } -