From 81c332684218ab6e16f408a901b65f90ea5dae1a Mon Sep 17 00:00:00 2001 From: srebrek Date: Wed, 14 Jan 2026 13:13:45 +0100 Subject: [PATCH 1/5] Add Abstract AIModel class --- src/MaIN.Domain/Models/Abstract/AIModel.cs | 51 ++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 src/MaIN.Domain/Models/Abstract/AIModel.cs diff --git a/src/MaIN.Domain/Models/Abstract/AIModel.cs b/src/MaIN.Domain/Models/Abstract/AIModel.cs new file mode 100644 index 0000000..395f67c --- /dev/null +++ b/src/MaIN.Domain/Models/Abstract/AIModel.cs @@ -0,0 +1,51 @@ +using MaIN.Domain.Abstractions; +using MaIN.Domain.Configuration; // TODO: change localization + +namespace MaIN.Domain.Models.Abstract; + +public abstract class AIModel +{ + /// Internal Id. + public abstract string Id { get; } + + /// Name displayed to users. + public abstract string Name { get; } + + /// Gets the type of backend used by the model eg OpenAI or Self (Local). + public abstract BackendType Backend { get; } + + /// System Message added before first prompt. + public virtual string? SystemMessage { get; } + + /// Model description eg. capabilities or purpose. + public virtual string? Description { get; } + + /// Max context widnow size supported by the model. + public abstract uint MaxContextWindowSize { get; } + + public abstract T Accept(IModelVisitor visitor); +} + +public abstract class LocalModel : AIModel +{ + /// Name of the model file on the hard drive eg. Gemma2-2b.gguf + public abstract string FileName { get; } + + /// Uri to download model eg. https://huggingface.co/Inza124/gemma2_2b/resolve/main/gemma2-2b-maIN.gguf?download=true + public abstract Uri DownloadUrl { get; } + public override BackendType Backend => BackendType.Self; + + // Visitor Pattern + public override T Accept(IModelVisitor visitor) => visitor.Visit(this); + + public bool IsDownloaded(string basePath) + => File.Exists(Path.Combine(basePath, FileName)); +} + +public abstract class CloudModel : AIModel +{ + public abstract override BackendType Backend { get; } + + // Visitor Pattern + public override T Accept(IModelVisitor visitor) => visitor.Visit(this); +} From 2c3c1f61e9808c54e2af2d6a1b2867bfa11dcac5 Mon Sep 17 00:00:00 2001 From: srebrek Date: Thu, 15 Jan 2026 10:04:21 +0100 Subject: [PATCH 2/5] draft refactor for review --- MaIN.sln | 18 ++- Test/Program.cs | 22 ++++ Test/Test.csproj | 20 ++++ Test/appsettings.json | 12 ++ src/MaIN.Core/Hub/Contexts/ChatContext.cs | 44 ++++++-- src/MaIN.Domain/Entities/Chat.cs | 6 +- .../Exceptions/ChatConfigurationException.cs | 5 + src/MaIN.Domain/Exceptions/ModelException.cs | 5 + src/MaIN.Domain/Models/Abstract/AIModel.cs | 13 +-- .../Models/Abstract/ModelRegistry.cs | 56 ++++++++++ src/MaIN.Domain/Models/Concrete/Gemma_2b.cs | 15 +++ src/MaIN.Domain/Models/Concrete/Gpt4o.cs | 13 +++ src/MaIN.Services/Dtos/ChatDto.cs | 2 +- src/MaIN.Services/Mappers/ChatMapper.cs | 8 +- src/MaIN.Services/Services/ChatService.cs | 104 +++++++++--------- 15 files changed, 261 insertions(+), 82 deletions(-) create mode 100644 Test/Program.cs create mode 100644 Test/Test.csproj create mode 100644 Test/appsettings.json create mode 100644 src/MaIN.Domain/Exceptions/ChatConfigurationException.cs create mode 100644 src/MaIN.Domain/Exceptions/ModelException.cs create mode 100644 src/MaIN.Domain/Models/Abstract/ModelRegistry.cs create mode 100644 src/MaIN.Domain/Models/Concrete/Gemma_2b.cs create mode 100644 src/MaIN.Domain/Models/Concrete/Gpt4o.cs diff --git a/MaIN.sln b/MaIN.sln index cfd8fda..9a5bb26 100644 --- a/MaIN.sln +++ b/MaIN.sln @@ -1,6 +1,9 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Main.Infrastructure", "src\MaIN.Infrastructure\MaIN.Infrastructure.csproj", "{84C09EF9-B9E3-4ACB-9BC3-DEFFC29D528F}" +# Visual Studio Version 18 +VisualStudioVersion = 18.1.11312.151 d18.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MaIN.Infrastructure", "src\MaIN.Infrastructure\MaIN.Infrastructure.csproj", "{84C09EF9-B9E3-4ACB-9BC3-DEFFC29D528F}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MaIN.Services", "src\MaIN.Services\MaIN.Services.csproj", "{35DBE637-EC89-40A7-B5F9-D005D554E8ED}" EndProject @@ -22,6 +25,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MaIN.InferPage", "src\MaIN. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MaIN.Core.IntegrationTests", "MaIN.Core.IntegrationTests\MaIN.Core.IntegrationTests.csproj", "{2C15062A-E9F6-47FC-A4CD-1190A49E3FE3}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Test", "Test\Test.csproj", "{EC6B1BDC-EA44-41C9-86AB-ED9CF1AC35B7}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -64,11 +69,22 @@ Global {2C15062A-E9F6-47FC-A4CD-1190A49E3FE3}.Debug|Any CPU.Build.0 = Debug|Any CPU {2C15062A-E9F6-47FC-A4CD-1190A49E3FE3}.Release|Any CPU.ActiveCfg = Release|Any CPU {2C15062A-E9F6-47FC-A4CD-1190A49E3FE3}.Release|Any CPU.Build.0 = Release|Any CPU + {EC6B1BDC-EA44-41C9-86AB-ED9CF1AC35B7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EC6B1BDC-EA44-41C9-86AB-ED9CF1AC35B7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EC6B1BDC-EA44-41C9-86AB-ED9CF1AC35B7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EC6B1BDC-EA44-41C9-86AB-ED9CF1AC35B7}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution {781BDD20-65BA-4C5D-815B-D8A15931570A} = {28851935-517F-438D-BF7C-02FEB1A37A68} {46E6416B-1736-478C-B697-B37BB8E6A23E} = {53D24B04-279D-4D18-8829-EA0F57AE69F3} {75DEBB8A-75CD-44BA-9369-3916950428EF} = {28851935-517F-438D-BF7C-02FEB1A37A68} {2C15062A-E9F6-47FC-A4CD-1190A49E3FE3} = {53D24B04-279D-4D18-8829-EA0F57AE69F3} + {EC6B1BDC-EA44-41C9-86AB-ED9CF1AC35B7} = {28851935-517F-438D-BF7C-02FEB1A37A68} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {82494AB7-60FC-49E3-B37C-B9785FB0DE7A} EndGlobalSection EndGlobal diff --git a/Test/Program.cs b/Test/Program.cs new file mode 100644 index 0000000..d550bd0 --- /dev/null +++ b/Test/Program.cs @@ -0,0 +1,22 @@ +using MaIN.Core; +using MaIN.Core.Hub; +using MaIN.Core.Hub.Contexts; +using MaIN.Services.Services.Models; + +internal class Program +{ + private static async Task Main(string[] args) + { + Console.WriteLine("Hello, World!"); + + MaINBootstrapper.Initialize(); + + ChatContext chat = AIHub.Chat(); + chat.WithModel("gemma2:2bc"); + chat.WithMessage("Where do hedgehogs goes at night?"); + await chat.CompleteAsync(interactive: true); + + chat.WithMessage("What were you talking about in the previous message?."); + await chat.CompleteAsync(interactive: true); + } +} \ No newline at end of file diff --git a/Test/Test.csproj b/Test/Test.csproj new file mode 100644 index 0000000..034f973 --- /dev/null +++ b/Test/Test.csproj @@ -0,0 +1,20 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + + PreserveNewest + + + + diff --git a/Test/appsettings.json b/Test/appsettings.json new file mode 100644 index 0000000..abb9ec9 --- /dev/null +++ b/Test/appsettings.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "MaIN": { + "ImageGenUrl": "http://localhost:5003" + }, + "AllowedHosts": "*" +} diff --git a/src/MaIN.Core/Hub/Contexts/ChatContext.cs b/src/MaIN.Core/Hub/Contexts/ChatContext.cs index 8f58c83..d61e293 100644 --- a/src/MaIN.Core/Hub/Contexts/ChatContext.cs +++ b/src/MaIN.Core/Hub/Contexts/ChatContext.cs @@ -4,6 +4,7 @@ using MaIN.Domain.Entities.Tools; using MaIN.Domain.Exceptions.Chats; using MaIN.Domain.Models; +using MaIN.Domain.Models.Abstract; using MaIN.Services; using MaIN.Services.Constants; using MaIN.Services.Services.Abstract; @@ -16,8 +17,8 @@ public sealed class ChatContext : IChatBuilderEntryPoint, IChatMessageBuilder, I { private readonly IChatService _chatService; private bool _preProcess; - private Chat _chat { get; set; } - private List _files { get; set; } = []; + private readonly Chat _chat; + private List _files = []; internal ChatContext(IChatService chatService) { @@ -26,8 +27,8 @@ internal ChatContext(IChatService chatService) { Name = "New Chat", Id = Guid.NewGuid().ToString(), - Messages = new List(), - Model = string.Empty + Messages = [], + ModelId = string.Empty }; _files = []; } @@ -38,14 +39,29 @@ internal ChatContext(IChatService chatService, Chat existingChat) _chat = existingChat; } + public IChatMessageBuilder WithModel() where TModel : AIModel, new() + { + var model = new TModel(); + SetModel(model); + return this; + } - public IChatMessageBuilder WithModel(string model) + [Obsolete("Use WithModel() instead.")] + public ChatContext WithModel(string modelId) { - _chat.Model = model; + var model = ModelRegistry.GetById(modelId); + SetModel(model); return this; } - public IChatMessageBuilder WithCustomModel(string model, string path, string? mmProject = null) + private void SetModel(AIModel model) + { + _chat.ModelId = model.Id; + _chat.ModelInstance = model; + _chat.Backend = model.Backend; + } + + public IChatMessageBuilder WithInferenceParams(InferenceParams inferenceParams) { KnownModels.AddModel(model, path, mmProject); _chat.Model = model; @@ -64,9 +80,11 @@ public IChatConfigurationBuilder WithInferenceParams(InferenceParams inferencePa return this; } - public IChatConfigurationBuilder WithTools(ToolsConfiguration toolsConfiguration) + [Obsolete("Use WithModel() instead.")] + public ChatContext WithCustomModel(string model, string path, string? mmProject = null) { - _chat.ToolsConfiguration = toolsConfiguration; + KnownModels.AddModel(model, path, mmProject); + _chat.ModelId = model; return this; } @@ -167,10 +185,14 @@ public IChatConfigurationBuilder DisableCache() } public async Task CompleteAsync( - bool translate = false, - bool interactive = false, + bool translate = false, // Move to WithTranslate + bool interactive = false, // Move to WithInteractive Func? changeOfValue = null) { + if (_chat.ModelInstance is null) + { + throw new ChatConfigurationException("Model is required. Use .WithModel() before calling CompleteAsync()."); + } if (_chat.Messages.Count == 0) { throw new EmptyChatException(_chat.Id); diff --git a/src/MaIN.Domain/Entities/Chat.cs b/src/MaIN.Domain/Entities/Chat.cs index d88b8d1..80f9889 100644 --- a/src/MaIN.Domain/Entities/Chat.cs +++ b/src/MaIN.Domain/Entities/Chat.cs @@ -1,6 +1,7 @@ using LLama.Batched; using MaIN.Domain.Configuration; using MaIN.Domain.Entities.Tools; +using MaIN.Domain.Models.Abstract; namespace MaIN.Domain.Entities; @@ -8,7 +9,8 @@ public class Chat { public string Id { get; init; } = string.Empty; public required string Name { get; init; } - public required string Model { get; set; } + public required string ModelId { get; set; } + public AIModel? ModelInstance { get; set; } public List Messages { get; set; } = []; public ChatType Type { get; set; } = ChatType.Conversation; public bool Visual { get; set; } @@ -18,7 +20,7 @@ public class Chat public TextToSpeechParams? TextToSpeechParams { get; set; } public Dictionary Properties { get; init; } = []; public List Memory { get; } = []; - public BackendType? Backend { get; set; } + public BackendType? Backend { get; set; } // TODO: remove because of ModelInstance public Conversation.State? ConversationState { get; set; } public bool Interactive = false; diff --git a/src/MaIN.Domain/Exceptions/ChatConfigurationException.cs b/src/MaIN.Domain/Exceptions/ChatConfigurationException.cs new file mode 100644 index 0000000..8bbeed4 --- /dev/null +++ b/src/MaIN.Domain/Exceptions/ChatConfigurationException.cs @@ -0,0 +1,5 @@ +namespace MaIN.Domain.Exceptions; + +public class ChatConfigurationException(string message) : Exception(message) +{ +} diff --git a/src/MaIN.Domain/Exceptions/ModelException.cs b/src/MaIN.Domain/Exceptions/ModelException.cs new file mode 100644 index 0000000..8bce057 --- /dev/null +++ b/src/MaIN.Domain/Exceptions/ModelException.cs @@ -0,0 +1,5 @@ +namespace MaIN.Domain.Exceptions; + +public class ModelException(string message) : Exception(message) +{ +} diff --git a/src/MaIN.Domain/Models/Abstract/AIModel.cs b/src/MaIN.Domain/Models/Abstract/AIModel.cs index 395f67c..60b1388 100644 --- a/src/MaIN.Domain/Models/Abstract/AIModel.cs +++ b/src/MaIN.Domain/Models/Abstract/AIModel.cs @@ -1,11 +1,10 @@ -using MaIN.Domain.Abstractions; -using MaIN.Domain.Configuration; // TODO: change localization +using MaIN.Domain.Configuration; // TODO: change localization namespace MaIN.Domain.Models.Abstract; public abstract class AIModel { - /// Internal Id. + /// Internal Id. For cloud models it is the cloud Id. public abstract string Id { get; } /// Name displayed to users. @@ -22,8 +21,6 @@ public abstract class AIModel /// Max context widnow size supported by the model. public abstract uint MaxContextWindowSize { get; } - - public abstract T Accept(IModelVisitor visitor); } public abstract class LocalModel : AIModel @@ -35,9 +32,6 @@ public abstract class LocalModel : AIModel public abstract Uri DownloadUrl { get; } public override BackendType Backend => BackendType.Self; - // Visitor Pattern - public override T Accept(IModelVisitor visitor) => visitor.Visit(this); - public bool IsDownloaded(string basePath) => File.Exists(Path.Combine(basePath, FileName)); } @@ -45,7 +39,4 @@ public bool IsDownloaded(string basePath) public abstract class CloudModel : AIModel { public abstract override BackendType Backend { get; } - - // Visitor Pattern - public override T Accept(IModelVisitor visitor) => visitor.Visit(this); } diff --git a/src/MaIN.Domain/Models/Abstract/ModelRegistry.cs b/src/MaIN.Domain/Models/Abstract/ModelRegistry.cs new file mode 100644 index 0000000..2b08567 --- /dev/null +++ b/src/MaIN.Domain/Models/Abstract/ModelRegistry.cs @@ -0,0 +1,56 @@ +using MaIN.Domain.Exceptions; +using System.Reflection; + +namespace MaIN.Domain.Models.Abstract; + +public static class ModelRegistry +{ + private static readonly Dictionary _models = new(StringComparer.OrdinalIgnoreCase); + + static ModelRegistry() + { + // Reflection but only at startup to register all available models + var modelTypes = Assembly.GetExecutingAssembly() + .GetTypes() + .Where(t => t.IsSubclassOf(typeof(AIModel)) && !t.IsAbstract); + + foreach (var type in modelTypes) + { + if (Activator.CreateInstance(type) is AIModel instance) + { + if (string.IsNullOrWhiteSpace(instance.Id)) + { + throw new ModelException($"Model type {type.Name} has an empty or null Id."); + } + + var normalizedId = instance.Id.Trim().Replace(':', '-'); + + if (!_models.TryAdd(normalizedId, instance)) + { + throw new InvalidOperationException($"Duplicate Model ID detected: '{normalizedId}'. Classes: {_models[normalizedId].GetType().Name} and {type.Name}"); + } + } + } + } + + public static AIModel GetById(string id) + { + if (string.IsNullOrWhiteSpace(id)) + { + throw new ArgumentException("Model ID cannot be null or empty.", nameof(id)); + } + + if (_models.TryGetValue(id.Trim(), out var model)) + { + return model; + } + + var availableIds = string.Join(", ", _models.Keys); + throw new KeyNotFoundException($"Model with ID '{id}' not found. Available models: {availableIds}"); + } + + public static IEnumerable GetAll() => _models.Values; + + public static bool Exists(string id) => + !string.IsNullOrWhiteSpace(id) && _models.ContainsKey(id.Trim()); +} diff --git a/src/MaIN.Domain/Models/Concrete/Gemma_2b.cs b/src/MaIN.Domain/Models/Concrete/Gemma_2b.cs new file mode 100644 index 0000000..3215551 --- /dev/null +++ b/src/MaIN.Domain/Models/Concrete/Gemma_2b.cs @@ -0,0 +1,15 @@ +using MaIN.Domain.Configuration; +using MaIN.Domain.Models.Abstract; + +namespace MaIN.Domain.Models.Concrete; + +public sealed class Gemma_2b : LocalModel +{ + public override string Id => "Gemma2-2B"; + public override string Name => "Gemma2-2B"; + public override string FileName => "Gemma2-2b.gguf"; + public override Uri DownloadUrl => new("https://huggingface.co/Inza124/gemma2_2b/resolve/main/gemma2-2b-maIN.gguf?download=true"); + public override string Description => "Compact 2B model for text generation, summarization, and simple Q&A"; + + public override uint MaxContextWindowSize => 8192; // TODO: verify +} diff --git a/src/MaIN.Domain/Models/Concrete/Gpt4o.cs b/src/MaIN.Domain/Models/Concrete/Gpt4o.cs new file mode 100644 index 0000000..a54ad75 --- /dev/null +++ b/src/MaIN.Domain/Models/Concrete/Gpt4o.cs @@ -0,0 +1,13 @@ +using MaIN.Domain.Configuration; +using MaIN.Domain.Models.Abstract; + +namespace MaIN.Domain.Models.Concrete; + +public sealed class Gpt4o : CloudModel +{ + public override string Id => "gpt-4o"; + public override string Name => "GPT-4 Omni"; + public override BackendType Backend => BackendType.OpenAi; + public override uint MaxContextWindowSize => 128000; // TODO: verify + public override string Description => "Most advanced OpenAI model."; +} diff --git a/src/MaIN.Services/Dtos/ChatDto.cs b/src/MaIN.Services/Dtos/ChatDto.cs index 6c991cd..ac29561 100644 --- a/src/MaIN.Services/Dtos/ChatDto.cs +++ b/src/MaIN.Services/Dtos/ChatDto.cs @@ -2,7 +2,7 @@ namespace MaIN.Services.Dtos; -public class ChatDto +public class ChatDto // TODO: make it record and not nullable for Id, Name, Model as they are required { [JsonPropertyName("id")] public string? Id { get; set; } diff --git a/src/MaIN.Services/Mappers/ChatMapper.cs b/src/MaIN.Services/Mappers/ChatMapper.cs index 4ccff91..ff9775e 100644 --- a/src/MaIN.Services/Mappers/ChatMapper.cs +++ b/src/MaIN.Services/Mappers/ChatMapper.cs @@ -15,7 +15,7 @@ public static ChatDto ToDto(this Chat chat) { Id = chat.Id, Name = chat.Name, - Model = chat.Model, + Model = chat.ModelId, Messages = chat.Messages.Select(m => m.ToDto()).ToList(), Visual = chat.Visual, Type = Enum.Parse(chat.Type.ToString()), @@ -44,7 +44,7 @@ public static Chat ToDomain(this ChatDto chat) { Id = chat.Id!, Name = chat.Name!, - Model = chat.Model!, + ModelId = chat.Model!, Messages = chat.Messages?.Select(m => m.ToDomain()).ToList()!, Visual = chat.Model == ImageGenService.LocalImageModels.FLUX, Type = Enum.Parse(chat.Type.ToString()), @@ -87,7 +87,7 @@ public static ChatDocument ToDocument(this Chat chat) { Id = chat.Id, Name = chat.Name, - Model = chat.Model, + Model = chat.ModelId, Messages = chat.Messages.Select(m => m.ToDocument()).ToList(), Visual = chat.Visual, Backend = chat.Backend, @@ -106,7 +106,7 @@ public static Chat ToDomain(this ChatDocument chat) { Id = chat.Id, Name = chat.Name, - Model = chat.Model, + ModelId = chat.Model, Messages = chat.Messages.Select(m => m.ToDomain()).ToList(), Visual = chat.Visual, Backend = chat.Backend, diff --git a/src/MaIN.Services/Services/ChatService.cs b/src/MaIN.Services/Services/ChatService.cs index 193d880..019c4cc 100644 --- a/src/MaIN.Services/Services/ChatService.cs +++ b/src/MaIN.Services/Services/ChatService.cs @@ -26,68 +26,68 @@ public async Task Create(Chat chat) await chatProvider.AddChat(chat.ToDocument()); } - public async Task Completions( - Chat chat, - bool translate = false, - bool interactiveUpdates = false, - Func? changeOfValue = null) -{ - if (chat.Model == ImageGenService.LocalImageModels.FLUX) + public async Task Completions( + Chat chat, + bool translate = false, + bool interactiveUpdates = false, + Func? changeOfValue = null) { - chat.Visual = true; - } - chat.Backend ??= settings.BackendType; - - chat.Messages.Where(x => x.Type == MessageType.NotSet).ToList() - .ForEach(x => x.Type = chat.Backend != BackendType.Self ? MessageType.CloudLLM : MessageType.LocalLLM); - - translate = translate || chat.Translate; - interactiveUpdates = interactiveUpdates || chat.Interactive; - var newMsg = chat.Messages.Last(); - newMsg.Time = DateTime.Now; + if (chat.ModelId == ImageGenService.LocalImageModels.FLUX) + { + chat.Visual = true; // TODO: add IImageGenModel interface and check for that instead + } + chat.Backend ??= settings.BackendType; // can be romved as we set backend on chat creation + + chat.Messages.Where(x => x.Type == MessageType.NotSet).ToList() + .ForEach(x => x.Type = chat.Backend != BackendType.Self ? MessageType.CloudLLM : MessageType.LocalLLM); - var lng = translate ? await translatorService.DetectLanguage(newMsg.Content) : null; - var originalMessages = chat.Messages; + translate = translate || chat.Translate; + interactiveUpdates = interactiveUpdates || chat.Interactive; + var newMsg = chat.Messages.Last(); + newMsg.Time = DateTime.Now; // TODO: introduce IDateTimeProvider (better for tests) + + var lng = translate ? await translatorService.DetectLanguage(newMsg.Content) : null; + var originalMessages = chat.Messages; - if (translate) - { - chat.Messages = (await Task.WhenAll(chat.Messages.Select(async m => new Message() + if (translate) { - Role = m.Role, - Content = await translatorService.Translate(m.Content, "en"), - Type = m.Type - }))).ToList(); - } + chat.Messages = (await Task.WhenAll(chat.Messages.Select(async m => new Message() + { + Role = m.Role, + Content = await translatorService.Translate(m.Content, "en"), + Type = m.Type + }))).ToList(); + } - var result = chat.Visual - ? await imageGenServiceFactory.CreateService(chat.Backend.Value)!.Send(chat) - : await llmServiceFactory.CreateService(chat.Backend.Value).Send(chat, new ChatRequestOptions() - { - InteractiveUpdates = interactiveUpdates, - TokenCallback = changeOfValue - }); + var result = chat.Visual + ? await imageGenServiceFactory.CreateService(chat.Backend.Value)!.Send(chat) + : await llmServiceFactory.CreateService(chat.Backend.Value).Send(chat, new ChatRequestOptions() + { + InteractiveUpdates = interactiveUpdates, + TokenCallback = changeOfValue + }); - if (translate) - { - result!.Message.Content = await translatorService.Translate(result.Message.Content, lng!); - result.Message.Time = DateTime.Now; - } + if (translate) + { + result!.Message.Content = await translatorService.Translate(result.Message.Content, lng!); + result.Message.Time = DateTime.Now; + } - if (!chat.Visual && chat.TextToSpeechParams != null) - { - var speechBytes = await ttsServiceFactory - .CreateService(chat.Backend.Value).Send(result!.Message, chat.TextToSpeechParams.Model, - chat.TextToSpeechParams.Voice, chat.TextToSpeechParams.Playback); + if (!chat.Visual && chat.TextToSpeechParams != null) + { + var speechBytes = await ttsServiceFactory + .CreateService(chat.Backend.Value).Send(result!.Message, chat.TextToSpeechParams.Model, + chat.TextToSpeechParams.Voice, chat.TextToSpeechParams.Playback); - result.Message.Speech = speechBytes; - } + result.Message.Speech = speechBytes; + } - originalMessages.Add(result!.Message); - chat.Messages = originalMessages; + originalMessages.Add(result!.Message); + chat.Messages = originalMessages; - await chatProvider.UpdateChat(chat.Id!, chat.ToDocument()); - return result; -} + await chatProvider.UpdateChat(chat.Id!, chat.ToDocument()); + return result; + } public async Task Delete(string id) { From 6da61e07128ba2cfa65607a91fbfa1c9b2ab9d2b Mon Sep 17 00:00:00 2001 From: srebrek Date: Tue, 20 Jan 2026 12:16:34 +0100 Subject: [PATCH 3/5] Add generic implementations and capabilities interfaces --- Examples/Examples.SimpleConsole/Program.cs | 15 +- Examples/Examples/Chat/ChatExample.cs | 4 +- MaIN.Core.IntegrationTests/ChatTests.cs | 14 +- Test/Program.cs | 4 +- src/MaIN.Core.UnitTests/AgentContextTests.cs | 4 +- src/MaIN.Core.UnitTests/ChatContextTests.cs | 2 +- src/MaIN.Core.UnitTests/FlowContextTests.cs | 4 +- src/MaIN.Core/Hub/Contexts/AgentContext.cs | 8 +- src/MaIN.Core/Hub/Contexts/ChatContext.cs | 14 +- src/MaIN.Core/Hub/Contexts/FlowContext.cs | 6 +- src/MaIN.Core/Hub/Contexts/ModelContext.cs | 130 +++++-- src/MaIN.Domain/Entities/Chat.cs | 2 +- src/MaIN.Domain/Models/Abstract/AIModel.cs | 226 +++++++++++- .../Models/Abstract/IModelCapabilities.cs | 56 +++ .../Models/Abstract/ModelRegistry.cs | 157 +++++++- src/MaIN.Domain/Models/Concrete/Gemma_2b.cs | 15 - src/MaIN.Domain/Models/Concrete/Gpt4o.cs | 2 +- .../Models/Concrete/LocalModels.cs | 345 ++++++++++++++++++ src/MaIN.Domain/Models/SupportedModels.cs | 3 + .../Components/Pages/Home.razor | 11 +- src/MaIN.Services/Services/AgentService.cs | 2 +- .../ImageGenServices/GeminiImageGenService.cs | 6 +- .../ImageGenServices/ImageGenDalleService.cs | 2 +- .../ImageGenServices/XaiImageGenService.cs | 2 +- .../Services/LLMService/AnthropicService.cs | 8 +- .../Services/LLMService/LLMService.cs | 58 +-- .../LLMService/OpenAiCompatibleService.cs | 4 +- .../Services/LLMService/Utils/ChatHelper.cs | 8 +- .../Steps/Commands/AnswerCommandHandler.cs | 2 +- .../Services/Steps/FechDataStepHandler.cs | 2 +- src/MaIN.Services/Utils/AgentStateManager.cs | 2 +- 31 files changed, 960 insertions(+), 158 deletions(-) create mode 100644 src/MaIN.Domain/Models/Abstract/IModelCapabilities.cs delete mode 100644 src/MaIN.Domain/Models/Concrete/Gemma_2b.cs create mode 100644 src/MaIN.Domain/Models/Concrete/LocalModels.cs diff --git a/Examples/Examples.SimpleConsole/Program.cs b/Examples/Examples.SimpleConsole/Program.cs index 8662cdc..12b1d2e 100644 --- a/Examples/Examples.SimpleConsole/Program.cs +++ b/Examples/Examples.SimpleConsole/Program.cs @@ -1,12 +1,19 @@ using MaIN.Core; using MaIN.Core.Hub; +using MaIN.Domain.Models.Abstract; +using MaIN.Domain.Models.Concrete; MaINBootstrapper.Initialize(); -var model = AIHub.Model(); +var modelContext = AIHub.Model(); -var m = model.GetModel("gemma3:4b"); -var x = model.GetModel("llama3.2:3b"); -await model.DownloadAsync(x.Name); +// Get models using ModelRegistry +var gemma = modelContext.GetModel("gemma3-4b"); +var llama = modelContext.GetModel("llama3.2-3b"); +// Or use strongly-typed models directly +var gemma2b = new Gemma_2b(); +Console.WriteLine($"Model: {gemma2b.Name}, File: {gemma2b.FileName}"); +// Download a model +await modelContext.DownloadAsync(gemma2b.Id); diff --git a/Examples/Examples/Chat/ChatExample.cs b/Examples/Examples/Chat/ChatExample.cs index 7a7db1d..df159fe 100644 --- a/Examples/Examples/Chat/ChatExample.cs +++ b/Examples/Examples/Chat/ChatExample.cs @@ -1,4 +1,5 @@ using MaIN.Core.Hub; +using MaIN.Domain.Models.Concrete; namespace Examples.Chat; @@ -8,8 +9,9 @@ public async Task Start() { Console.WriteLine("ChatExample is running!"); + // Using strongly-typed model await AIHub.Chat() - .WithModel("gemma2:2b") + .WithModel() .WithMessage("Where do hedgehogs goes at night?") .CompleteAsync(interactive: true); } diff --git a/MaIN.Core.IntegrationTests/ChatTests.cs b/MaIN.Core.IntegrationTests/ChatTests.cs index f0b7896..eee2b09 100644 --- a/MaIN.Core.IntegrationTests/ChatTests.cs +++ b/MaIN.Core.IntegrationTests/ChatTests.cs @@ -1,5 +1,6 @@ using MaIN.Core.Hub; using MaIN.Domain.Entities; +using MaIN.Domain.Models.Concrete; namespace MaIN.Core.IntegrationTests; @@ -12,7 +13,7 @@ public ChatTests() : base() [Fact] public async Task Should_AnswerQuestion_BasicChat() { - var context = AIHub.Chat().WithModel("gemma2:2b"); + var context = AIHub.Chat().WithModel(); var result = await context .WithMessage("Where the hedgehog goes at night?") @@ -29,7 +30,7 @@ public async Task Should_AnswerDifferences_BetweenDocuments_ChatWithFiles() List files = ["./Files/Nicolaus_Copernicus.pdf", "./Files/Galileo_Galilei.pdf"]; var result = await AIHub.Chat() - .WithModel("gemma2:2b") + .WithModel() .WithMessage("You have 2 documents in memory. Whats the difference of work between Galileo and Copernicus?. Give answer based on the documents.") .WithFiles(files) .CompleteAsync(); @@ -43,11 +44,12 @@ public async Task Should_AnswerDifferences_BetweenDocuments_ChatWithFiles() public async Task Should_AnswerQuestion_FromExistingChat() { var result = AIHub.Chat() - .WithModel("qwen2.5:0.5b"); + .WithModel(); await result.WithMessage("What do you think about math theories?") .CompleteAsync(); + await result.WithMessage("And about physics?") .CompleteAsync(); @@ -63,9 +65,7 @@ public async Task Should_AnswerGameFromImage_ChatWithVision() List images = ["./Files/gamex.jpg"]; var result = await AIHub.Chat() - .WithModel("llama3.2:3b") - .WithMessage("What is the title of game?") - + .WithModel() .WithMemoryParams(new MemoryParams { AnswerTokens = 1000 @@ -120,7 +120,7 @@ public async Task Should_AnswerDifferences_BetweenDocuments_ChatWithFiles_UsingS } var result = await AIHub.Chat() - .WithModel("gemma2:2b") + .WithModel() .WithMessage("You have 2 documents in memory. Whats the difference of work between Galileo and Copernicus?. Give answer based on the documents.") .WithFiles(fileStreams) .CompleteAsync(); diff --git a/Test/Program.cs b/Test/Program.cs index d550bd0..f250ae8 100644 --- a/Test/Program.cs +++ b/Test/Program.cs @@ -1,6 +1,7 @@ using MaIN.Core; using MaIN.Core.Hub; using MaIN.Core.Hub.Contexts; +using MaIN.Domain.Models.Concrete; using MaIN.Services.Services.Models; internal class Program @@ -12,7 +13,8 @@ private static async Task Main(string[] args) MaINBootstrapper.Initialize(); ChatContext chat = AIHub.Chat(); - chat.WithModel("gemma2:2bc"); + chat.WithModel("gemma2:2b"); // Using string (deprecated) + chat.WithModel(); // Using strongly-typed model chat.WithMessage("Where do hedgehogs goes at night?"); await chat.CompleteAsync(interactive: true); diff --git a/src/MaIN.Core.UnitTests/AgentContextTests.cs b/src/MaIN.Core.UnitTests/AgentContextTests.cs index 69ea8e3..93db54f 100644 --- a/src/MaIN.Core.UnitTests/AgentContextTests.cs +++ b/src/MaIN.Core.UnitTests/AgentContextTests.cs @@ -159,7 +159,7 @@ public async Task ProcessAsync_WithStringMessage_ShouldReturnChatResult() { // Arrange var message = "Hello, agent!"; - var chat = new Chat { Id = _agentContext.GetAgentId(), Messages = new List(), Name = "test", Model = "default"}; + var chat = new Chat { Id = _agentContext.GetAgentId(), Messages = new List(), Name = "test", ModelId = "default"}; var chatResult = new ChatResult { Done = true, Model = "test-model", Message = new Message { Role = "Assistant", @@ -175,7 +175,7 @@ public async Task ProcessAsync_WithStringMessage_ShouldReturnChatResult() _mockAgentService .Setup(s => s.Process(It.IsAny(), _agentContext.GetAgentId(), It.IsAny(), It.IsAny(), null, null)) .ReturnsAsync(new Chat { - Model = "test-model", + ModelId = "test-model", Name = "test", Messages = new List { new Message { Content = "Response", Role = "Assistant", Type = MessageType.LocalLLM} diff --git a/src/MaIN.Core.UnitTests/ChatContextTests.cs b/src/MaIN.Core.UnitTests/ChatContextTests.cs index 6da951c..739e18d 100644 --- a/src/MaIN.Core.UnitTests/ChatContextTests.cs +++ b/src/MaIN.Core.UnitTests/ChatContextTests.cs @@ -101,7 +101,7 @@ public async Task CompleteAsync_ShouldCallChatService() public async Task GetCurrentChat_ShouldCallChatService() { // Arrange - var chat = new Chat { Id = _chatContext.GetChatId(), Model = "default", Name = "test"}; + var chat = new Chat { Id = _chatContext.GetChatId(), ModelId = "default", Name = "test"}; _mockChatService.Setup(s => s.GetById(chat.Id)).ReturnsAsync(chat); // Act diff --git a/src/MaIN.Core.UnitTests/FlowContextTests.cs b/src/MaIN.Core.UnitTests/FlowContextTests.cs index 7db0803..70245b1 100644 --- a/src/MaIN.Core.UnitTests/FlowContextTests.cs +++ b/src/MaIN.Core.UnitTests/FlowContextTests.cs @@ -92,7 +92,7 @@ public async Task ProcessAsync_WithStringMessage_ShouldReturnChatResult() _flowContext.AddAgent(firstAgent); var message = "Hello, flow!"; - var chat = new Chat { Id = firstAgent.Id, Messages = new List(), Model = "default", Name = "test"}; + var chat = new Chat { Id = firstAgent.Id, Messages = new List(), ModelId = "default", Name = "test"}; _mockAgentService .Setup(s => s.GetChatByAgent(firstAgent.Id)) @@ -101,7 +101,7 @@ public async Task ProcessAsync_WithStringMessage_ShouldReturnChatResult() _mockAgentService .Setup(s => s.Process(It.IsAny(), firstAgent.Id, It.IsAny(), It.IsAny(), null, null)) .ReturnsAsync(new Chat { - Model = "test-model", + ModelId = "test-model", Name = "test", Messages = new List { new() { Content = "Response", Role = "Assistant", Type = MessageType.LocalLLM} diff --git a/src/MaIN.Core/Hub/Contexts/AgentContext.cs b/src/MaIN.Core/Hub/Contexts/AgentContext.cs index aa31362..b9fdd09 100644 --- a/src/MaIN.Core/Hub/Contexts/AgentContext.cs +++ b/src/MaIN.Core/Hub/Contexts/AgentContext.cs @@ -242,7 +242,7 @@ public async Task ProcessAsync(Chat chat, bool translate = false) return new ChatResult() { Done = true, - Model = result.Model, + Model = result.ModelId, Message = message, CreatedAt = DateTime.Now }; @@ -271,7 +271,7 @@ public async Task ProcessAsync( return new ChatResult() { Done = true, - Model = result.Model, + Model = result.ModelId, Message = messageResult, CreatedAt = DateTime.Now }; @@ -293,7 +293,7 @@ public async Task ProcessAsync(Message message, return new ChatResult() { Done = true, - Model = result.Model, + Model = result.ModelId, Message = messageResult, CreatedAt = DateTime.Now }; @@ -322,7 +322,7 @@ public async Task ProcessAsync( return new ChatResult() { Done = true, - Model = result.Model, + Model = result.ModelId, Message = messageResult, CreatedAt = DateTime.Now }; diff --git a/src/MaIN.Core/Hub/Contexts/ChatContext.cs b/src/MaIN.Core/Hub/Contexts/ChatContext.cs index d61e293..1293654 100644 --- a/src/MaIN.Core/Hub/Contexts/ChatContext.cs +++ b/src/MaIN.Core/Hub/Contexts/ChatContext.cs @@ -30,7 +30,6 @@ internal ChatContext(IChatService chatService) Messages = [], ModelId = string.Empty }; - _files = []; } internal ChatContext(IChatService chatService, Chat existingChat) @@ -39,14 +38,21 @@ internal ChatContext(IChatService chatService, Chat existingChat) _chat = existingChat; } + public IChatMessageBuilder WithModel(AIModel model) + { + _chat.ModelInstance = model; + _chat.ModelId = model.Id; + _chat.Backend = model.Backend; + return this; + } + public IChatMessageBuilder WithModel() where TModel : AIModel, new() { var model = new TModel(); - SetModel(model); - return this; + return WithModel(model); } - [Obsolete("Use WithModel() instead.")] + [Obsolete("Use WithModel(AIModel model) or WithModel() instead.")] public ChatContext WithModel(string modelId) { var model = ModelRegistry.GetById(modelId); diff --git a/src/MaIN.Core/Hub/Contexts/FlowContext.cs b/src/MaIN.Core/Hub/Contexts/FlowContext.cs index c6ea64c..39d6cb7 100644 --- a/src/MaIN.Core/Hub/Contexts/FlowContext.cs +++ b/src/MaIN.Core/Hub/Contexts/FlowContext.cs @@ -149,7 +149,7 @@ public async Task ProcessAsync(Chat chat, bool translate = false) return new ChatResult() { Done = true, - Model = result.Model, + Model = result.ModelId, Message = message, CreatedAt = DateTime.Now }; @@ -170,7 +170,7 @@ public async Task ProcessAsync(string message, bool translate = fals return new ChatResult() { Done = true, - Model = result.Model, + Model = result.ModelId, Message = messageResult, CreatedAt = DateTime.Now }; @@ -185,7 +185,7 @@ public async Task ProcessAsync(Message message, bool translate = fal return new ChatResult() { Done = true, - Model = result.Model, + Model = result.ModelId, Message = messageResult, CreatedAt = DateTime.Now }; diff --git a/src/MaIN.Core/Hub/Contexts/ModelContext.cs b/src/MaIN.Core/Hub/Contexts/ModelContext.cs index 5bfa66c..5b8d0f0 100644 --- a/src/MaIN.Core/Hub/Contexts/ModelContext.cs +++ b/src/MaIN.Core/Hub/Contexts/ModelContext.cs @@ -4,6 +4,8 @@ using MaIN.Domain.Configuration; using MaIN.Domain.Exceptions.Models; using MaIN.Domain.Models; +using MaIN.Domain.Models.Abstract; +using MaIN.Domain.Models.Concrete; using MaIN.Services.Constants; using MaIN.Services.Services.LLMService.Utils; @@ -25,39 +27,77 @@ internal ModelContext(MaINSettings settings, IHttpClientFactory httpClientFactor _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); } - public List GetAll() => KnownModels.All(); - - public Model GetModel(string model) => KnownModels.GetModel(model); - - public Model GetEmbeddingModel() => KnownModels.GetEmbeddingModel(); - - public bool Exists(string modelName) + /// + /// Gets all registered models. + /// + public IEnumerable GetAll() => ModelRegistry.GetAll(); + + /// + /// Gets all local models. + /// + public IEnumerable GetAllLocal() => ModelRegistry.GetAllLocal(); + + /// + /// Gets a model by its ID. + /// + public AIModel GetModel(string modelId) => ModelRegistry.GetById(modelId); + + /// + /// Gets the embedding model. + /// + public LocalModel GetEmbeddingModel() => new Nomic_Embedding(); + + /// + /// Checks if a local model file exists on disk. + /// + public bool Exists(string modelId) { - if (string.IsNullOrWhiteSpace(modelName)) + if (string.IsNullOrWhiteSpace(modelId)) { - throw new ArgumentException(nameof(modelName)); + throw new ArgumentException(nameof(modelId)); } - var model = KnownModels.GetModel(modelName); - var modelPath = GetModelFilePath(model.FileName); + var model = ModelRegistry.GetById(modelId); + if (model is not LocalModel localModel) + { + return false; // Cloud models don't have local files + } + + var modelPath = GetModelFilePath(localModel.FileName); return File.Exists(modelPath); } - public async Task DownloadAsync(string modelName, CancellationToken cancellationToken = default) + /// + /// Downloads a model by its ID. + /// + public async Task DownloadAsync(string modelId, CancellationToken cancellationToken = default) { - if (string.IsNullOrWhiteSpace(modelName)) + if (string.IsNullOrWhiteSpace(modelId)) { throw new MissingModelNameException(nameof(modelName)); } - var model = KnownModels.GetModel(modelName); - await DownloadModelAsync(model.DownloadUrl!, model.FileName, cancellationToken); + var model = ModelRegistry.GetById(modelId); + if (model is not LocalModel localModel) + { + throw new InvalidOperationException($"Model '{modelId}' is not a local model and cannot be downloaded."); + } + + if (localModel.DownloadUrl == null) + { + throw new InvalidOperationException($"Model '{modelId}' does not have a download URL."); + } + + await DownloadModelAsync(localModel.DownloadUrl.ToString(), localModel.FileName, cancellationToken); return this; } - public async Task DownloadAsync(string model, string url) + /// + /// Downloads a custom model from a URL and registers it. + /// + public async Task DownloadAsync(string modelId, string url) { - if (string.IsNullOrWhiteSpace(model)) + if (string.IsNullOrWhiteSpace(modelId)) { throw new MissingModelNameException(nameof(model)); } @@ -67,31 +107,43 @@ public async Task DownloadAsync(string model, string url) throw new ArgumentException("URL cannot be null or empty", nameof(url)); } - var fileName = $"{model}.gguf"; + var fileName = $"{modelId}.gguf"; await DownloadModelAsync(url, fileName, CancellationToken.None); - var filePath = GetModelFilePath(fileName); - KnownModels.AddModel(model, filePath); + // Register the newly downloaded model + var newModel = new GenericLocalModel( + FileName: fileName, + Name: modelId, + Id: modelId, + DownloadUrl: new Uri(url) + ); + ModelRegistry.RegisterOrReplace(newModel); + return this; } [Obsolete("Use async method instead")] - public IModelContext Download(string modelName) + public IModelContext Download(string modelId) { - if (string.IsNullOrWhiteSpace(modelName)) + if (string.IsNullOrWhiteSpace(modelId)) { - throw new MissingModelNameException(nameof(modelName)); + throw new ArgumentException(MissingModelName, nameof(modelId)); } - var model = KnownModels.GetModel(modelName); - DownloadModelSync(model.DownloadUrl!, model.FileName); + var model = ModelRegistry.GetById(modelId); + if (model is not LocalModel localModel || localModel.DownloadUrl == null) + { + throw new MissingModelNameException(nameof(modelName)); + } + + DownloadModelSync(localModel.DownloadUrl.ToString(), localModel.FileName); return this; } - [Obsolete("Obsolete async method instead")] - public IModelContext Download(string model, string url) + [Obsolete("Use async method instead")] + public IModelContext Download(string modelId, string url) { - if (string.IsNullOrWhiteSpace(model)) + if (string.IsNullOrWhiteSpace(modelId)) { throw new MissingModelNameException(nameof(model)); } @@ -101,15 +153,24 @@ public IModelContext Download(string model, string url) throw new ArgumentException("URL cannot be null or empty", nameof(url)); } - var fileName = $"{model}.gguf"; + var fileName = $"{modelId}.gguf"; DownloadModelSync(url, fileName); - var filePath = GetModelFilePath(fileName); - KnownModels.AddModel(model, filePath); + var newModel = new GenericLocalModel( + FileName: fileName, + Name: modelId, + Id: modelId, + DownloadUrl: new Uri(url) + ); + ModelRegistry.RegisterOrReplace(newModel); + return this; } - public IModelContext LoadToCache(Model model) + /// + /// Loads a local model into cache for faster subsequent access. + /// + public IModelContext LoadToCache(LocalModel model) { ArgumentNullException.ThrowIfNull(model); @@ -118,7 +179,10 @@ public IModelContext LoadToCache(Model model) return this; } - public async Task LoadToCacheAsync(Model model) + /// + /// Loads a local model into cache asynchronously. + /// + public async Task LoadToCacheAsync(LocalModel model) { ArgumentNullException.ThrowIfNull(model); diff --git a/src/MaIN.Domain/Entities/Chat.cs b/src/MaIN.Domain/Entities/Chat.cs index 80f9889..babe5bd 100644 --- a/src/MaIN.Domain/Entities/Chat.cs +++ b/src/MaIN.Domain/Entities/Chat.cs @@ -9,7 +9,7 @@ public class Chat { public string Id { get; init; } = string.Empty; public required string Name { get; init; } - public required string ModelId { get; set; } + public required string ModelId { get; set; } // TODO: make set private and settable by the ModelInstance setter public AIModel? ModelInstance { get; set; } public List Messages { get; set; } = []; public ChatType Type { get; set; } = ChatType.Conversation; diff --git a/src/MaIN.Domain/Models/Abstract/AIModel.cs b/src/MaIN.Domain/Models/Abstract/AIModel.cs index 60b1388..f5b7542 100644 --- a/src/MaIN.Domain/Models/Abstract/AIModel.cs +++ b/src/MaIN.Domain/Models/Abstract/AIModel.cs @@ -1,42 +1,236 @@ -using MaIN.Domain.Configuration; // TODO: change localization +using MaIN.Domain.Configuration; namespace MaIN.Domain.Models.Abstract; -public abstract class AIModel +public abstract record AIModel( + string Id, + BackendType Backend, + string? Name = null, + uint MaxContextWindowSize = 128000, + string? Description = null, + string? SystemMessage = null) { /// Internal Id. For cloud models it is the cloud Id. - public abstract string Id { get; } + public virtual string Id { get; } = Id; /// Name displayed to users. - public abstract string Name { get; } + public virtual string Name { get; } = Name ?? Id; /// Gets the type of backend used by the model eg OpenAI or Self (Local). - public abstract BackendType Backend { get; } + public virtual BackendType Backend { get; } = Backend; /// System Message added before first prompt. - public virtual string? SystemMessage { get; } + public virtual string? SystemMessage { get; } = SystemMessage; /// Model description eg. capabilities or purpose. - public virtual string? Description { get; } + public virtual string? Description { get; } = Description; - /// Max context widnow size supported by the model. - public abstract uint MaxContextWindowSize { get; } + /// Max context window size supported by the model. + public virtual uint MaxContextWindowSize { get; } = MaxContextWindowSize; + + /// Checks if model supports reasoning/thinking mode. + public bool HasReasoning => this is IReasoningModel; + + /// Checks if model supports vision/image input. + public bool HasVision => this is IVisionModel; } -public abstract class LocalModel : AIModel +/// Base class for local models. +public abstract record LocalModel( + string Id, + string FileName, + Uri? DownloadUrl = null, + string? Name = null, + uint MaxContextWindowSize = 128000, + string? Description = null, + string? SystemMessage = null) : AIModel(Id, BackendType.Self, Name, MaxContextWindowSize, Description, SystemMessage) { /// Name of the model file on the hard drive eg. Gemma2-2b.gguf - public abstract string FileName { get; } + public virtual string FileName { get; } = FileName; /// Uri to download model eg. https://huggingface.co/Inza124/gemma2_2b/resolve/main/gemma2-2b-maIN.gguf?download=true - public abstract Uri DownloadUrl { get; } - public override BackendType Backend => BackendType.Self; + public virtual Uri? DownloadUrl { get; } = DownloadUrl; - public bool IsDownloaded(string basePath) + public virtual bool IsDownloaded(string basePath) => File.Exists(Path.Combine(basePath, FileName)); + + public virtual string GetFullPath(string basePath) + => Path.Combine(basePath, FileName); +} + +/// Base class for cloud models. +public abstract record CloudModel( + string Id, + BackendType Backend, + string? Name = null, + uint MaxContextWindowSize = 128000, + string? Description = null, + string? SystemMessage = null) : AIModel(Id, Backend, Name, MaxContextWindowSize, Description, SystemMessage) +{ +} + +/// Generic class for runtime defined cloud models. +public record GenericCloudModel( + string Id, + BackendType Backend, + string? Name = null, + uint MaxContextWindowSize = 128000, + string? Description = null, + string? SystemMessage = null +) : CloudModel(Id, Backend, Name, MaxContextWindowSize, Description, SystemMessage) +{ +} + +/// Generic class for runtime defined cloud models with reasoning capability. +public record GenericCloudReasoningModel( + string Id, + BackendType Backend, + string? Name = null, + uint MaxContextWindowSize = 128000, + string? Description = null, + string? SystemMessage = null, + string? AdditionalPrompt = null +) : CloudModel(Id, Backend, Name, MaxContextWindowSize, Description, SystemMessage), IReasoningModel +{ + // IReasoningModel - null for cloud (handled by provider API) + public Func? ReasonFunction => null; + public string? AdditionalPrompt { get; } = AdditionalPrompt; +} + +/// Generic class for runtime defined cloud models with vision capability. +public record GenericCloudVisionModel( + string Id, + BackendType Backend, + string? Name = null, + uint MaxContextWindowSize = 128000, + string? Description = null, + string? SystemMessage = null +) : CloudModel(Id, Backend, Name, MaxContextWindowSize, Description, SystemMessage), IVisionModel +{ + // IVisionModel - cloud models don't need MMProjectPath + public string? MMProjectPath => null; +} + +/// Generic class for runtime defined cloud models with both vision and reasoning capabilities. +public record GenericCloudVisionReasoningModel( + string Id, + BackendType Backend, + string? Name = null, + uint MaxContextWindowSize = 128000, + string? Description = null, + string? SystemMessage = null, + string? AdditionalPrompt = null +) : CloudModel(Id, Backend, Name, MaxContextWindowSize, Description, SystemMessage), IVisionModel, IReasoningModel +{ + // IVisionModel - null for cloud (handled by provider API) + public string? MMProjectPath => null; + + // IReasoningModel - null for cloud (handled by provider API) + public Func? ReasonFunction => null; + public string? AdditionalPrompt { get; } = AdditionalPrompt; +} + +/// Generic class for runtime defined local models. +public record GenericLocalModel( + string FileName, + string? Name = null, + string? Id = null, + Uri? DownloadUrl = null, + uint MaxContextWindowSize = 4096, + string? CustomPath = null, + string? Description = null, + string? SystemMessage = null +) : LocalModel(Id ?? FileName, FileName, DownloadUrl, Name ?? FileName, MaxContextWindowSize, Description, SystemMessage) +{ + /// Custom path override for the model file (only for dynamically loaded models). + public string? CustomPath { get; set; } = CustomPath; + + public override bool IsDownloaded(string basePath) + => File.Exists(Path.Combine(CustomPath ?? basePath, FileName)); + + public override string GetFullPath(string basePath) + => Path.Combine(CustomPath ?? basePath, FileName); +} + +/// Generic class for runtime defined local models with reasoning capability. +public record GenericReasoningModel( + string FileName, + Func ReasonFunction, + string? Name = null, + string? Id = null, + Uri? DownloadUrl = null, + uint MaxContextWindowSize = 4096, + string? CustomPath = null, + string? AdditionalPrompt = null, + string? Description = null, + string? SystemMessage = null +) : LocalModel(Id ?? FileName, FileName, DownloadUrl, Name ?? FileName, MaxContextWindowSize, Description, SystemMessage), IReasoningModel +{ + public string? CustomPath { get; set; } = CustomPath; + + // IReasoningModel implementation + public Func ReasonFunction { get; } = ReasonFunction; + public string? AdditionalPrompt { get; } = AdditionalPrompt; + + public override bool IsDownloaded(string basePath) + => File.Exists(Path.Combine(CustomPath ?? basePath, FileName)); + + public override string GetFullPath(string basePath) + => Path.Combine(CustomPath ?? basePath, FileName); +} + +/// Generic class for runtime defined local models with vision capability. +public record GenericVisionModel( + string FileName, + string MMProjectPath, + string? Name = null, + string? Id = null, + Uri? DownloadUrl = null, + uint MaxContextWindowSize = 4096, + string? CustomPath = null, + string? Description = null, + string? SystemMessage = null +) : LocalModel(Id ?? FileName, FileName, DownloadUrl, Name ?? FileName, MaxContextWindowSize, Description, SystemMessage), IVisionModel +{ + public string? CustomPath { get; set; } = CustomPath; + + // IVisionModel implementation + public string MMProjectPath { get; } = MMProjectPath; + + public override bool IsDownloaded(string basePath) + => File.Exists(Path.Combine(CustomPath ?? basePath, FileName)); + + public override string GetFullPath(string basePath) + => Path.Combine(CustomPath ?? basePath, FileName); } -public abstract class CloudModel : AIModel +/// Generic class for runtime defined local models with both vision and reasoning capabilities. +public record GenericVisionReasoningModel( + string FileName, + string MMProjectPath, + Func ReasonFunction, + string? Name = null, + string? Id = null, + Uri? DownloadUrl = null, + uint MaxContextWindowSize = 4096, + string? CustomPath = null, + string? AdditionalPrompt = null, + string? Description = null, + string? SystemMessage = null +) : LocalModel(Id ?? FileName, FileName, DownloadUrl, Name ?? FileName, MaxContextWindowSize, Description, SystemMessage), IVisionModel, IReasoningModel { - public abstract override BackendType Backend { get; } + public string? CustomPath { get; set; } = CustomPath; + + // IVisionModel implementation + public string MMProjectPath { get; } = MMProjectPath; + + // IReasoningModel implementation + public Func ReasonFunction { get; } = ReasonFunction; + public string? AdditionalPrompt { get; } = AdditionalPrompt; + + public override bool IsDownloaded(string basePath) + => File.Exists(Path.Combine(CustomPath ?? basePath, FileName)); + + public override string GetFullPath(string basePath) + => Path.Combine(CustomPath ?? basePath, FileName); } diff --git a/src/MaIN.Domain/Models/Abstract/IModelCapabilities.cs b/src/MaIN.Domain/Models/Abstract/IModelCapabilities.cs new file mode 100644 index 0000000..63dd55b --- /dev/null +++ b/src/MaIN.Domain/Models/Abstract/IModelCapabilities.cs @@ -0,0 +1,56 @@ +using MaIN.Domain.Models; + +namespace MaIN.Domain.Models.Abstract; + +/// +/// Interface for models that support vision/image input capabilities. +/// +public interface IVisionModel +{ + /// + /// Path to multimodal projector file required for vision processing. + /// Null for cloud models (handled by provider API). + /// + string? MMProjectPath { get; } +} + +/// +/// Interface for models that support reasoning/thinking capabilities. +/// +public interface IReasoningModel +{ + /// + /// Function to process reasoning tokens. + /// Null for cloud models (reasoning handled by provider API). + /// + Func? ReasonFunction { get; } + + /// + /// Additional prompt added to enable reasoning mode. + /// + string? AdditionalPrompt { get; } +} + +// TODO: use it with existing embedding model +/// +/// Interface for models that support embeddings generation. +/// +public interface IEmbeddingModel +{ + /// + /// Dimension of the embedding vector. + /// + int EmbeddingDimension { get; } +} + +// TODO: use it with existing TTS model +/// +/// Interface for models that support text-to-speech. +/// +public interface ITTSModel +{ + /// + /// Available voices for the TTS model. + /// + IReadOnlyList AvailableVoices { get; } +} diff --git a/src/MaIN.Domain/Models/Abstract/ModelRegistry.cs b/src/MaIN.Domain/Models/Abstract/ModelRegistry.cs index 2b08567..dfd25b6 100644 --- a/src/MaIN.Domain/Models/Abstract/ModelRegistry.cs +++ b/src/MaIN.Domain/Models/Abstract/ModelRegistry.cs @@ -1,38 +1,95 @@ using MaIN.Domain.Exceptions; +using System.Collections.Concurrent; using System.Reflection; namespace MaIN.Domain.Models.Abstract; public static class ModelRegistry { - private static readonly Dictionary _models = new(StringComparer.OrdinalIgnoreCase); + private static readonly ConcurrentDictionary _models = new(StringComparer.OrdinalIgnoreCase); + private static bool _initialized = false; + private static readonly object _lock = new(); static ModelRegistry() { - // Reflection but only at startup to register all available models - var modelTypes = Assembly.GetExecutingAssembly() - .GetTypes() - .Where(t => t.IsSubclassOf(typeof(AIModel)) && !t.IsAbstract); + Initialize(); + } - foreach (var type in modelTypes) + private static void Initialize() + { + if (_initialized) return; + + lock (_lock) { - if (Activator.CreateInstance(type) is AIModel instance) + if (_initialized) return; + + // Reflection but only at startup to register all available models + // Skip abstract, generic types, and Generic* classes (they're for runtime registration) + var modelTypes = Assembly.GetExecutingAssembly() + .GetTypes() + .Where(t => t.IsSubclassOf(typeof(AIModel)) + && !t.IsAbstract + && !t.IsGenericType + && !t.Name.StartsWith("Generic")); + + foreach (var type in modelTypes) { - if (string.IsNullOrWhiteSpace(instance.Id)) + try { - throw new ModelException($"Model type {type.Name} has an empty or null Id."); + if (Activator.CreateInstance(type) is AIModel instance) + { + Register(instance); + } } - - var normalizedId = instance.Id.Trim().Replace(':', '-'); - - if (!_models.TryAdd(normalizedId, instance)) + catch (Exception ex) { - throw new InvalidOperationException($"Duplicate Model ID detected: '{normalizedId}'. Classes: {_models[normalizedId].GetType().Name} and {type.Name}"); + Console.WriteLine($"Warning: Could not register model type {type.Name}: {ex.Message}"); } } + + _initialized = true; + } + } + + /// + /// Registers a custom model at runtime (e.g., dynamically loaded GGUF file). + /// + public static void Register(AIModel model) + { + ArgumentNullException.ThrowIfNull(model); + + if (string.IsNullOrWhiteSpace(model.Id)) + { + throw new ArgumentException("Model ID cannot be null or empty.", nameof(model)); + } + + var normalizedId = NormalizeId(model.Id); + + if (!_models.TryAdd(normalizedId, model)) + { + throw new InvalidOperationException($"Model with ID '{model.Id}' is already registered."); } } + /// + /// Registers a custom model at runtime, replacing existing if present. + /// + public static void RegisterOrReplace(AIModel model) + { + ArgumentNullException.ThrowIfNull(model); + + if (string.IsNullOrWhiteSpace(model.Id)) + { + throw new ArgumentException("Model ID cannot be null or empty.", nameof(model)); + } + + var normalizedId = NormalizeId(model.Id); + _models[normalizedId] = model; + } + + /// + /// Gets a model by its ID. + /// public static AIModel GetById(string id) { if (string.IsNullOrWhiteSpace(id)) @@ -40,17 +97,81 @@ public static AIModel GetById(string id) throw new ArgumentException("Model ID cannot be null or empty.", nameof(id)); } - if (_models.TryGetValue(id.Trim(), out var model)) + var normalizedId = NormalizeId(id); + + if (_models.TryGetValue(normalizedId, out var model)) { return model; } - var availableIds = string.Join(", ", _models.Keys); - throw new KeyNotFoundException($"Model with ID '{id}' not found. Available models: {availableIds}"); + var availableIds = string.Join(", ", _models.Keys.Take(10)); + throw new KeyNotFoundException($"Model with ID '{id}' not found. Available models (first 10): {availableIds}"); + } + + /// + /// Tries to get a model by its ID. + /// + public static bool TryGetById(string id, out AIModel? model) + { + model = null; + + if (string.IsNullOrWhiteSpace(id)) + { + return false; + } + + var normalizedId = NormalizeId(id); + return _models.TryGetValue(normalizedId, out model); + } + + /// + /// Gets a local model by its filename. + /// + public static LocalModel? GetByFileName(string fileName) + { + if (string.IsNullOrWhiteSpace(fileName)) + { + return null; + } + + return _models.Values + .OfType() + .FirstOrDefault(m => m.FileName.Equals(fileName, StringComparison.OrdinalIgnoreCase)); } + /// + /// Gets all registered models. + /// public static IEnumerable GetAll() => _models.Values; + /// + /// Gets all local models. + /// + public static IEnumerable GetAllLocal() => _models.Values.OfType(); + + /// + /// Gets all cloud models. + /// + public static IEnumerable GetAllCloud() => _models.Values.OfType(); + + /// + /// Checks if a model with the given ID exists. + /// public static bool Exists(string id) => - !string.IsNullOrWhiteSpace(id) && _models.ContainsKey(id.Trim()); + !string.IsNullOrWhiteSpace(id) && _models.ContainsKey(NormalizeId(id)); + + /// + /// Removes a model from the registry (useful for dynamically loaded models). + /// + public static bool Unregister(string id) + { + if (string.IsNullOrWhiteSpace(id)) + { + return false; + } + + return _models.TryRemove(NormalizeId(id), out _); + } + + private static string NormalizeId(string id) => id.Trim().Replace(':', '-'); } diff --git a/src/MaIN.Domain/Models/Concrete/Gemma_2b.cs b/src/MaIN.Domain/Models/Concrete/Gemma_2b.cs deleted file mode 100644 index 3215551..0000000 --- a/src/MaIN.Domain/Models/Concrete/Gemma_2b.cs +++ /dev/null @@ -1,15 +0,0 @@ -using MaIN.Domain.Configuration; -using MaIN.Domain.Models.Abstract; - -namespace MaIN.Domain.Models.Concrete; - -public sealed class Gemma_2b : LocalModel -{ - public override string Id => "Gemma2-2B"; - public override string Name => "Gemma2-2B"; - public override string FileName => "Gemma2-2b.gguf"; - public override Uri DownloadUrl => new("https://huggingface.co/Inza124/gemma2_2b/resolve/main/gemma2-2b-maIN.gguf?download=true"); - public override string Description => "Compact 2B model for text generation, summarization, and simple Q&A"; - - public override uint MaxContextWindowSize => 8192; // TODO: verify -} diff --git a/src/MaIN.Domain/Models/Concrete/Gpt4o.cs b/src/MaIN.Domain/Models/Concrete/Gpt4o.cs index a54ad75..ee50324 100644 --- a/src/MaIN.Domain/Models/Concrete/Gpt4o.cs +++ b/src/MaIN.Domain/Models/Concrete/Gpt4o.cs @@ -3,7 +3,7 @@ namespace MaIN.Domain.Models.Concrete; -public sealed class Gpt4o : CloudModel +public sealed record Gpt4o : CloudModel { public override string Id => "gpt-4o"; public override string Name => "GPT-4 Omni"; diff --git a/src/MaIN.Domain/Models/Concrete/LocalModels.cs b/src/MaIN.Domain/Models/Concrete/LocalModels.cs new file mode 100644 index 0000000..971dd88 --- /dev/null +++ b/src/MaIN.Domain/Models/Concrete/LocalModels.cs @@ -0,0 +1,345 @@ +using MaIN.Domain.Models.Abstract; +using MaIN.Domain.Models; + +namespace MaIN.Domain.Models.Concrete; + +// ===== Gemma Family ===== + +public sealed record Gemma_2b() : LocalModel( + "gemma-2b", + "Gemma-2b.gguf", + new Uri("https://huggingface.co/Inza124/Gemma-2b/resolve/main/gemma-2b-maIN.gguf?download=true"), + "Gemma 2B", + 8192, + "Lightweight 2B model for general-purpose text generation and understanding") +{ +} + +public sealed record Gemma3_4b() : LocalModel( + "gemma3-4b", + "Gemma3-4b.gguf", + new Uri("https://huggingface.co/Inza124/Gemma3-4b/resolve/main/gemma3-4b.gguf?download=true"), + "Gemma3 4B", + 8192, + "Balanced 4B model for writing, analysis, and mathematical reasoning") +{ +} + +public sealed record Gemma3_12b() : LocalModel( + "gemma3-12b", + "Gemma3-12b.gguf", + new Uri("https://huggingface.co/Inza124/Gemma3-12b/resolve/main/gemma3-12b.gguf?download=true"), + "Gemma3 12B", + 8192, + "Large 12B model for complex analysis, research, and creative writing") +{ +} + +public sealed record Gemma3n_e4b() : LocalModel( + "gemma3n-e4b", + "Gemma3n-e4b.gguf", + new Uri("https://huggingface.co/Inza124/Gemma-3n-e4b/resolve/main/gemma-3n-e4b.gguf?download=true"), + "Gemma3n E4B", + 8192, + "Compact 4B model optimized for efficient reasoning and general-purpose tasks") +{ +} + +// ===== Llama Family ===== + +public sealed record Llama3_2_3b() : LocalModel( + "llama3.2-3b", + "Llama3.2-3b.gguf", + new Uri("https://huggingface.co/Inza124/Llama3.2_3b/resolve/main/Llama3.2-maIN.gguf?download=true"), + "Llama 3.2 3B", + 8192, + "Lightweight 3B model for chatbots, content creation, and basic coding") +{ +} + +public sealed record Llama3_1_8b() : LocalModel( + "llama3.1-8b", + "Llama3.1-8b.gguf", + new Uri("https://huggingface.co/Inza124/Llama3.1_8b/resolve/main/Llama3.1-maIN.gguf?download=true"), + "Llama 3.1 8B", + 8192, + "Versatile 8B model for writing, coding, math, and general assistance") +{ +} + +public sealed record Llava_7b() : LocalModel( + "llava-7b", + "Llava.gguf", + new Uri("https://huggingface.co/Inza124/Llava/resolve/main/Llava-maIN.gguf?download=true"), + "LLaVA 7B", + 4096, + "Vision-language model for image analysis, OCR, and visual Q&A"), IVisionModel +{ + public string MMProjectPath => "mmproj-model-f16.gguf"; +} + +// ===== Hermes Family ===== + +public sealed record Hermes3_3b() : LocalModel( + "hermes3-3b", + "Hermes3-3b.gguf", + new Uri("https://huggingface.co/Inza124/Hermes3-3b/resolve/main/hermes3-3b.gguf?download=true"), + "Hermes 3 3B", + 8192, + "Efficient 3B model for dialogue, roleplay, and conversational AI") +{ +} + +public sealed record Hermes3_8b() : LocalModel( + "hermes3-8b", + "Hermes3-8b.gguf", + new Uri("https://huggingface.co/Inza124/Hermes3_8b/resolve/main/hermes3-8b.gguf?download=true"), + "Hermes 3 8B", + 8192, + "Enhanced 8B model for complex dialogue, storytelling, and advice") +{ +} + +// ===== Qwen Family ===== + +public sealed record Qwen2_5_0_5b() : LocalModel( + "qwen2.5-0.5b", + "Qwen2.5-0.5b.gguf", + new Uri("https://huggingface.co/Inza124/Qwen2.5/resolve/main/Qwen2.5-maIN.gguf?download=true"), + "Qwen 2.5 0.5B", + 4096, + "Ultra-lightweight 0.5B model for simple text completion and basic tasks") +{ +} + +public sealed record Qwen2_5_Coder_3b() : LocalModel( + "qwen2.5-coder-3b", + "Qwen2.5-coder-3b.gguf", + new Uri("https://huggingface.co/Inza124/Qwen2.5-Coder-3b/resolve/main/Qwen2.5-coder-3b.gguf?download=true"), + "Qwen 2.5 Coder 3B", + 8192, + "Compact 3B model for Python, JavaScript, bug fixing, and code review") +{ +} + +public sealed record Qwen2_5_Coder_7b() : LocalModel( + "qwen2.5-coder-7b", + "Qwen2.5-coder-7b.gguf", + new Uri("https://huggingface.co/Inza124/Qwen2.5-Coder-7b/resolve/main/Qwen2.5-coder-7b.gguf?download=true"), + "Qwen 2.5 Coder 7B", + 8192, + "Advanced 7B model for full-stack development, API design, and testing") +{ +} + +public sealed record Qwen2_5_Coder_14b() : LocalModel( + "qwen2.5-coder-14b", + "Qwen2.5-coder-14b.gguf", + new Uri("https://huggingface.co/Inza124/Qwen2.5-Coder-14b/resolve/main/Qwen2.5-coder-14b.gguf?download=true"), + "Qwen 2.5 Coder 14B", + 8192, + "Professional 14B model for system design, architecture, and code refactoring") +{ +} + +public sealed record Qwen3_8b() : LocalModel( + "qwen3-8b", + "Qwen3-8b.gguf", + new Uri("https://huggingface.co/Inza124/Qwen3-8b/resolve/main/Qwen3-8b.gguf?download=true"), + "Qwen 3 8B", + 8192, + "Fast 8B model for multilingual tasks, translation, and logical reasoning" + ), IReasoningModel +{ + // IReasoningModel implementation + public Func? ReasonFunction => ReasoningFunctions.ProcessDeepSeekToken; + public string? AdditionalPrompt => null; +} + +public sealed record Qwen3_14b() : LocalModel( + "qwen3-14b", + "Qwen3-14b.gguf", + new Uri("https://huggingface.co/Inza124/Qwen3-14b/resolve/main/Qwen3-14b.gguf?download=true"), + "Qwen 3 14B", + 8192, + "Advanced 14B model for complex reasoning, research, and document analysis"), IReasoningModel +{ + public Func? ReasonFunction => ReasoningFunctions.ProcessDeepSeekToken; + public string? AdditionalPrompt => null; +} + +public sealed record QwQ_7b() : LocalModel( + "qwq-7b", + "QwQ-7b.gguf", + new Uri("https://huggingface.co/Inza124/QwQ-7b/resolve/main/qwq-7b.gguf?download=true"), + "QwQ 7B", + 8192, + "Reasoning-focused 7B model for step-by-step problem solving and analysis"), IReasoningModel +{ + public Func? ReasonFunction => ReasoningFunctions.ProcessQwQ_QwenModToken; + public string? AdditionalPrompt => "- Output nothing before , enclose all step-by-step reasoning (excluding the final answer) within ..., and place the final answer immediately after the closing "; +} + +// ===== DeepSeek Family ===== + +public sealed record DeepSeek_R1_8b() : LocalModel( + "deepseekr1-8b", + "DeepSeekR1-8b.gguf", + new Uri("https://huggingface.co/Inza124/DeepseekR1-8b/resolve/main/DeepSeekR1-8b-maIN.gguf?download=true"), + "DeepSeek R1 8B", + 8192, + "Advanced 8B model for math proofs, scientific reasoning, and logical puzzles"), IReasoningModel +{ + public Func? ReasonFunction => ReasoningFunctions.ProcessDeepSeekToken; + public string? AdditionalPrompt => null; +} + +public sealed record DeepSeek_R1_1_5b() : LocalModel( + "deepseekr1-1.5b", + "DeepSeekR1-1.5b.gguf", + new Uri("https://huggingface.co/Inza124/DeepseekR1-1.5b/resolve/main/DeepSeekR1-1.5b.gguf?download=true"), + "DeepSeek R1 1.5B", + 4096, + "Compact 1.5B model for basic logic, simple math, and chain-of-thought tasks"), IReasoningModel +{ + public Func? ReasonFunction => ReasoningFunctions.ProcessDeepSeekToken; + public string? AdditionalPrompt => null; +} + +// ===== Phi Family ===== + +public sealed record Phi3_5_3b() : LocalModel( + "phi3.5-3b", + "phi3.5-3b.gguf", + new Uri("https://huggingface.co/Inza124/phi3.5-3b/resolve/main/phi3.5-3b.gguf?download=true"), + "Phi 3.5 3B", + 4096, + "Efficient 3B model for mobile apps, IoT devices, and edge computing") +{ +} + +public sealed record Phi4_4b() : LocalModel( + "phi4-4b", + "phi4-4b.gguf", + new Uri("https://huggingface.co/Inza124/Phi4-4b/resolve/main/phi4-4b.gguf?download=true"), + "Phi 4 4B", + 4096, + "Latest 4B model for factual Q&A, safety-focused applications, and education") +{ +} + +// ===== Other Models ===== + +public sealed record LFM2_1_2b() : LocalModel( + "lfm2-1.2b", + "lfm2-1.2b.gguf", + new Uri("https://huggingface.co/Inza124/Lfm2-1.2b/resolve/main/lfm2-1.2b.gguf?download=true"), + "LFM2 1.2B", + 4096, + "Lightweight modern 1.2B model for fast inference and resource-constrained environments") +{ +} + +public sealed record Minicpm4_8b() : LocalModel( + "minicpm4-8b", + "Minicpm4-8b.gguf", + new Uri("https://huggingface.co/Inza124/Minicpm4-8b/resolve/main/MiniCPM4-8b.gguf?download=true"), + "MiniCPM4 8B", + 8192, + "Mid-size 8B model balancing performance and efficiency for diverse applications") +{ +} + +public sealed record Mistral_3_2_24b() : LocalModel( + "mistral-3.2-24b", + "Mistral3.2-24b.gguf", + new Uri("https://huggingface.co/Inza124/Mistral3.2-24b/resolve/main/Mistral3.2-24b.gguf?download=true"), + "Mistral 3.2 24B", + 8192, + "Large 24B model offering advanced reasoning and comprehensive knowledge for complex tasks") +{ +} + +public sealed record Webgen_4b() : LocalModel( + "webgen-4b", + "webgen-4b.gguf", + new Uri("https://huggingface.co/Inza124/webgen-4b/resolve/main/Webgen-4b.gguf?download=true"), + "Webgen 4B", + 8192, + "Specialized 4B model optimized for web development and code generation tasks") +{ +} + +public sealed record Bielik_2_5_11b() : LocalModel( + "bielik-2.5-11b", + "Bielik2.5-11b.gguf", + new Uri("https://huggingface.co/Inza124/Bielik2.5-11b/resolve/main/Bielik2.5-11b.gguf?download=true"), + "Bielik 2.5 11B", + 8192, + "Large 11B Polish language model with strong multilingual capabilities and reasoning") +{ +} + +public sealed record OlympicCoder_7b() : LocalModel( + "olympiccoder-7b", + "Olympiccoder-7b.gguf", + new Uri("https://huggingface.co/Inza124/OlympicCoder-7b/resolve/main/OlympicCoder-7b.gguf?download=true"), + "OlympicCoder 7B", + 8192, + "Specialized 7B model for algorithms, data structures, and contest programming") +{ +} + +public sealed record Yi_6b() : LocalModel( + "yi-6b", + "Yi-6b.gguf", + new Uri("https://huggingface.co/Inza124/yi-6b/resolve/main/yi-6b.gguf?download=true"), + "Yi 6B", + 4096, + "Bilingual 6B model for Chinese-English translation and cultural content") +{ +} + +public sealed record Smollm2_0_1b() : LocalModel( + "smollm2-0.1b", + "Smollm2-0.1b.gguf", + new Uri("https://huggingface.co/Inza124/Smollm2-0.1b/resolve/main/smollm2-0.1b.gguf?download=true"), + "SmolLM2 0.1B", + 2048, + "Tiny 0.1B model for keyword extraction, simple classification, and demos") +{ +} + +public sealed record Olmo2_7b() : LocalModel( + "olmo2-7b", + "Olmo2-7b.gguf", + new Uri("https://huggingface.co/Inza124/Olmo2-7b/resolve/main/olmo2-7b.gguf?download=true"), + "OLMo2 7B", + 8192, + "Open-source 7B model for research, benchmarking, and academic studies") +{ +} + +// ===== Embedding Model ===== + +public sealed record Nomic_Embedding() : LocalModel( + "nomic-embedding", + "nomicv2.gguf", + new Uri("https://huggingface.co/Inza124/Nomic/resolve/main/nomicv2.gguf?download=true"), + "Nomic Embedding", + 8192, + "Model used to generate embeddings") +{ +} + +// ===== TTS Model ===== + +public sealed record Kokoro_82m() : LocalModel( + "kokoro-82m", + "kokoro.onnx", + new Uri("https://github.com/taylorchu/kokoro-onnx/releases/download/v0.2.0/kokoro.onnx"), + "Kokoro 82M", + 4096, + "Frontier TTS model for its size of 82 million parameters (text in/audio out)") +{ +} diff --git a/src/MaIN.Domain/Models/SupportedModels.cs b/src/MaIN.Domain/Models/SupportedModels.cs index d2fd387..76e936e 100644 --- a/src/MaIN.Domain/Models/SupportedModels.cs +++ b/src/MaIN.Domain/Models/SupportedModels.cs @@ -1,7 +1,9 @@ +using MaIN.Domain.Models.Abstract; using MaIN.Domain.Exceptions.Models; namespace MaIN.Domain.Models; +[Obsolete("Use AIModel and ModelRegistry instead. This class will be removed in future versions.")] public class Model { public required string Name { get; init; } @@ -15,6 +17,7 @@ public class Model public bool HasReasoning() => ReasonFunction is not null; } +[Obsolete("Use ModelRegistry instead. This class will be removed in future versions.")] public static class KnownModels { private static List Models { get; } = diff --git a/src/MaIN.InferPage/Components/Pages/Home.razor b/src/MaIN.InferPage/Components/Pages/Home.razor index 0c9c272..1711683 100644 --- a/src/MaIN.InferPage/Components/Pages/Home.razor +++ b/src/MaIN.InferPage/Components/Pages/Home.razor @@ -7,6 +7,7 @@ @using MaIN.Domain.Entities @using MaIN.Domain.Exceptions @using MaIN.Domain.Models +@using MaIN.Domain.Models.Abstract @using Markdig @using Microsoft.FluentUI.AspNetCore.Components.Icons.Regular @using Message = MaIN.Domain.Entities.Message @@ -191,9 +192,8 @@ private string? _incomingMessage = null; private string? _incomingReasoning = null; private readonly string? _displayName = Utils.Model; - private IChatMessageBuilder? ctxBuilder; - // private ChatContext? ctx; - private Chat Chat { get; } = new() { Name = "MaIN Infer", Model = Utils.Model! }; + private IChatMessageBuilder? ctx; + private Chat Chat { get; } = new() { Name = "MaIN Infer", ModelId = Utils.Model! }; private List Messages { get; set; } = new(); private ElementReference? _bottomElement; private int _inputKey = 0; @@ -221,7 +221,8 @@ } else if (!Utils.OpenAi) { - _reasoning = !Utils.Visual && KnownModels.GetModel(Utils.Model!).HasReasoning(); + var model = ModelRegistry.TryGetById(Utils.Model!, out var foundModel) ? foundModel : null; + _reasoning = !Utils.Visual && model?.HasReasoning == true; Utils.Reason = _reasoning; } @@ -248,7 +249,7 @@ { Message = newMsg }); - Chat.Model = Utils.Model!; + Chat.ModelId = Utils.Model!; _isLoading = true; Chat.Visual = Utils.Visual; _inputKey++; diff --git a/src/MaIN.Services/Services/AgentService.cs b/src/MaIN.Services/Services/AgentService.cs index c43c1db..5e93283 100644 --- a/src/MaIN.Services/Services/AgentService.cs +++ b/src/MaIN.Services/Services/AgentService.cs @@ -99,7 +99,7 @@ public async Task CreateAgent(Agent agent, bool flow = false, bool intera var chat = new Chat { Id = Guid.NewGuid().ToString(), - Model = agent.Model, + ModelId = agent.Model, Name = agent.Name, Visual = agent.Model == ImageGenService.LocalImageModels.FLUX, ToolsConfiguration = agent.ToolsConfiguration, diff --git a/src/MaIN.Services/Services/ImageGenServices/GeminiImageGenService.cs b/src/MaIN.Services/Services/ImageGenServices/GeminiImageGenService.cs index 8faffa0..dd223e6 100644 --- a/src/MaIN.Services/Services/ImageGenServices/GeminiImageGenService.cs +++ b/src/MaIN.Services/Services/ImageGenServices/GeminiImageGenService.cs @@ -22,14 +22,14 @@ internal class GeminiImageGenService(IHttpClientFactory httpClientFactory, MaINS string apiKey = _settings.GeminiKey ?? Environment.GetEnvironmentVariable(LLMApiRegistry.Gemini.ApiKeyEnvName) ?? throw new APIKeyNotConfiguredException(LLMApiRegistry.Gemini.ApiName); - if (string.IsNullOrEmpty(chat.Model)) + if (string.IsNullOrEmpty(chat.ModelId)) { - chat.Model = Models.IMAGEN_GENERATE; + chat.ModelId = Models.IMAGEN_GENERATE; } client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); var requestBody = new { - model = chat.Model, + model = chat.ModelId, prompt = BuildPromptFromChat(chat), response_format = "b64_json", // necessary for gemini api size = ServiceConstants.Defaults.ImageSize, diff --git a/src/MaIN.Services/Services/ImageGenServices/ImageGenDalleService.cs b/src/MaIN.Services/Services/ImageGenServices/ImageGenDalleService.cs index 7a0e254..066bdaf 100644 --- a/src/MaIN.Services/Services/ImageGenServices/ImageGenDalleService.cs +++ b/src/MaIN.Services/Services/ImageGenServices/ImageGenDalleService.cs @@ -27,7 +27,7 @@ public class OpenAiImageGenService( client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); var requestBody = new { - model = chat.Model, + model = chat.ModelId, prompt = BuildPromptFromChat(chat), size = ServiceConstants.Defaults.ImageSize }; diff --git a/src/MaIN.Services/Services/ImageGenServices/XaiImageGenService.cs b/src/MaIN.Services/Services/ImageGenServices/XaiImageGenService.cs index ac3ce8c..3b931b4 100644 --- a/src/MaIN.Services/Services/ImageGenServices/XaiImageGenService.cs +++ b/src/MaIN.Services/Services/ImageGenServices/XaiImageGenService.cs @@ -28,7 +28,7 @@ public class XaiImageGenService( client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); var requestBody = new { - model = string.IsNullOrWhiteSpace(chat.Model) ? Models.GROK_IMAGE : chat.Model, + model = string.IsNullOrWhiteSpace(chat.ModelId) ? Models.GROK_IMAGE : chat.ModelId, prompt = BuildPromptFromChat(chat), n = 1, response_format = "b64_json" //or "url" diff --git a/src/MaIN.Services/Services/LLMService/AnthropicService.cs b/src/MaIN.Services/Services/LLMService/AnthropicService.cs index 9685cd5..0372456 100644 --- a/src/MaIN.Services/Services/LLMService/AnthropicService.cs +++ b/src/MaIN.Services/Services/LLMService/AnthropicService.cs @@ -504,7 +504,7 @@ private object BuildAnthropicRequestBody(Chat chat, List conversati { var requestBody = new Dictionary { - ["model"] = chat.Model, + ["model"] = chat.ModelId, ["max_tokens"] = chat.InterferenceParams.MaxTokens < 0 ? 4096 : chat.InterferenceParams.MaxTokens, ["stream"] = stream, ["messages"] = BuildAnthropicMessages(conversation) @@ -645,7 +645,7 @@ private async Task ProcessStreamingChatAsync( var requestBody = new { - model = chat.Model, + model = chat.ModelId, max_tokens = chat.InterferenceParams.MaxTokens < 0 ? 4096 : chat.InterferenceParams.MaxTokens, stream = true, system = chat.InterferenceParams.Grammar is not null @@ -733,7 +733,7 @@ private async Task ProcessNonStreamingChatAsync( var requestBody = new { - model = chat.Model, + model = chat.ModelId, max_tokens = chat.InterferenceParams.MaxTokens < 0 ? 4096 : chat.InterferenceParams.MaxTokens, stream = false, system = chat.InterferenceParams.Grammar is not null @@ -770,7 +770,7 @@ private static ChatResult CreateChatResult(Chat chat, string content, List GetCurrentModels() + public Task GetCurrentModels() // TODO: return AIModel[] { var models = Directory.GetFiles(modelsPath, "*.gguf", SearchOption.AllDirectories) .Select(Path.GetFileName) - .Where(fileName => KnownModels.GetModelByFileName(modelsPath, fileName!) != null) - .Select(fileName => KnownModels.GetModelByFileName(modelsPath, fileName!)!.Name) + .Where(fileName => ModelRegistry.GetByFileName(fileName!) != null) + .Select(fileName => ModelRegistry.GetByFileName(fileName!)!.Name) .ToArray(); return Task.FromResult(models); @@ -100,15 +101,15 @@ public Task CleanSessionCache(string? id) return Task.CompletedTask; } + public async Task AskMemory( Chat chat, ChatMemoryOptions memoryOptions, ChatRequestOptions requestOptions, CancellationToken cancellationToken = default) { - var model = KnownModels.GetModel(chat.Model); - var finalModelPath = model.Path ?? modelsPath; - var parameters = new ModelParams(Path.Combine(finalModelPath, model.FileName)) + var model = GetLocalModel(chat.ModelId); + var parameters = new ModelParams(Path.Combine(modelsPath, model.FileName)) { GpuLayerCount = chat.MemoryParams.GpuLayerCount, ContextSize = (uint)chat.MemoryParams.ContextSize, @@ -116,10 +117,10 @@ public Task CleanSessionCache(string? id) var disableCache = chat.Properties.CheckProperty(ServiceConstants.Properties.DisableCacheProperty); var llmModel = disableCache ? await LLamaWeights.LoadFromFileAsync(parameters, cancellationToken) - : await ModelLoader.GetOrLoadModelAsync(finalModelPath, model.FileName); + : await ModelLoader.GetOrLoadModelAsync(modelsPath, model.FileName); var memory = memoryFactory.CreateMemoryWithModel( - finalModelPath, + modelsPath, llmModel, model.FileName, chat.MemoryParams); @@ -200,7 +201,7 @@ await notificationService.DispatchNotification( { Done = true, CreatedAt = DateTime.Now, - Model = chat.Model, + Model = chat.ModelId, Message = new Message { Content = memoryService.CleanResponseText(result.Result), @@ -212,7 +213,7 @@ await notificationService.DispatchNotification( private async Task> ProcessChatRequest( Chat chat, - Model model, + LocalModel model, Message lastMsg, ChatRequestOptions requestOptions, CancellationToken cancellationToken) @@ -221,15 +222,15 @@ private async Task> ProcessChatRequest( var thinkingState = new ThinkingState(); var tokens = new List(); - var parameters = CreateModelParameters(chat, modelKey, model.Path); + var parameters = CreateModelParameters(chat, modelKey, null); var disableCache = chat.Properties.CheckProperty(ServiceConstants.Properties.DisableCacheProperty); var llmModel = disableCache ? await LLamaWeights.LoadFromFileAsync(parameters, cancellationToken) - : await ModelLoader.GetOrLoadModelAsync( - model.Path ?? modelsPath, modelKey); + : await ModelLoader.GetOrLoadModelAsync(modelsPath, modelKey); - var llavaWeights = model.MMProject != null - ? await LLavaWeights.LoadFromFileAsync(model.MMProject, cancellationToken) + var visionModel = model as IVisionModel; + var llavaWeights = visionModel?.MMProjectPath != null + ? await LLavaWeights.LoadFromFileAsync(visionModel.MMProjectPath, cancellationToken) : null; using var executor = new BatchedExecutor(llmModel, parameters); @@ -278,9 +279,10 @@ private ModelParams CreateModelParameters(Chat chat, string modelKey, string? cu }; } + private async Task<(Conversation Conversation, bool IsComplete, bool HasFailed)> InitializeConversation(Chat chat, Message lastMsg, - Model model, + LocalModel model, LLamaWeights llmModel, LLavaWeights? llavaWeights, BatchedExecutor executor, @@ -325,7 +327,7 @@ private static async Task ProcessImageMessage(Conversation conversation, private static void ProcessTextMessage(Conversation conversation, Chat chat, Message lastMsg, - Model model, + LocalModel model, LLamaWeights llmModel, BatchedExecutor executor, bool isNewConversation) @@ -396,7 +398,7 @@ private static string FormatToolsForPrompt(ToolsConfiguration toolsConfig) private async Task<(List Tokens, bool IsComplete, bool HasFailed)> ProcessTokens( Chat chat, Conversation conversation, - Model model, + LocalModel model, LLamaWeights llmModel, BatchedExecutor executor, ThinkingState thinkingState, @@ -412,6 +414,7 @@ private static string FormatToolsForPrompt(ToolsConfiguration toolsConfig) var inferenceParams = ChatHelper.CreateInferenceParams(chat, llmModel); var maxTokens = inferenceParams.MaxTokens == -1 ? int.MaxValue : inferenceParams.MaxTokens; + var reasoningModel = model as IReasoningModel; for (var i = 0; i < maxTokens && !isComplete; i++) { @@ -444,8 +447,8 @@ private static string FormatToolsForPrompt(ToolsConfiguration toolsConfig) var tokenTxt = decoder.Read(); conversation.Prompt(token); - var tokenValue = model.ReasonFunction != null - ? model.ReasonFunction(tokenTxt, thinkingState) + var tokenValue = reasoningModel?.ReasonFunction != null + ? reasoningModel.ReasonFunction(tokenTxt, thinkingState) : new LLMTokenValue() { Text = tokenTxt, @@ -463,6 +466,7 @@ private static string FormatToolsForPrompt(ToolsConfiguration toolsConfig) } } + return (tokens, isComplete, hasFailed); } @@ -486,6 +490,16 @@ private BaseSamplingPipeline CreateSampler(InferenceParams interferenceParams) }; } + private static LocalModel GetLocalModel(string modelId) + { + var model = ModelRegistry.GetById(modelId); + if (model is LocalModel localModel) + { + return localModel; + } + + throw new InvalidOperationException($"Model '{modelId}' is not a local model. LLMService only supports local models."); + } private string GetModelsPath() { @@ -513,7 +527,7 @@ private async Task CreateChatResult(Chat chat, List t { Done = true, CreatedAt = DateTime.Now, - Model = chat.Model, + Model = chat.ModelId, Message = new Message { Content = responseText, diff --git a/src/MaIN.Services/Services/LLMService/OpenAiCompatibleService.cs b/src/MaIN.Services/Services/LLMService/OpenAiCompatibleService.cs index b0ae2d1..740e498 100644 --- a/src/MaIN.Services/Services/LLMService/OpenAiCompatibleService.cs +++ b/src/MaIN.Services/Services/LLMService/OpenAiCompatibleService.cs @@ -760,7 +760,7 @@ private object BuildRequestBody(Chat chat, List conversation, bool { var requestBody = new Dictionary { - ["model"] = chat.Model, + ["model"] = chat.ModelId, ["messages"] = BuildMessagesArray(conversation, chat, ImageType.AsUrl).Result, ["stream"] = stream }; @@ -841,7 +841,7 @@ protected static ChatResult CreateChatResult(Chat chat, string content, List /// Generates final prompt including additional prompt if needed /// - public static string GetFinalPrompt(Message message, Model model, bool startSession) + public static string GetFinalPrompt(Message message, AIModel model, bool startSession) { - return startSession && model.AdditionalPrompt != null - ? $"{message.Content}{model.AdditionalPrompt}" + var additionalPrompt = (model as IReasoningModel)?.AdditionalPrompt; + return startSession && additionalPrompt != null + ? $"{message.Content}{additionalPrompt}" : message.Content; } diff --git a/src/MaIN.Services/Services/Steps/Commands/AnswerCommandHandler.cs b/src/MaIN.Services/Services/Steps/Commands/AnswerCommandHandler.cs index 6913a8b..036941f 100644 --- a/src/MaIN.Services/Services/Steps/Commands/AnswerCommandHandler.cs +++ b/src/MaIN.Services/Services/Steps/Commands/AnswerCommandHandler.cs @@ -131,7 +131,7 @@ Find tags that fits user query based on available knowledge (provided to you abo await notificationService.DispatchNotification(NotificationMessageBuilder.CreateActorKnowledgeStepProgress( agentId, knowledgeItems.Select(x => $" {x.Name}|{x.Type} ").ToList(), - mcpConfig?.Model ?? chat.Model), "ReceiveAgentUpdate"); + mcpConfig?.Model ?? chat.ModelId), "ReceiveAgentUpdate"); if (mcpConfig != null) { diff --git a/src/MaIN.Services/Services/Steps/FechDataStepHandler.cs b/src/MaIN.Services/Services/Steps/FechDataStepHandler.cs index b82e48c..28af385 100644 --- a/src/MaIN.Services/Services/Steps/FechDataStepHandler.cs +++ b/src/MaIN.Services/Services/Steps/FechDataStepHandler.cs @@ -65,7 +65,7 @@ private static Chat CreateMemoryChat(StepContext context, string? filterVal) Role = "User" } }, - Model = context.Chat.Model, + ModelId = context.Chat.ModelId, Properties = context.Chat.Properties, MemoryParams = context.Chat.MemoryParams, InterferenceParams = context.Chat.InterferenceParams, diff --git a/src/MaIN.Services/Utils/AgentStateManager.cs b/src/MaIN.Services/Utils/AgentStateManager.cs index d3fb461..f863daa 100644 --- a/src/MaIN.Services/Utils/AgentStateManager.cs +++ b/src/MaIN.Services/Utils/AgentStateManager.cs @@ -11,7 +11,7 @@ public static void ClearState(AgentDocument agent, Chat chat) agent.CurrentBehaviour = "Default"; chat.Properties.Clear(); - if (chat.Model == ImageGenService.LocalImageModels.FLUX) + if (chat.ModelId == ImageGenService.LocalImageModels.FLUX) { chat.Messages = []; } From 0d23b485c43f4fa0df48a3dd212aa5b33b473ae1 Mon Sep 17 00:00:00 2001 From: srebrek Date: Mon, 2 Feb 2026 13:10:30 +0100 Subject: [PATCH 4/5] Apply models in the code and add predefined cloud models --- Examples/Examples.SimpleConsole/Program.cs | 2 +- .../Examples/Chat/ChatCustomGrammarExample.cs | 3 +- Examples/Examples/Chat/ChatExample.cs | 2 +- .../Examples/Chat/ChatExampleAnthropic.cs | 3 +- Examples/Examples/Chat/ChatExampleGemini.cs | 14 +- .../Examples/Chat/ChatExampleGroqCloud.cs | 3 +- Examples/Examples/Chat/ChatExampleOllama.cs | 3 +- Examples/Examples/Chat/ChatExampleOpenAi.cs | 3 +- .../Examples/Chat/ChatExampleToolsSimple.cs | 3 +- .../Chat/ChatExampleToolsSimpleLocalLLM.cs | 3 +- Examples/Examples/Chat/ChatExampleXai.cs | 3 +- .../Examples/Chat/ChatFromExistingExample.cs | 3 +- .../Examples/Chat/ChatGrammarExampleGemini.cs | 3 +- .../Examples/Chat/ChatWithFilesExample.cs | 3 +- .../Chat/ChatWithFilesExampleGemini.cs | 3 +- .../Chat/ChatWithFilesFromStreamExample.cs | 3 +- .../Chat/ChatWithImageGenOpenAiExample.cs | 3 +- .../Chat/ChatWithReasoningDeepSeekExample.cs | 3 +- .../Examples/Chat/ChatWithReasoningExample.cs | 3 +- .../Chat/ChatWithTextToSpeechExample.cs | 4 +- .../Examples/Chat/ChatWithVisionExample.cs | 7 +- Examples/Examples/Utils/GeminiExampleSetup.cs | 2 +- MaIN.Core.IntegrationTests/ChatTests.cs | 6 +- Test/Program.cs | 2 +- src/MaIN.Core/Hub/Contexts/ChatContext.cs | 48 +++--- .../Interfaces/ModelContext/IModelContext.cs | 45 +++--- src/MaIN.Core/Hub/Contexts/ModelContext.cs | 76 ++++------ src/MaIN.Domain/Entities/Chat.cs | 9 +- .../Exceptions/ChatConfigurationException.cs | 5 - .../Exceptions/Models/InvalidModelType.cs | 10 ++ .../DownloadUrlNullOrEmptyException.cs | 10 ++ ...xception.cs => MissingModelIdException.cs} | 6 +- src/MaIN.Domain/Models/Abstract/AIModel.cs | 10 +- .../Models/Abstract/ModelRegistry.cs | 16 +- .../Models/Concrete/CloudModels.cs | 137 ++++++++++++++++++ src/MaIN.Domain/Models/Concrete/Gpt4o.cs | 13 -- .../Models/Concrete/LocalModels.cs | 20 ++- src/MaIN.Services/Services/ChatService.cs | 16 +- .../Services/LLMService/LLMService.cs | 40 ++--- 39 files changed, 357 insertions(+), 191 deletions(-) delete mode 100644 src/MaIN.Domain/Exceptions/ChatConfigurationException.cs create mode 100644 src/MaIN.Domain/Exceptions/Models/InvalidModelType.cs create mode 100644 src/MaIN.Domain/Exceptions/Models/LocalModels/DownloadUrlNullOrEmptyException.cs rename src/MaIN.Domain/Exceptions/Models/{MissingModelNameException.cs => MissingModelIdException.cs} (59%) create mode 100644 src/MaIN.Domain/Models/Concrete/CloudModels.cs delete mode 100644 src/MaIN.Domain/Models/Concrete/Gpt4o.cs diff --git a/Examples/Examples.SimpleConsole/Program.cs b/Examples/Examples.SimpleConsole/Program.cs index 12b1d2e..9338cbf 100644 --- a/Examples/Examples.SimpleConsole/Program.cs +++ b/Examples/Examples.SimpleConsole/Program.cs @@ -12,7 +12,7 @@ var llama = modelContext.GetModel("llama3.2-3b"); // Or use strongly-typed models directly -var gemma2b = new Gemma_2b(); +var gemma2b = new Gemma2_2b(); Console.WriteLine($"Model: {gemma2b.Name}, File: {gemma2b.FileName}"); // Download a model diff --git a/Examples/Examples/Chat/ChatCustomGrammarExample.cs b/Examples/Examples/Chat/ChatCustomGrammarExample.cs index ec58c25..7863fd7 100644 --- a/Examples/Examples/Chat/ChatCustomGrammarExample.cs +++ b/Examples/Examples/Chat/ChatCustomGrammarExample.cs @@ -1,6 +1,7 @@ using MaIN.Core.Hub; using MaIN.Domain.Entities; using MaIN.Domain.Models; +using MaIN.Domain.Models.Concrete; using Grammar = MaIN.Domain.Models.Grammar; namespace Examples.Chat; @@ -21,7 +22,7 @@ public async Task Start() """, GrammarFormat.GBNF); await AIHub.Chat() - .WithModel("gemma2:2b") + .WithModel() .WithMessage("Generate random person") .WithInferenceParams(new InferenceParams { diff --git a/Examples/Examples/Chat/ChatExample.cs b/Examples/Examples/Chat/ChatExample.cs index df159fe..3d97ccf 100644 --- a/Examples/Examples/Chat/ChatExample.cs +++ b/Examples/Examples/Chat/ChatExample.cs @@ -11,7 +11,7 @@ public async Task Start() // Using strongly-typed model await AIHub.Chat() - .WithModel() + .WithModel() .WithMessage("Where do hedgehogs goes at night?") .CompleteAsync(interactive: true); } diff --git a/Examples/Examples/Chat/ChatExampleAnthropic.cs b/Examples/Examples/Chat/ChatExampleAnthropic.cs index b24baf5..851580f 100644 --- a/Examples/Examples/Chat/ChatExampleAnthropic.cs +++ b/Examples/Examples/Chat/ChatExampleAnthropic.cs @@ -1,5 +1,6 @@ using Examples.Utils; using MaIN.Core.Hub; +using MaIN.Domain.Models.Concrete; namespace Examples.Chat; @@ -11,7 +12,7 @@ public async Task Start() Console.WriteLine("(Anthropic) ChatExample is running!"); await AIHub.Chat() - .WithModel("claude-sonnet-4-20250514") + .WithModel() .WithMessage("Write a haiku about programming on Monday morning.") .CompleteAsync(interactive: true); } diff --git a/Examples/Examples/Chat/ChatExampleGemini.cs b/Examples/Examples/Chat/ChatExampleGemini.cs index a578db6..deb9b65 100644 --- a/Examples/Examples/Chat/ChatExampleGemini.cs +++ b/Examples/Examples/Chat/ChatExampleGemini.cs @@ -1,5 +1,8 @@ using Examples.Utils; using MaIN.Core.Hub; +using MaIN.Domain.Configuration; +using MaIN.Domain.Models.Abstract; +using MaIN.Domain.Models.Concrete; namespace Examples.Chat; @@ -10,8 +13,17 @@ public async Task Start() GeminiExample.Setup(); //We need to provide Gemini API key Console.WriteLine("(Gemini) ChatExample is running!"); + // Get built-in Gemini 2.5 Flash model + var model = AIHub.Model().GetModel(new Gemini2_5Flash().Id); + + // Or create the model manually if not available in the hub + var customModel = new GenericCloudModel( + "gemini-2.5-flash", + BackendType.Gemini + ); + await AIHub.Chat() - .WithModel("gemini-2.5-flash") + .WithModel(customModel) .WithMessage("Is the killer whale the smartest animal?") .CompleteAsync(interactive: true); } diff --git a/Examples/Examples/Chat/ChatExampleGroqCloud.cs b/Examples/Examples/Chat/ChatExampleGroqCloud.cs index 7bfc444..69df1dd 100644 --- a/Examples/Examples/Chat/ChatExampleGroqCloud.cs +++ b/Examples/Examples/Chat/ChatExampleGroqCloud.cs @@ -1,5 +1,6 @@ using Examples.Utils; using MaIN.Core.Hub; +using MaIN.Domain.Models.Concrete; namespace Examples.Chat; @@ -11,7 +12,7 @@ public async Task Start() Console.WriteLine("(GroqCloud) ChatExample is running!"); await AIHub.Chat() - .WithModel("llama-3.1-8b-instant") + .WithModel() .WithMessage("Which color do people like the most?") .CompleteAsync(interactive: true); } diff --git a/Examples/Examples/Chat/ChatExampleOllama.cs b/Examples/Examples/Chat/ChatExampleOllama.cs index 5a1685d..08dd1ba 100644 --- a/Examples/Examples/Chat/ChatExampleOllama.cs +++ b/Examples/Examples/Chat/ChatExampleOllama.cs @@ -1,5 +1,6 @@ using Examples.Utils; using MaIN.Core.Hub; +using MaIN.Domain.Models.Concrete; namespace Examples.Chat; @@ -11,7 +12,7 @@ public async Task Start() Console.WriteLine("(Ollama) ChatExample is running!"); await AIHub.Chat() - .WithModel("gemma3:4b") + .WithModel() .WithMessage("Write a short poem about the color green.") .CompleteAsync(interactive: true); } diff --git a/Examples/Examples/Chat/ChatExampleOpenAi.cs b/Examples/Examples/Chat/ChatExampleOpenAi.cs index 80ef4d2..da03637 100644 --- a/Examples/Examples/Chat/ChatExampleOpenAi.cs +++ b/Examples/Examples/Chat/ChatExampleOpenAi.cs @@ -1,5 +1,6 @@ using Examples.Utils; using MaIN.Core.Hub; +using MaIN.Domain.Models.Concrete; namespace Examples.Chat; @@ -12,7 +13,7 @@ public async Task Start() Console.WriteLine("(OpenAi) ChatExample is running!"); await AIHub.Chat() - .WithModel("gpt-5-nano") + .WithModel() .WithMessage("What do you consider to be the greatest invention in history?") .CompleteAsync(interactive: true); } diff --git a/Examples/Examples/Chat/ChatExampleToolsSimple.cs b/Examples/Examples/Chat/ChatExampleToolsSimple.cs index 9681976..f4fa821 100644 --- a/Examples/Examples/Chat/ChatExampleToolsSimple.cs +++ b/Examples/Examples/Chat/ChatExampleToolsSimple.cs @@ -1,6 +1,7 @@ using Examples.Utils; using MaIN.Core.Hub; using MaIN.Core.Hub.Utils; +using MaIN.Domain.Models.Concrete; namespace Examples.Chat; @@ -13,7 +14,7 @@ public async Task Start() Console.WriteLine("(OpenAi) ChatExample with tools is running!"); await AIHub.Chat() - .WithModel("gpt-5-nano") + .WithModel() .WithMessage("What time is it right now?") .WithTools(new ToolsConfigurationBuilder() .AddTool( diff --git a/Examples/Examples/Chat/ChatExampleToolsSimpleLocalLLM.cs b/Examples/Examples/Chat/ChatExampleToolsSimpleLocalLLM.cs index 9694c1a..479cc1e 100644 --- a/Examples/Examples/Chat/ChatExampleToolsSimpleLocalLLM.cs +++ b/Examples/Examples/Chat/ChatExampleToolsSimpleLocalLLM.cs @@ -1,6 +1,7 @@ using Examples.Utils; using MaIN.Core.Hub; using MaIN.Core.Hub.Utils; +using MaIN.Domain.Models.Concrete; namespace Examples.Chat; @@ -11,7 +12,7 @@ public async Task Start() Console.WriteLine("Local LLM ChatExample with tools is running!"); await AIHub.Chat() - .WithModel("gemma3:4b") + .WithModel() .WithMessage("What time is it right now?") .WithTools(new ToolsConfigurationBuilder() .AddTool( diff --git a/Examples/Examples/Chat/ChatExampleXai.cs b/Examples/Examples/Chat/ChatExampleXai.cs index 2f8df21..be7d474 100644 --- a/Examples/Examples/Chat/ChatExampleXai.cs +++ b/Examples/Examples/Chat/ChatExampleXai.cs @@ -1,5 +1,6 @@ using Examples.Utils; using MaIN.Core.Hub; +using MaIN.Domain.Models.Concrete; namespace Examples.Chat; @@ -11,7 +12,7 @@ public async Task Start() Console.WriteLine("(xAI) ChatExample is running!"); await AIHub.Chat() - .WithModel("grok-3-beta") + .WithModel() .WithMessage("Is the killer whale cute?") .CompleteAsync(interactive: true); } diff --git a/Examples/Examples/Chat/ChatFromExistingExample.cs b/Examples/Examples/Chat/ChatFromExistingExample.cs index adeba5a..6a14961 100644 --- a/Examples/Examples/Chat/ChatFromExistingExample.cs +++ b/Examples/Examples/Chat/ChatFromExistingExample.cs @@ -1,6 +1,7 @@ using System.Text.Json; using MaIN.Core.Hub; using MaIN.Domain.Exceptions.Chats; +using MaIN.Domain.Models.Concrete; namespace Examples.Chat; @@ -11,7 +12,7 @@ public async Task Start() Console.WriteLine("ChatExample with files is running!"); var result = AIHub.Chat() - .WithModel("qwen2.5:0.5b"); + .WithModel(); await result.WithMessage("What do you think about math theories?") .CompleteAsync(); diff --git a/Examples/Examples/Chat/ChatGrammarExampleGemini.cs b/Examples/Examples/Chat/ChatGrammarExampleGemini.cs index 43ee0eb..eea49de 100644 --- a/Examples/Examples/Chat/ChatGrammarExampleGemini.cs +++ b/Examples/Examples/Chat/ChatGrammarExampleGemini.cs @@ -2,6 +2,7 @@ using MaIN.Core.Hub; using MaIN.Domain.Entities; using MaIN.Domain.Models; +using MaIN.Domain.Models.Concrete; namespace Examples.Chat; @@ -38,7 +39,7 @@ public async Task Start() """; await AIHub.Chat() - .WithModel("gemini-2.5-flash") + .WithModel() .WithMessage("Generate random person") .WithInferenceParams(new InferenceParams { diff --git a/Examples/Examples/Chat/ChatWithFilesExample.cs b/Examples/Examples/Chat/ChatWithFilesExample.cs index 75b2337..9be491d 100644 --- a/Examples/Examples/Chat/ChatWithFilesExample.cs +++ b/Examples/Examples/Chat/ChatWithFilesExample.cs @@ -1,4 +1,5 @@ using MaIN.Core.Hub; +using MaIN.Domain.Models.Concrete; namespace Examples.Chat; @@ -14,7 +15,7 @@ public async Task Start() ]; var result = await AIHub.Chat() - .WithModel("gemma3:4b") + .WithModel() .WithMessage("You have 2 documents in memory. Whats the difference of work between Galileo and Copernicus?. Give answer based on the documents.") .WithFiles(files) .DisableCache() diff --git a/Examples/Examples/Chat/ChatWithFilesExampleGemini.cs b/Examples/Examples/Chat/ChatWithFilesExampleGemini.cs index b4ab6ef..c359918 100644 --- a/Examples/Examples/Chat/ChatWithFilesExampleGemini.cs +++ b/Examples/Examples/Chat/ChatWithFilesExampleGemini.cs @@ -1,5 +1,6 @@ using Examples.Utils; using MaIN.Core.Hub; +using MaIN.Domain.Models.Concrete; namespace Examples.Chat; @@ -13,7 +14,7 @@ public async Task Start() List files = ["./Files/Nicolaus_Copernicus.pdf", "./Files/Galileo_Galilei.pdf"]; var result = await AIHub.Chat() - .WithModel("gemini-2.0-flash") + .WithModel() .WithMessage("You have 2 documents in memory. Whats the difference of work between Galileo and Copernicus?. Give answer based on the documents.") .WithFiles(files) .CompleteAsync(interactive: true); diff --git a/Examples/Examples/Chat/ChatWithFilesFromStreamExample.cs b/Examples/Examples/Chat/ChatWithFilesFromStreamExample.cs index 08e0cb8..1643cf6 100644 --- a/Examples/Examples/Chat/ChatWithFilesFromStreamExample.cs +++ b/Examples/Examples/Chat/ChatWithFilesFromStreamExample.cs @@ -1,4 +1,5 @@ using MaIN.Core.Hub; +using MaIN.Domain.Models.Concrete; namespace Examples.Chat; @@ -37,7 +38,7 @@ public async Task Start() } var result = await AIHub.Chat() - .WithModel("qwen2.5:0.5b") + .WithModel() .WithMessage("You have 2 documents in memory. Whats the difference of work between Galileo and Copernicus?. Give answer based on the documents.") .WithFiles(fileStreams) .CompleteAsync(); diff --git a/Examples/Examples/Chat/ChatWithImageGenOpenAiExample.cs b/Examples/Examples/Chat/ChatWithImageGenOpenAiExample.cs index 3f3529c..d7460c6 100644 --- a/Examples/Examples/Chat/ChatWithImageGenOpenAiExample.cs +++ b/Examples/Examples/Chat/ChatWithImageGenOpenAiExample.cs @@ -1,5 +1,6 @@ using Examples.Utils; using MaIN.Core.Hub; +using MaIN.Domain.Models.Concrete; namespace Examples.Chat; @@ -11,7 +12,7 @@ public async Task Start() OpenAiExample.Setup(); // We need to provide OpenAi API key var result = await AIHub.Chat() - .WithModel("dall-e-3") + .WithModel() .EnableVisual() .WithMessage("Generate rock style cow playing guitar") .CompleteAsync(); diff --git a/Examples/Examples/Chat/ChatWithReasoningDeepSeekExample.cs b/Examples/Examples/Chat/ChatWithReasoningDeepSeekExample.cs index 5d77192..7fc4b1f 100644 --- a/Examples/Examples/Chat/ChatWithReasoningDeepSeekExample.cs +++ b/Examples/Examples/Chat/ChatWithReasoningDeepSeekExample.cs @@ -1,5 +1,6 @@ using Examples.Utils; using MaIN.Core.Hub; +using MaIN.Domain.Models.Concrete; namespace Examples.Chat; @@ -11,7 +12,7 @@ public async Task Start() Console.WriteLine("(DeepSeek) ChatExample with reasoning is running!"); await AIHub.Chat() - .WithModel("deepseek-reasoner") // a model that supports reasoning + .WithModel() // a model that supports reasoning .WithMessage("What chill pc game do you recommend?") .CompleteAsync(interactive: true); } diff --git a/Examples/Examples/Chat/ChatWithReasoningExample.cs b/Examples/Examples/Chat/ChatWithReasoningExample.cs index e6a249d..d233461 100644 --- a/Examples/Examples/Chat/ChatWithReasoningExample.cs +++ b/Examples/Examples/Chat/ChatWithReasoningExample.cs @@ -1,4 +1,5 @@ using MaIN.Core.Hub; +using MaIN.Domain.Models.Concrete; namespace Examples.Chat; @@ -9,7 +10,7 @@ public async Task Start() Console.WriteLine("ChatWithReasoningExample is running!"); await AIHub.Chat() - .WithModel("deepseekR1:1.5b") + .WithModel() .WithMessage("Think about greatest poet of all time") .CompleteAsync(interactive: true); } diff --git a/Examples/Examples/Chat/ChatWithTextToSpeechExample.cs b/Examples/Examples/Chat/ChatWithTextToSpeechExample.cs index 9b3640a..6dd0212 100644 --- a/Examples/Examples/Chat/ChatWithTextToSpeechExample.cs +++ b/Examples/Examples/Chat/ChatWithTextToSpeechExample.cs @@ -1,5 +1,6 @@ using MaIN.Core.Hub; using MaIN.Domain.Entities; +using MaIN.Domain.Models.Concrete; using MaIN.Services.Services.TTSService; #pragma warning disable CS0618 // Type or member is obsolete @@ -18,7 +19,8 @@ public async Task Start() var voice = VoiceService.GetVoice("af_heart") .MixWith(VoiceService.GetVoice("bf_emma")); - await AIHub.Chat().WithModel("gemma2:2b") + await AIHub.Chat() + .WithModel() .WithMessage("Generate a 4 sentence poem.") .Speak(new TextToSpeechParams("kokoro:82m", voice, true)) .CompleteAsync(interactive: true); diff --git a/Examples/Examples/Chat/ChatWithVisionExample.cs b/Examples/Examples/Chat/ChatWithVisionExample.cs index 40a9808..5259bd8 100644 --- a/Examples/Examples/Chat/ChatWithVisionExample.cs +++ b/Examples/Examples/Chat/ChatWithVisionExample.cs @@ -1,4 +1,5 @@ using MaIN.Core.Hub; +using MaIN.Domain.Models.Concrete; namespace Examples.Chat; @@ -11,11 +12,9 @@ public async Task Start() var image = await File.ReadAllBytesAsync( Path.Combine(AppContext.BaseDirectory, "Files", "gamex.jpg")); - + await AIHub.Chat() - .WithCustomModel("Llava1.6-Mistral", - path: ".gguf", - mmProject: ".gguf") + .WithModel() .WithMessage("What can you see on the image?", image) .CompleteAsync(interactive: true); } diff --git a/Examples/Examples/Utils/GeminiExampleSetup.cs b/Examples/Examples/Utils/GeminiExampleSetup.cs index 0c03765..5475847 100644 --- a/Examples/Examples/Utils/GeminiExampleSetup.cs +++ b/Examples/Examples/Utils/GeminiExampleSetup.cs @@ -10,7 +10,7 @@ public static void Setup() MaINBootstrapper.Initialize(configureSettings: (options) => { options.BackendType = BackendType.Gemini; - options.GeminiKey = ""; + options.GeminiKey = ""; }); } } \ No newline at end of file diff --git a/MaIN.Core.IntegrationTests/ChatTests.cs b/MaIN.Core.IntegrationTests/ChatTests.cs index eee2b09..27a5fb7 100644 --- a/MaIN.Core.IntegrationTests/ChatTests.cs +++ b/MaIN.Core.IntegrationTests/ChatTests.cs @@ -13,7 +13,7 @@ public ChatTests() : base() [Fact] public async Task Should_AnswerQuestion_BasicChat() { - var context = AIHub.Chat().WithModel(); + var context = AIHub.Chat().WithModel(); var result = await context .WithMessage("Where the hedgehog goes at night?") @@ -30,7 +30,7 @@ public async Task Should_AnswerDifferences_BetweenDocuments_ChatWithFiles() List files = ["./Files/Nicolaus_Copernicus.pdf", "./Files/Galileo_Galilei.pdf"]; var result = await AIHub.Chat() - .WithModel() + .WithModel() .WithMessage("You have 2 documents in memory. Whats the difference of work between Galileo and Copernicus?. Give answer based on the documents.") .WithFiles(files) .CompleteAsync(); @@ -120,7 +120,7 @@ public async Task Should_AnswerDifferences_BetweenDocuments_ChatWithFiles_UsingS } var result = await AIHub.Chat() - .WithModel() + .WithModel() .WithMessage("You have 2 documents in memory. Whats the difference of work between Galileo and Copernicus?. Give answer based on the documents.") .WithFiles(fileStreams) .CompleteAsync(); diff --git a/Test/Program.cs b/Test/Program.cs index f250ae8..53d4043 100644 --- a/Test/Program.cs +++ b/Test/Program.cs @@ -14,7 +14,7 @@ private static async Task Main(string[] args) ChatContext chat = AIHub.Chat(); chat.WithModel("gemma2:2b"); // Using string (deprecated) - chat.WithModel(); // Using strongly-typed model + chat.WithModel(); // Using strongly-typed model chat.WithMessage("Where do hedgehogs goes at night?"); await chat.CompleteAsync(interactive: true); diff --git a/src/MaIN.Core/Hub/Contexts/ChatContext.cs b/src/MaIN.Core/Hub/Contexts/ChatContext.cs index 1293654..c962edc 100644 --- a/src/MaIN.Core/Hub/Contexts/ChatContext.cs +++ b/src/MaIN.Core/Hub/Contexts/ChatContext.cs @@ -40,9 +40,7 @@ internal ChatContext(IChatService chatService, Chat existingChat) public IChatMessageBuilder WithModel(AIModel model) { - _chat.ModelInstance = model; - _chat.ModelId = model.Id; - _chat.Backend = model.Backend; + SetModel(model); return this; } @@ -53,13 +51,21 @@ public IChatMessageBuilder WithModel(AIModel model) } [Obsolete("Use WithModel(AIModel model) or WithModel() instead.")] - public ChatContext WithModel(string modelId) + public IChatMessageBuilder WithModel(string modelId) { var model = ModelRegistry.GetById(modelId); SetModel(model); return this; } + [Obsolete("Use WithModel() instead.")] + public IChatMessageBuilder WithCustomModel(string model, string path, string? mmProject = null) + { + KnownModels.AddModel(model, path, mmProject); + _chat.ModelId = model; + return this; + } + private void SetModel(AIModel model) { _chat.ModelId = model.Id; @@ -67,13 +73,6 @@ private void SetModel(AIModel model) _chat.Backend = model.Backend; } - public IChatMessageBuilder WithInferenceParams(InferenceParams inferenceParams) - { - KnownModels.AddModel(model, path, mmProject); - _chat.Model = model; - return this; - } - public IChatMessageBuilder EnableVisual() { _chat.Visual = true; @@ -86,11 +85,9 @@ public IChatConfigurationBuilder WithInferenceParams(InferenceParams inferencePa return this; } - [Obsolete("Use WithModel() instead.")] - public ChatContext WithCustomModel(string model, string path, string? mmProject = null) + public IChatConfigurationBuilder WithTools(ToolsConfiguration toolsConfiguration) { - KnownModels.AddModel(model, path, mmProject); - _chat.ModelId = model; + _chat.ToolsConfiguration = toolsConfiguration; return this; } @@ -156,8 +153,7 @@ public IChatConfigurationBuilder WithMessages(IEnumerable messages) public IChatConfigurationBuilder WithFiles(List file, bool preProcess = false) { - _files = file.Select(f => new FileInfo { Name = Path.GetFileName(f.Name), StreamContent = f, Extension = Path.GetExtension(f.Name) }) - .ToList(); + _files = [.. file.Select(f => new FileInfo { Name = Path.GetFileName(f.Name), StreamContent = f, Extension = Path.GetExtension(f.Name) })]; _preProcess = preProcess; return this; } @@ -171,15 +167,14 @@ public IChatConfigurationBuilder WithFiles(List file, bool preProcess public IChatConfigurationBuilder WithFiles(List file, bool preProcess = false) { - _files = file + _files = [.. file .Select(path => new FileInfo { Name = Path.GetFileName(path), Path = path, Extension = Path.GetExtension(path) - }) - .ToList(); + })]; _preProcess = preProcess; return this; } @@ -197,7 +192,7 @@ public async Task CompleteAsync( { if (_chat.ModelInstance is null) { - throw new ChatConfigurationException("Model is required. Use .WithModel() before calling CompleteAsync()."); + throw new ChatNotInitializedException(); } if (_chat.Messages.Count == 0) { @@ -255,11 +250,8 @@ public async Task GetCurrentChat() return await _chatService.GetById(_chat.Id); } - public async Task> GetAllChats() - { - return await _chatService.GetAll(); - } - + public async Task> GetAllChats() => await _chatService.GetAll(); + public async Task DeleteChat() { if (_chat.Id == null) @@ -272,11 +264,11 @@ public async Task DeleteChat() public List GetChatHistory() { - return _chat.Messages.Select(x => new MessageShort() + return [.. _chat.Messages.Select(x => new MessageShort() { Content = x.Content, Role = x.Role, Time = x.Time - }).ToList(); + })]; } } \ No newline at end of file diff --git a/src/MaIN.Core/Hub/Contexts/Interfaces/ModelContext/IModelContext.cs b/src/MaIN.Core/Hub/Contexts/Interfaces/ModelContext/IModelContext.cs index 3b7b216..8e2255f 100644 --- a/src/MaIN.Core/Hub/Contexts/Interfaces/ModelContext/IModelContext.cs +++ b/src/MaIN.Core/Hub/Contexts/Interfaces/ModelContext/IModelContext.cs @@ -1,48 +1,55 @@ -using MaIN.Domain.Models; +using MaIN.Domain.Models.Abstract; namespace MaIN.Core.Hub.Contexts.Interfaces.ModelContext; public interface IModelContext { /// - /// Retrieves a complete list of all available models in the system. This method returns all known models that + /// Retrieves an enumerable collection of all available models in the system. This method returns all known models that /// can be used within the MaIN framework. /// - /// A list of containing all available models in the system - List GetAll(); + /// A list of containing all available models in the system + IEnumerable GetAll(); /// - /// Retrieves information about a specific model by its name. This method allows you to get detailed information about a particular model, + /// Retrieves all local models available in the data store. + /// + /// An enumerable collection of instances representing all local models. The collection is + /// empty if no local models are found. + IEnumerable GetAllLocal(); + + /// + /// Retrieves information about a specific model by its id. This method allows you to get detailed information about a particular model, /// including its configuration and metadata. /// - /// The name of the model to retrieve. - /// A object containing the model's information and configuration. - Model GetModel(string model); + /// The id of the model to retrieve. + /// A object containing the model's information and configuration. + AIModel GetModel(string modelId); /// /// Retrieves the designated embedding model used for generating vector representations of text. This is typically used /// for semantic search, similarity calculations, and other NLP tasks that require text embeddings. /// - /// A object representing the embedding model. - Model GetEmbeddingModel(); + /// A object representing the embedding model. + AIModel GetEmbeddingModel(); /// /// Checks whether a specific model exists locally on the filesystem. This method verifies if the model file is present /// and accessible before attempting to use it. /// - /// The name of the model to check for existence. + /// The id of the model to check for existence. /// A boolean value indicating whether the model file exists locally. - bool Exists(string modelName); + bool Exists(string modelId); /// /// Asynchronously downloads a known model from its configured download URL. This method handles the complete download process /// with progress tracking and error handling. /// - /// The name of the model to download. + /// The id of the model to download. /// Optional cancellation token to abort the download operation. /// A task that represents the asynchronous download operation that completes when the download finishes, /// returning the context instance implementing for method chaining. - Task DownloadAsync(string modelName, CancellationToken cancellationToken = default); + Task DownloadAsync(string modelId, CancellationToken cancellationToken = default); /// /// Asynchronously downloads a custom model from a specified URL. This method allows downloading models that are not part @@ -52,7 +59,7 @@ public interface IModelContext /// The URL from which to download the model. /// A task that represents the asynchronous download operation that completes when the download finishes, /// returning the context instance implementing for method chaining. - Task DownloadAsync(string model, string url); + Task DownloadAsync(string model, string url, CancellationToken cancellationToken = default); /// /// Synchronously downloads a known model from its configured download URL. This is the blocking version of the download operation @@ -79,14 +86,14 @@ public interface IModelContext /// /// The Model object to load into a cache. /// The context instance implementing for method chaining. - IModelContext LoadToCache(Model model); + IModelContext LoadToCache(LocalModel model); /// /// Asynchronously loads a model into the memory cache for faster access during inference operations. This is the non-blocking /// version of cache loading that allows other operations to continue while the model loads. /// - /// The object to load into a cache. + /// The object to load into a cache. /// A task that represents the asynchronous operation that completes when the model is loaded into cache, /// returning the context instance implementing for method chaining. - Task LoadToCacheAsync(Model model); -} \ No newline at end of file + Task LoadToCacheAsync(LocalModel model); +} diff --git a/src/MaIN.Core/Hub/Contexts/ModelContext.cs b/src/MaIN.Core/Hub/Contexts/ModelContext.cs index 5b8d0f0..17796d9 100644 --- a/src/MaIN.Core/Hub/Contexts/ModelContext.cs +++ b/src/MaIN.Core/Hub/Contexts/ModelContext.cs @@ -3,7 +3,7 @@ using MaIN.Core.Hub.Contexts.Interfaces.ModelContext; using MaIN.Domain.Configuration; using MaIN.Domain.Exceptions.Models; -using MaIN.Domain.Models; +using MaIN.Domain.Exceptions.Models.LocalModels; using MaIN.Domain.Models.Abstract; using MaIN.Domain.Models.Concrete; using MaIN.Services.Constants; @@ -27,34 +27,19 @@ internal ModelContext(MaINSettings settings, IHttpClientFactory httpClientFactor _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); } - /// - /// Gets all registered models. - /// public IEnumerable GetAll() => ModelRegistry.GetAll(); - /// - /// Gets all local models. - /// public IEnumerable GetAllLocal() => ModelRegistry.GetAllLocal(); - /// - /// Gets a model by its ID. - /// public AIModel GetModel(string modelId) => ModelRegistry.GetById(modelId); - /// - /// Gets the embedding model. - /// - public LocalModel GetEmbeddingModel() => new Nomic_Embedding(); + public AIModel GetEmbeddingModel() => new Nomic_Embedding(); - /// - /// Checks if a local model file exists on disk. - /// public bool Exists(string modelId) { if (string.IsNullOrWhiteSpace(modelId)) { - throw new ArgumentException(nameof(modelId)); + throw new MissingModelIdException(nameof(modelId)); } var model = ModelRegistry.GetById(modelId); @@ -67,48 +52,43 @@ public bool Exists(string modelId) return File.Exists(modelPath); } - /// - /// Downloads a model by its ID. - /// public async Task DownloadAsync(string modelId, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(modelId)) { - throw new MissingModelNameException(nameof(modelName)); + throw new MissingModelIdException(nameof(modelId)); } - var model = ModelRegistry.GetById(modelId); + var model = ModelRegistry.GetById(modelId) ?? throw new ModelNotSupportedException(modelId); + if (model is not LocalModel localModel) { - throw new InvalidOperationException($"Model '{modelId}' is not a local model and cannot be downloaded."); + throw new InvalidModelTypeException(nameof(LocalModel)); } - if (localModel.DownloadUrl == null) + if (localModel.DownloadUrl is null) { - throw new InvalidOperationException($"Model '{modelId}' does not have a download URL."); + throw new DownloadUrlNullOrEmptyException(); } await DownloadModelAsync(localModel.DownloadUrl.ToString(), localModel.FileName, cancellationToken); return this; } - /// - /// Downloads a custom model from a URL and registers it. - /// - public async Task DownloadAsync(string modelId, string url) + public async Task DownloadAsync(string modelId, string url, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(modelId)) { - throw new MissingModelNameException(nameof(model)); + throw new MissingModelIdException(nameof(modelId)); } if (string.IsNullOrWhiteSpace(url)) { - throw new ArgumentException("URL cannot be null or empty", nameof(url)); + throw new DownloadUrlNullOrEmptyException(); } var fileName = $"{modelId}.gguf"; - await DownloadModelAsync(url, fileName, CancellationToken.None); + await DownloadModelAsync(url, fileName, cancellationToken); // Register the newly downloaded model var newModel = new GenericLocalModel( @@ -127,15 +107,20 @@ public IModelContext Download(string modelId) { if (string.IsNullOrWhiteSpace(modelId)) { - throw new ArgumentException(MissingModelName, nameof(modelId)); + throw new MissingModelIdException(nameof(modelId)); } - var model = ModelRegistry.GetById(modelId); - if (model is not LocalModel localModel || localModel.DownloadUrl == null) + var model = ModelRegistry.GetById(modelId) ?? throw new ModelNotSupportedException(modelId); + if (model is not LocalModel localModel) { - throw new MissingModelNameException(nameof(modelName)); + throw new MissingModelIdException(nameof(LocalModel)); } - + + if (localModel.DownloadUrl is null) + { + throw new DownloadUrlNullOrEmptyException(); + } + DownloadModelSync(localModel.DownloadUrl.ToString(), localModel.FileName); return this; } @@ -145,17 +130,18 @@ public IModelContext Download(string modelId, string url) { if (string.IsNullOrWhiteSpace(modelId)) { - throw new MissingModelNameException(nameof(model)); + throw new MissingModelIdException(nameof(modelId)); } if (string.IsNullOrWhiteSpace(url)) { - throw new ArgumentException("URL cannot be null or empty", nameof(url)); + throw new DownloadUrlNullOrEmptyException(); } var fileName = $"{modelId}.gguf"; DownloadModelSync(url, fileName); - + + // Register the newly downloaded model var newModel = new GenericLocalModel( FileName: fileName, Name: modelId, @@ -163,13 +149,10 @@ public IModelContext Download(string modelId, string url) DownloadUrl: new Uri(url) ); ModelRegistry.RegisterOrReplace(newModel); - + return this; } - /// - /// Loads a local model into cache for faster subsequent access. - /// public IModelContext LoadToCache(LocalModel model) { ArgumentNullException.ThrowIfNull(model); @@ -179,9 +162,6 @@ public IModelContext LoadToCache(LocalModel model) return this; } - /// - /// Loads a local model into cache asynchronously. - /// public async Task LoadToCacheAsync(LocalModel model) { ArgumentNullException.ThrowIfNull(model); diff --git a/src/MaIN.Domain/Entities/Chat.cs b/src/MaIN.Domain/Entities/Chat.cs index babe5bd..dd343f3 100644 --- a/src/MaIN.Domain/Entities/Chat.cs +++ b/src/MaIN.Domain/Entities/Chat.cs @@ -10,7 +10,11 @@ public class Chat public string Id { get; init; } = string.Empty; public required string Name { get; init; } public required string ModelId { get; set; } // TODO: make set private and settable by the ModelInstance setter - public AIModel? ModelInstance { get; set; } + public AIModel? ModelInstance + { + get => _modelInstance ?? ModelRegistry.GetById(ModelId); + set => _modelInstance = value; + } public List Messages { get; set; } = []; public ChatType Type { get; set; } = ChatType.Conversation; public bool Visual { get; set; } @@ -25,5 +29,6 @@ public class Chat public bool Interactive = false; public bool Translate = false; - + + private AIModel? _modelInstance; } \ No newline at end of file diff --git a/src/MaIN.Domain/Exceptions/ChatConfigurationException.cs b/src/MaIN.Domain/Exceptions/ChatConfigurationException.cs deleted file mode 100644 index 8bbeed4..0000000 --- a/src/MaIN.Domain/Exceptions/ChatConfigurationException.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace MaIN.Domain.Exceptions; - -public class ChatConfigurationException(string message) : Exception(message) -{ -} diff --git a/src/MaIN.Domain/Exceptions/Models/InvalidModelType.cs b/src/MaIN.Domain/Exceptions/Models/InvalidModelType.cs new file mode 100644 index 0000000..e87f188 --- /dev/null +++ b/src/MaIN.Domain/Exceptions/Models/InvalidModelType.cs @@ -0,0 +1,10 @@ +using System.Net; + +namespace MaIN.Domain.Exceptions.Models; + +public class InvalidModelTypeException(string expectedType) + : MaINCustomException($"Expected {expectedType}") +{ + public override string PublicErrorMessage => "Invalid model type."; + public override HttpStatusCode HttpStatusCode => HttpStatusCode.BadRequest; +} diff --git a/src/MaIN.Domain/Exceptions/Models/LocalModels/DownloadUrlNullOrEmptyException.cs b/src/MaIN.Domain/Exceptions/Models/LocalModels/DownloadUrlNullOrEmptyException.cs new file mode 100644 index 0000000..56096cf --- /dev/null +++ b/src/MaIN.Domain/Exceptions/Models/LocalModels/DownloadUrlNullOrEmptyException.cs @@ -0,0 +1,10 @@ +using System.Net; + +namespace MaIN.Domain.Exceptions.Models.LocalModels; + +public class DownloadUrlNullOrEmptyException() + : MaINCustomException("Download url cannot be null or empty.") +{ + public override string PublicErrorMessage => "Download url cannot be null or empty."; + public override HttpStatusCode HttpStatusCode => HttpStatusCode.BadRequest; +} \ No newline at end of file diff --git a/src/MaIN.Domain/Exceptions/Models/MissingModelNameException.cs b/src/MaIN.Domain/Exceptions/Models/MissingModelIdException.cs similarity index 59% rename from src/MaIN.Domain/Exceptions/Models/MissingModelNameException.cs rename to src/MaIN.Domain/Exceptions/Models/MissingModelIdException.cs index ea5e299..d0cdd96 100644 --- a/src/MaIN.Domain/Exceptions/Models/MissingModelNameException.cs +++ b/src/MaIN.Domain/Exceptions/Models/MissingModelIdException.cs @@ -2,9 +2,9 @@ namespace MaIN.Domain.Exceptions.Models; -public class MissingModelNameException(string modelNameParameter) - : MaINCustomException($"Model name cannot be null or empty, {modelNameParameter}.") +public class MissingModelIdException(string modelIdParameter) + : MaINCustomException($"Model id cannot be null or empty, {modelIdParameter}.") { public override string PublicErrorMessage => "Model name cannot be null or empty"; public override HttpStatusCode HttpStatusCode => HttpStatusCode.BadRequest; -} \ No newline at end of file +} diff --git a/src/MaIN.Domain/Models/Abstract/AIModel.cs b/src/MaIN.Domain/Models/Abstract/AIModel.cs index f5b7542..00e55d1 100644 --- a/src/MaIN.Domain/Models/Abstract/AIModel.cs +++ b/src/MaIN.Domain/Models/Abstract/AIModel.cs @@ -93,7 +93,7 @@ public record GenericCloudReasoningModel( ) : CloudModel(Id, Backend, Name, MaxContextWindowSize, Description, SystemMessage), IReasoningModel { // IReasoningModel - null for cloud (handled by provider API) - public Func? ReasonFunction => null; + public Func? ReasonFunction => null; public string? AdditionalPrompt { get; } = AdditionalPrompt; } @@ -126,7 +126,7 @@ public record GenericCloudVisionReasoningModel( public string? MMProjectPath => null; // IReasoningModel - null for cloud (handled by provider API) - public Func? ReasonFunction => null; + public Func? ReasonFunction => null; public string? AdditionalPrompt { get; } = AdditionalPrompt; } @@ -155,7 +155,7 @@ public override string GetFullPath(string basePath) /// Generic class for runtime defined local models with reasoning capability. public record GenericReasoningModel( string FileName, - Func ReasonFunction, + Func ReasonFunction, string? Name = null, string? Id = null, Uri? DownloadUrl = null, @@ -169,7 +169,7 @@ public record GenericReasoningModel( public string? CustomPath { get; set; } = CustomPath; // IReasoningModel implementation - public Func ReasonFunction { get; } = ReasonFunction; + public Func ReasonFunction { get; } = ReasonFunction; public string? AdditionalPrompt { get; } = AdditionalPrompt; public override bool IsDownloaded(string basePath) @@ -208,7 +208,7 @@ public override string GetFullPath(string basePath) public record GenericVisionReasoningModel( string FileName, string MMProjectPath, - Func ReasonFunction, + Func ReasonFunction, string? Name = null, string? Id = null, Uri? DownloadUrl = null, diff --git a/src/MaIN.Domain/Models/Abstract/ModelRegistry.cs b/src/MaIN.Domain/Models/Abstract/ModelRegistry.cs index dfd25b6..f3182f7 100644 --- a/src/MaIN.Domain/Models/Abstract/ModelRegistry.cs +++ b/src/MaIN.Domain/Models/Abstract/ModelRegistry.cs @@ -17,12 +17,18 @@ static ModelRegistry() private static void Initialize() { - if (_initialized) return; - + if (_initialized) + { + return; + } + lock (_lock) { - if (_initialized) return; - + if (_initialized) + { + return; + } + // Reflection but only at startup to register all available models // Skip abstract, generic types, and Generic* classes (they're for runtime registration) var modelTypes = Assembly.GetExecutingAssembly() @@ -173,5 +179,5 @@ public static bool Unregister(string id) return _models.TryRemove(NormalizeId(id), out _); } - private static string NormalizeId(string id) => id.Trim().Replace(':', '-'); + private static string NormalizeId(string id) => id.Trim(); } diff --git a/src/MaIN.Domain/Models/Concrete/CloudModels.cs b/src/MaIN.Domain/Models/Concrete/CloudModels.cs new file mode 100644 index 0000000..4f370de --- /dev/null +++ b/src/MaIN.Domain/Models/Concrete/CloudModels.cs @@ -0,0 +1,137 @@ +using MaIN.Domain.Configuration; +using MaIN.Domain.Models.Abstract; + +namespace MaIN.Domain.Models.Concrete; + +// ===== OpenAI Models ===== + +public sealed record Gpt4oMini() : CloudModel( + "gpt-4o-mini", + BackendType.OpenAi, + "GPT-4o Mini", + 128000, + "Fast and affordable OpenAI model for everyday tasks") +{ +} + +public sealed record Gpt4_1Mini() : CloudModel( + "gpt-4.1-mini", + BackendType.OpenAi, + "GPT-4.1 Mini", + 128000, + "Updated mini model with improved capabilities") +{ +} + +public sealed record Gpt5Nano() : CloudModel( + "gpt-5-nano", + BackendType.OpenAi, + "GPT-5 Nano", + 128000, + "Next generation OpenAI nano model") +{ +} + +public sealed record DallE3() : CloudModel( + "dall-e-3", + BackendType.OpenAi, + "DALL-E 3", + 4000, + "Advanced image generation model from OpenAI") +{ +} + +// ===== Anthropic Models ===== + +public sealed record ClaudeSonnet4() : CloudModel( + "claude-sonnet-4-20250514", + BackendType.Anthropic, + "Claude Sonnet 4", + 200000, + "Latest Claude model with enhanced reasoning capabilities") +{ +} + +public sealed record ClaudeSonnet4_5() : CloudModel( + "claude-sonnet-4-5-20250929", + BackendType.Anthropic, + "Claude Sonnet 4.5", + 200000, + "Advanced Claude model with superior performance and extended context") +{ +} + +// ===== Gemini Models ===== + +public sealed record Gemini2_5Flash() : CloudModel( + "gemini-2.5-flash", + BackendType.Gemini, + "Gemini 2.5 Flash", + 1000000, + "Fast and efficient Google Gemini model for quick responses") +{ +} + +public sealed record Gemini2_0Flash() : CloudModel( + "gemini-2.0-flash", + BackendType.Gemini, + "Gemini 2.0 Flash", + 1000000, + "Google Gemini 2.0 flash model optimized for speed and efficiency") +{ +} + +// ===== xAI Models ===== + +public sealed record Grok3Beta() : CloudModel( + "grok-3-beta", + BackendType.Xai, + "Grok 3 Beta", + 128000, + "xAI latest Grok model in beta testing phase") +{ +} + +// ===== GroqCloud Models ===== + +public sealed record Llama3_1_8bInstant() : CloudModel( + "llama-3.1-8b-instant", + BackendType.GroqCloud, + "Llama 3.1 8B Instant", + 8192, + "Meta Llama 3.1 8B model optimized for fast inference on Groq hardware") +{ +} + +public sealed record GptOss20b() : CloudModel( + "openai/gpt-oss-20b", + BackendType.GroqCloud, + "GPT OSS 20B", + 8192, + "Open-source 20B parameter GPT model running on Groq infrastructure") +{ +} + +// ===== DeepSeek Models ===== + +public sealed record DeepSeekReasoner() : CloudModel( + "deepseek-reasoner", + BackendType.DeepSeek, + "DeepSeek Reasoner", + 64000, + "DeepSeek reasoning-focused model for complex problem solving"), IReasoningModel +{ + public Func? ReasonFunction => null; + public string? AdditionalPrompt => null; +} + +// ===== Ollama Models ===== + +public sealed record OllamaGemma3_4b() : CloudModel( + "gemma3:4b", + BackendType.Ollama, + "Gemma3 4B (Ollama)", + 8192, + "Balanced 4B model running on Ollama for writing, analysis, and mathematical reasoning") +{ +} diff --git a/src/MaIN.Domain/Models/Concrete/Gpt4o.cs b/src/MaIN.Domain/Models/Concrete/Gpt4o.cs deleted file mode 100644 index ee50324..0000000 --- a/src/MaIN.Domain/Models/Concrete/Gpt4o.cs +++ /dev/null @@ -1,13 +0,0 @@ -using MaIN.Domain.Configuration; -using MaIN.Domain.Models.Abstract; - -namespace MaIN.Domain.Models.Concrete; - -public sealed record Gpt4o : CloudModel -{ - public override string Id => "gpt-4o"; - public override string Name => "GPT-4 Omni"; - public override BackendType Backend => BackendType.OpenAi; - public override uint MaxContextWindowSize => 128000; // TODO: verify - public override string Description => "Most advanced OpenAI model."; -} diff --git a/src/MaIN.Domain/Models/Concrete/LocalModels.cs b/src/MaIN.Domain/Models/Concrete/LocalModels.cs index 971dd88..e762280 100644 --- a/src/MaIN.Domain/Models/Concrete/LocalModels.cs +++ b/src/MaIN.Domain/Models/Concrete/LocalModels.cs @@ -1,14 +1,13 @@ using MaIN.Domain.Models.Abstract; -using MaIN.Domain.Models; namespace MaIN.Domain.Models.Concrete; // ===== Gemma Family ===== -public sealed record Gemma_2b() : LocalModel( - "gemma-2b", - "Gemma-2b.gguf", - new Uri("https://huggingface.co/Inza124/Gemma-2b/resolve/main/gemma-2b-maIN.gguf?download=true"), +public sealed record Gemma2_2b() : LocalModel( + "gemma2-2b", + "Gemma2-2b.gguf", + new Uri("https://huggingface.co/Inza124/gemma2_2b/resolve/main/gemma2-2b-maIN.gguf?download=true"), "Gemma 2B", 8192, "Lightweight 2B model for general-purpose text generation and understanding") @@ -78,6 +77,17 @@ public sealed record Llava_7b() : LocalModel( public string MMProjectPath => "mmproj-model-f16.gguf"; } +public sealed record Llava16Mistral_7b() : LocalModel( + "llava-1.6-mistral-7b", + "llava-1.6-mistral-7b.gguf", + new Uri("https://huggingface.co/cjpais/llava-1.6-mistral-7b-gguf/resolve/main/llava-v1.6-mistral-7b.Q3_K_XS.gguf?download=true"), + "LLaVA 1.6 Mistral 7B", + 4096, + "Vision-language model for image analysis, OCR, and visual Q&A"), IVisionModel +{ + public string MMProjectPath => "mmproj-model-f16.gguf"; +} + // ===== Hermes Family ===== public sealed record Hermes3_3b() : LocalModel( diff --git a/src/MaIN.Services/Services/ChatService.cs b/src/MaIN.Services/Services/ChatService.cs index 019c4cc..c63f0c7 100644 --- a/src/MaIN.Services/Services/ChatService.cs +++ b/src/MaIN.Services/Services/ChatService.cs @@ -44,19 +44,19 @@ public async Task Completions( translate = translate || chat.Translate; interactiveUpdates = interactiveUpdates || chat.Interactive; var newMsg = chat.Messages.Last(); - newMsg.Time = DateTime.Now; // TODO: introduce IDateTimeProvider (better for tests) + newMsg.Time = DateTime.Now; var lng = translate ? await translatorService.DetectLanguage(newMsg.Content) : null; var originalMessages = chat.Messages; if (translate) { - chat.Messages = (await Task.WhenAll(chat.Messages.Select(async m => new Message() + chat.Messages = [.. await Task.WhenAll(chat.Messages.Select(async m => new Message() { Role = m.Role, Content = await translatorService.Translate(m.Content, "en"), Type = m.Type - }))).ToList(); + }))]; } var result = chat.Visual @@ -100,15 +100,9 @@ public async Task Delete(string id) public async Task GetById(string id) { var chatDocument = await chatProvider.GetChatById(id); - if (chatDocument == null) - { - throw new ChatNotFoundException(id); - } - - return chatDocument.ToDomain(); + return chatDocument is null ? throw new ChatNotFoundException(id) : chatDocument.ToDomain(); } public async Task> GetAll() - => (await chatProvider.GetAllChats()) - .Select(x => x.ToDomain()).ToList(); + => [.. (await chatProvider.GetAllChats()).Select(x => x.ToDomain())]; } \ No newline at end of file diff --git a/src/MaIN.Services/Services/LLMService/LLMService.cs b/src/MaIN.Services/Services/LLMService/LLMService.cs index e4aaf56..9999825 100644 --- a/src/MaIN.Services/Services/LLMService/LLMService.cs +++ b/src/MaIN.Services/Services/LLMService/LLMService.cs @@ -68,7 +68,7 @@ public LLMService( return await AskMemory(chat, memoryOptions, requestOptions, cancellationToken); } - if (chat.ToolsConfiguration?.Tools != null && chat.ToolsConfiguration.Tools.Any()) + if (chat.ToolsConfiguration?.Tools != null && chat.ToolsConfiguration.Tools.Count != 0) { return await ProcessWithToolsAsync(chat, requestOptions, cancellationToken); } @@ -119,13 +119,13 @@ public Task CleanSessionCache(string? id) ? await LLamaWeights.LoadFromFileAsync(parameters, cancellationToken) : await ModelLoader.GetOrLoadModelAsync(modelsPath, model.FileName); - var memory = memoryFactory.CreateMemoryWithModel( + var (km, generator, textGenerator) = memoryFactory.CreateMemoryWithModel( modelsPath, llmModel, model.FileName, chat.MemoryParams); - await memoryService.ImportDataToMemory((memory.km, memory.generator), memoryOptions, cancellationToken); + await memoryService.ImportDataToMemory((km, generator), memoryOptions, cancellationToken); var userMessage = chat.Messages.Last(); MemoryAnswer result; @@ -139,7 +139,7 @@ public Task CleanSessionCache(string? id) Stream = true }; - await foreach (var chunk in memory.km.AskStreamingAsync( + await foreach (var chunk in km.AskStreamingAsync( userMessage.Content, options: searchOptions, cancellationToken: cancellationToken)) @@ -179,23 +179,23 @@ await notificationService.DispatchNotification( Stream = false }; - result = await memory.km.AskAsync( + result = await km.AskAsync( userMessage.Content, options: searchOptions, cancellationToken: cancellationToken); } - await memory.km.DeleteIndexAsync(cancellationToken: cancellationToken); + await km.DeleteIndexAsync(cancellationToken: cancellationToken); if (disableCache) { llmModel.Dispose(); ModelLoader.RemoveModel(model.FileName); - memory.textGenerator.Dispose(); + textGenerator.Dispose(); } - memory.generator._embedder.Dispose(); - memory.generator._embedder._weights.Dispose(); - memory.generator.Dispose(); + generator._embedder.Dispose(); + generator._embedder._weights.Dispose(); + generator.Dispose(); return new ChatResult { @@ -229,13 +229,13 @@ private async Task> ProcessChatRequest( : await ModelLoader.GetOrLoadModelAsync(modelsPath, modelKey); var visionModel = model as IVisionModel; - var llavaWeights = visionModel?.MMProjectPath != null - ? await LLavaWeights.LoadFromFileAsync(visionModel.MMProjectPath, cancellationToken) + var llavaWeights = visionModel?.MMProjectPath is not null + ? await LLavaWeights.LoadFromFileAsync(Path.Combine(modelsPath, visionModel.MMProjectPath), cancellationToken) : null; using var executor = new BatchedExecutor(llmModel, parameters); - var (conversation, isComplete, hasFailed) = await InitializeConversation( + var (conversation, isComplete, hasFailed) = await LLMService.InitializeConversation( chat, lastMsg, model, llmModel, llavaWeights, executor, cancellationToken); if (!isComplete) @@ -280,7 +280,7 @@ private ModelParams CreateModelParameters(Chat chat, string modelKey, string? cu } - private async Task<(Conversation Conversation, bool IsComplete, bool HasFailed)> InitializeConversation(Chat chat, + private static async Task<(Conversation Conversation, bool IsComplete, bool HasFailed)> InitializeConversation(Chat chat, Message lastMsg, LocalModel model, LLamaWeights llmModel, @@ -335,7 +335,7 @@ private static void ProcessTextMessage(Conversation conversation, var template = new LLamaTemplate(llmModel); var finalPrompt = ChatHelper.GetFinalPrompt(lastMsg, model, isNewConversation); - var hasTools = chat.ToolsConfiguration?.Tools != null && chat.ToolsConfiguration.Tools.Any(); + var hasTools = chat.ToolsConfiguration?.Tools != null && chat.ToolsConfiguration.Tools.Count != 0; if (isNewConversation) { @@ -409,7 +409,7 @@ private static string FormatToolsForPrompt(ToolsConfiguration toolsConfig) var isComplete = false; var hasFailed = false; - using var sampler = CreateSampler(chat.InterferenceParams); + using var sampler = LLMService.CreateSampler(chat.InterferenceParams); var decoder = new StreamingTokenDecoder(executor.Context); var inferenceParams = ChatHelper.CreateInferenceParams(chat, llmModel); @@ -429,10 +429,14 @@ private static string FormatToolsForPrompt(ToolsConfiguration toolsConfig) } if (decodeResult == DecodeResult.DecodeFailed) + { throw new Exception("Unknown error occurred while inferring."); + } if (!conversation.RequiresSampling) + { continue; + } var token = conversation.Sample(sampler); var vocab = executor.Context.NativeHandle.ModelHandle.Vocab; @@ -471,7 +475,7 @@ private static string FormatToolsForPrompt(ToolsConfiguration toolsConfig) return (tokens, isComplete, hasFailed); } - private BaseSamplingPipeline CreateSampler(InferenceParams interferenceParams) + private static BaseSamplingPipeline CreateSampler(InferenceParams interferenceParams) { if (interferenceParams.Temperature == 0) { @@ -704,7 +708,7 @@ await requestOptions.ToolCallback.Invoke(new ToolInvocation { Done = true, CreatedAt = DateTime.Now, - Model = chat.Model, + Model = chat.ModelId, Message = chat.Messages.Last() }; } From c1f0045ac6f1e81e77bc64e2fdb228d8134b0c95 Mon Sep 17 00:00:00 2001 From: srebrek Date: Mon, 2 Feb 2026 13:49:58 +0100 Subject: [PATCH 5/5] cleanup --- Examples/Examples/Utils/GeminiExampleSetup.cs | 2 +- MaIN.Core.IntegrationTests/ChatTests.cs | 1 + MaIN.sln | 9 +------ Test/Program.cs | 24 ------------------- Test/Test.csproj | 20 ---------------- Test/appsettings.json | 12 ---------- src/MaIN.Core/Hub/Contexts/ModelContext.cs | 23 ++++++++++++------ src/MaIN.Domain/Entities/Chat.cs | 9 ++++--- .../Models/MissingModelInstanceException.cs | 10 ++++++++ .../Components/Pages/Home.razor | 2 +- src/MaIN.Services/Dtos/ChatDto.cs | 2 +- src/MaIN.Services/Services/ChatService.cs | 2 +- .../Services/LLMService/LLMService.cs | 11 ++++++--- 13 files changed, 44 insertions(+), 83 deletions(-) delete mode 100644 Test/Program.cs delete mode 100644 Test/Test.csproj delete mode 100644 Test/appsettings.json create mode 100644 src/MaIN.Domain/Exceptions/Models/MissingModelInstanceException.cs diff --git a/Examples/Examples/Utils/GeminiExampleSetup.cs b/Examples/Examples/Utils/GeminiExampleSetup.cs index 5475847..0c03765 100644 --- a/Examples/Examples/Utils/GeminiExampleSetup.cs +++ b/Examples/Examples/Utils/GeminiExampleSetup.cs @@ -10,7 +10,7 @@ public static void Setup() MaINBootstrapper.Initialize(configureSettings: (options) => { options.BackendType = BackendType.Gemini; - options.GeminiKey = ""; + options.GeminiKey = ""; }); } } \ No newline at end of file diff --git a/MaIN.Core.IntegrationTests/ChatTests.cs b/MaIN.Core.IntegrationTests/ChatTests.cs index 27a5fb7..e43a5f0 100644 --- a/MaIN.Core.IntegrationTests/ChatTests.cs +++ b/MaIN.Core.IntegrationTests/ChatTests.cs @@ -66,6 +66,7 @@ public async Task Should_AnswerGameFromImage_ChatWithVision() var result = await AIHub.Chat() .WithModel() + .WithMessage("What is the title of game?") .WithMemoryParams(new MemoryParams { AnswerTokens = 1000 diff --git a/MaIN.sln b/MaIN.sln index 9a5bb26..4417e84 100644 --- a/MaIN.sln +++ b/MaIN.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 18 -VisualStudioVersion = 18.1.11312.151 d18.0 +VisualStudioVersion = 18.1.11312.151 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MaIN.Infrastructure", "src\MaIN.Infrastructure\MaIN.Infrastructure.csproj", "{84C09EF9-B9E3-4ACB-9BC3-DEFFC29D528F}" EndProject @@ -25,8 +25,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MaIN.InferPage", "src\MaIN. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MaIN.Core.IntegrationTests", "MaIN.Core.IntegrationTests\MaIN.Core.IntegrationTests.csproj", "{2C15062A-E9F6-47FC-A4CD-1190A49E3FE3}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Test", "Test\Test.csproj", "{EC6B1BDC-EA44-41C9-86AB-ED9CF1AC35B7}" -EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -69,10 +67,6 @@ Global {2C15062A-E9F6-47FC-A4CD-1190A49E3FE3}.Debug|Any CPU.Build.0 = Debug|Any CPU {2C15062A-E9F6-47FC-A4CD-1190A49E3FE3}.Release|Any CPU.ActiveCfg = Release|Any CPU {2C15062A-E9F6-47FC-A4CD-1190A49E3FE3}.Release|Any CPU.Build.0 = Release|Any CPU - {EC6B1BDC-EA44-41C9-86AB-ED9CF1AC35B7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {EC6B1BDC-EA44-41C9-86AB-ED9CF1AC35B7}.Debug|Any CPU.Build.0 = Debug|Any CPU - {EC6B1BDC-EA44-41C9-86AB-ED9CF1AC35B7}.Release|Any CPU.ActiveCfg = Release|Any CPU - {EC6B1BDC-EA44-41C9-86AB-ED9CF1AC35B7}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -82,7 +76,6 @@ Global {46E6416B-1736-478C-B697-B37BB8E6A23E} = {53D24B04-279D-4D18-8829-EA0F57AE69F3} {75DEBB8A-75CD-44BA-9369-3916950428EF} = {28851935-517F-438D-BF7C-02FEB1A37A68} {2C15062A-E9F6-47FC-A4CD-1190A49E3FE3} = {53D24B04-279D-4D18-8829-EA0F57AE69F3} - {EC6B1BDC-EA44-41C9-86AB-ED9CF1AC35B7} = {28851935-517F-438D-BF7C-02FEB1A37A68} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {82494AB7-60FC-49E3-B37C-B9785FB0DE7A} diff --git a/Test/Program.cs b/Test/Program.cs deleted file mode 100644 index 53d4043..0000000 --- a/Test/Program.cs +++ /dev/null @@ -1,24 +0,0 @@ -using MaIN.Core; -using MaIN.Core.Hub; -using MaIN.Core.Hub.Contexts; -using MaIN.Domain.Models.Concrete; -using MaIN.Services.Services.Models; - -internal class Program -{ - private static async Task Main(string[] args) - { - Console.WriteLine("Hello, World!"); - - MaINBootstrapper.Initialize(); - - ChatContext chat = AIHub.Chat(); - chat.WithModel("gemma2:2b"); // Using string (deprecated) - chat.WithModel(); // Using strongly-typed model - chat.WithMessage("Where do hedgehogs goes at night?"); - await chat.CompleteAsync(interactive: true); - - chat.WithMessage("What were you talking about in the previous message?."); - await chat.CompleteAsync(interactive: true); - } -} \ No newline at end of file diff --git a/Test/Test.csproj b/Test/Test.csproj deleted file mode 100644 index 034f973..0000000 --- a/Test/Test.csproj +++ /dev/null @@ -1,20 +0,0 @@ - - - - Exe - net10.0 - enable - enable - - - - - - - - - PreserveNewest - - - - diff --git a/Test/appsettings.json b/Test/appsettings.json deleted file mode 100644 index abb9ec9..0000000 --- a/Test/appsettings.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "MaIN": { - "ImageGenUrl": "http://localhost:5003" - }, - "AllowedHosts": "*" -} diff --git a/src/MaIN.Core/Hub/Contexts/ModelContext.cs b/src/MaIN.Core/Hub/Contexts/ModelContext.cs index 17796d9..1883827 100644 --- a/src/MaIN.Core/Hub/Contexts/ModelContext.cs +++ b/src/MaIN.Core/Hub/Contexts/ModelContext.cs @@ -197,7 +197,7 @@ private async Task DownloadModelAsync(string url, string fileName, CancellationT } } - private async Task DownloadWithProgressAsync(HttpResponseMessage response, string filePath, string fileName, CancellationToken cancellationToken) + private static async Task DownloadWithProgressAsync(HttpResponseMessage response, string filePath, string fileName, CancellationToken cancellationToken) { var totalBytes = response.Content.Headers.ContentLength; var totalBytesRead = 0L; @@ -215,10 +215,13 @@ private async Task DownloadWithProgressAsync(HttpResponseMessage response, strin while (true) { - var bytesRead = await contentStream.ReadAsync(buffer, 0, buffer.Length, cancellationToken); - if (bytesRead == 0) break; + var bytesRead = await contentStream.ReadAsync(buffer, cancellationToken); + if (bytesRead == 0) + { + break; + } - await fileStream.WriteAsync(buffer, 0, bytesRead, cancellationToken); + await fileStream.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken); totalBytesRead += bytesRead; if (ShouldUpdateProgress(progressStopwatch)) @@ -317,7 +320,7 @@ private static void ShowProgress(long totalBytesRead, long? totalBytes, Stopwatc $"Speed: {FormatBytes((long)speed)}/s ETA: {eta:hh\\:mm\\:ss}"); var (leftAfter, topAfter) = Console.GetCursorPosition(); - int lengthDifference = leftBefore - leftAfter + (topBefore - topAfter) * Console.WindowWidth; + int lengthDifference = leftBefore - leftAfter + ((topBefore - topAfter) * Console.WindowWidth); while (lengthDifference > 0) { Console.Write(' '); @@ -345,7 +348,10 @@ private static void ShowFinalProgress(long totalBytesRead, Stopwatch totalStopwa private static string FormatBytes(long bytes) { - if (bytes == 0) return "0 Bytes"; + if (bytes == 0) + { + return "0 Bytes"; + } const int scale = 1024; string[] orders = ["GB", "MB", "KB", "Bytes"]; @@ -354,14 +360,17 @@ private static string FormatBytes(long bytes) foreach (var order in orders) { if (bytes >= max) + { return $"{decimal.Divide(bytes, max):##.##} {order}"; + } + max /= scale; } return "0 Bytes"; } - private string ResolvePath(string? settingsModelsPath) => + private static string ResolvePath(string? settingsModelsPath) => settingsModelsPath ?? Environment.GetEnvironmentVariable("MaIN_ModelsPath") ?? throw new ModelsPathNotFoundException(); diff --git a/src/MaIN.Domain/Entities/Chat.cs b/src/MaIN.Domain/Entities/Chat.cs index dd343f3..b658831 100644 --- a/src/MaIN.Domain/Entities/Chat.cs +++ b/src/MaIN.Domain/Entities/Chat.cs @@ -9,11 +9,12 @@ public class Chat { public string Id { get; init; } = string.Empty; public required string Name { get; init; } - public required string ModelId { get; set; } // TODO: make set private and settable by the ModelInstance setter + public required string ModelId { get; set; } + private AIModel? _modelInstance; public AIModel? ModelInstance { - get => _modelInstance ?? ModelRegistry.GetById(ModelId); - set => _modelInstance = value; + get => _modelInstance ??= ModelRegistry.GetById(ModelId); + set => (_modelInstance, ModelId) = (value, value?.Id ?? ModelId); } public List Messages { get; set; } = []; public ChatType Type { get; set; } = ChatType.Conversation; @@ -29,6 +30,4 @@ public AIModel? ModelInstance public bool Interactive = false; public bool Translate = false; - - private AIModel? _modelInstance; } \ No newline at end of file diff --git a/src/MaIN.Domain/Exceptions/Models/MissingModelInstanceException.cs b/src/MaIN.Domain/Exceptions/Models/MissingModelInstanceException.cs new file mode 100644 index 0000000..79e342b --- /dev/null +++ b/src/MaIN.Domain/Exceptions/Models/MissingModelInstanceException.cs @@ -0,0 +1,10 @@ +using System.Net; + +namespace MaIN.Domain.Exceptions.Models; + +public class MissingModelInstanceException() + : MaINCustomException("Model instance cannot be null.") +{ + public override string PublicErrorMessage => "Model instance cannot be null."; + public override HttpStatusCode HttpStatusCode => HttpStatusCode.BadRequest; +} diff --git a/src/MaIN.InferPage/Components/Pages/Home.razor b/src/MaIN.InferPage/Components/Pages/Home.razor index 1711683..4e4ad60 100644 --- a/src/MaIN.InferPage/Components/Pages/Home.razor +++ b/src/MaIN.InferPage/Components/Pages/Home.razor @@ -192,7 +192,7 @@ private string? _incomingMessage = null; private string? _incomingReasoning = null; private readonly string? _displayName = Utils.Model; - private IChatMessageBuilder? ctx; + private IChatMessageBuilder? ctxBuilder; private Chat Chat { get; } = new() { Name = "MaIN Infer", ModelId = Utils.Model! }; private List Messages { get; set; } = new(); private ElementReference? _bottomElement; diff --git a/src/MaIN.Services/Dtos/ChatDto.cs b/src/MaIN.Services/Dtos/ChatDto.cs index ac29561..e2bee19 100644 --- a/src/MaIN.Services/Dtos/ChatDto.cs +++ b/src/MaIN.Services/Dtos/ChatDto.cs @@ -2,7 +2,7 @@ namespace MaIN.Services.Dtos; -public class ChatDto // TODO: make it record and not nullable for Id, Name, Model as they are required +public record ChatDto { [JsonPropertyName("id")] public string? Id { get; set; } diff --git a/src/MaIN.Services/Services/ChatService.cs b/src/MaIN.Services/Services/ChatService.cs index c63f0c7..0792911 100644 --- a/src/MaIN.Services/Services/ChatService.cs +++ b/src/MaIN.Services/Services/ChatService.cs @@ -36,7 +36,7 @@ public async Task Completions( { chat.Visual = true; // TODO: add IImageGenModel interface and check for that instead } - chat.Backend ??= settings.BackendType; // can be romved as we set backend on chat creation + chat.Backend ??= settings.BackendType; chat.Messages.Where(x => x.Type == MessageType.NotSet).ToList() .ForEach(x => x.Type = chat.Backend != BackendType.Self ? MessageType.CloudLLM : MessageType.LocalLLM); diff --git a/src/MaIN.Services/Services/LLMService/LLMService.cs b/src/MaIN.Services/Services/LLMService/LLMService.cs index 9999825..3dc5114 100644 --- a/src/MaIN.Services/Services/LLMService/LLMService.cs +++ b/src/MaIN.Services/Services/LLMService/LLMService.cs @@ -79,7 +79,7 @@ public LLMService( return await CreateChatResult(chat, tokens, requestOptions); } - public Task GetCurrentModels() // TODO: return AIModel[] + public Task GetCurrentModels() { var models = Directory.GetFiles(modelsPath, "*.gguf", SearchOption.AllDirectories) .Select(Path.GetFileName) @@ -554,7 +554,12 @@ private async Task ProcessWithToolsAsync( ChatRequestOptions requestOptions, CancellationToken cancellationToken) { - var model = KnownModels.GetModel(chat.Model); + var model = chat.ModelInstance ?? throw new MissingModelInstanceException(); + if (model is not LocalModel localModel) + { + throw new InvalidModelTypeException(nameof(LocalModel)); + } + var iterations = 0; var lastResponseTokens = new List(); var lastResponse = string.Empty; @@ -565,7 +570,7 @@ private async Task ProcessWithToolsAsync( var tokenCallbackOrg = requestOptions.TokenCallback; requestOptions.InteractiveUpdates = false; requestOptions.TokenCallback = null; - lastResponseTokens = await ProcessChatRequest(chat, model, lastMsg, requestOptions, cancellationToken); + lastResponseTokens = await ProcessChatRequest(chat, localModel, lastMsg, requestOptions, cancellationToken); lastMsg.MarkProcessed(); lastResponse = string.Concat(lastResponseTokens.Select(x => x.Text)); var responseMessage = new Message