From 56e08b22817b8cdf85de87651473c9fc595e2e40 Mon Sep 17 00:00:00 2001 From: Magdalena Majta Date: Wed, 11 Feb 2026 12:40:13 +0100 Subject: [PATCH 01/22] chat main layout --- src/MaIN.InferPage/Components/App.razor | 1 + .../Components/Pages/Home.razor | 250 ++++++++---------- src/MaIN.InferPage/wwwroot/editor.js | 8 + src/MaIN.InferPage/wwwroot/home.css | 76 +++++- 4 files changed, 191 insertions(+), 144 deletions(-) create mode 100644 src/MaIN.InferPage/wwwroot/editor.js diff --git a/src/MaIN.InferPage/Components/App.razor b/src/MaIN.InferPage/Components/App.razor index 2543f9aa..b8ab8de5 100644 --- a/src/MaIN.InferPage/Components/App.razor +++ b/src/MaIN.InferPage/Components/App.razor @@ -15,6 +15,7 @@ + \ No newline at end of file diff --git a/src/MaIN.InferPage/Components/Pages/Home.razor b/src/MaIN.InferPage/Components/Pages/Home.razor index f7496366..78301429 100644 --- a/src/MaIN.InferPage/Components/Pages/Home.razor +++ b/src/MaIN.InferPage/Components/Pages/Home.razor @@ -13,166 +13,123 @@ MaIN Infer - - - -
- @foreach (var conversation in Messages) +
+
+ @foreach (var conversation in Messages) + { + @if (conversation.Message.Role != "System") { -
- @if (conversation.Message.Role != "System") + @if (Chat.Visual) { - @if (Chat.Visual) + + @(conversation.Message.Role == "User" ? "User" : Utils.Model) + + @if (conversation.Message.Role == "User") { - - @(conversation.Message.Role == "User" ? "User" : Utils.Model) - - @if (conversation.Message.Role == "User") - { - - @conversation.Message.Content - - } - else - { - -
- - imageResponse - -
-
- } + + @conversation.Message.Content + } else { + +
+ + imageResponse + +
+
+ } + } + else + { + + @if (conversation.Message.Role == "User") { - - User - +
+ @((MarkupString)Markdown.ToHtml(conversation.Message.Content ?? string.Empty, _markdownPipeline)) +
} else { - - - @Utils.Model - - @if (_reasoning && conversation.Message.Role == "Assistant") +
+ @if (conversation.ShowReason) { - +
+ @((MarkupString)Markdown.ToHtml(string.Concat(conversation.Message.Tokens.Where(x => x.Type == TokenType.Reason).Select(x => x.Text)), _markdownPipeline)) +
+
} - + @((MarkupString)Markdown.ToHtml(string.Concat(conversation.Message.Tokens.Where(x => x.Type == TokenType.Message).Select(x => x.Text)), _markdownPipeline)) +
} - - @if (conversation.Message.Role == "User") - { - @conversation.Message.Content - } - else - { -
- @if (conversation.ShowReason) - { -
- @((MarkupString)Markdown.ToHtml(string.Concat(conversation.Message.Tokens.Where(x => x.Type == TokenType.Reason).Select(x => x.Text)), - new MarkdownPipelineBuilder() - .UseAdvancedExtensions() - .Build())) -
-
- } - @((MarkupString)Markdown.ToHtml(string.Concat(conversation.Message.Tokens.Where(x => x.Type == TokenType.Message).Select(x => x.Text)), - new MarkdownPipelineBuilder() - .UseAdvancedExtensions() - .Build())) -
- } - -
- } +
} } - @if (_isLoading) + } + @if (_isLoading) + { + @if (Chat.Visual) { - @if (Chat.Visual) - { - @_displayName - This might take a while... - - } - else + This might take a while... + } + else + { + + @if (_isThinking) + { + Thinking... + } + + @if (_incomingMessage != null || _incomingReasoning != null) { - - @_displayName - + @if (_isThinking) { - Thinking... + + @((MarkupString)Markdown.ToHtml(_incomingReasoning ?? string.Empty, _markdownPipeline)) + + } + else + { + @((MarkupString)Markdown.ToHtml(_incomingMessage ?? string.Empty, _markdownPipeline)) } - - @if (_incomingMessage != null || _incomingReasoning != null) - { - - @if (_isThinking) - { - - @((MarkupString)Markdown.ToHtml(_incomingReasoning!, - new MarkdownPipelineBuilder() - .UseAdvancedExtensions() - .Build())) - - } - else - { - @((MarkupString)Markdown.ToHtml(_incomingMessage!, - new MarkdownPipelineBuilder() - .UseAdvancedExtensions() - .Build())) - } - - } + } } -
-
-
- - + } +
+
+
+
+ + + +
+
- - -
-
- -
@@ -190,8 +147,14 @@ private Chat Chat { get; } = new() { Name = "MaIN Infer", Model = Utils.Model! }; private List Messages { get; set; } = new(); private ElementReference? _bottomElement; + private ElementReference _editorRef; private int _inputKey = 0; + private readonly MarkdownPipeline _markdownPipeline = new MarkdownPipelineBuilder() + .UseAdvancedExtensions() + .UseSoftlineBreakAsHardlineBreak() + .Build(); + protected override async Task OnAfterRenderAsync(bool firstRender) { if (!firstRender) @@ -222,12 +185,23 @@ return base.OnInitializedAsync(); } - private async Task CheckEnterKey(KeyboardEventArgs e) + private async Task HandleKeyDown(KeyboardEventArgs e) { if (e is { Key: "Enter", ShiftKey: false }) { - _prompt = _prompt.Replace("\n", string.Empty); - await SendAsync(_prompt); + await HandleSend(); + } + } + + private async Task HandleSend() + { + if (_isLoading) return; + + var msg = await JS.InvokeAsync("editorManager.getInnerText", _editorRef); + if (!string.IsNullOrWhiteSpace(msg)) + { + await JS.InvokeVoidAsync("editorManager.clearContent", _editorRef); + await SendAsync(msg.Trim()); } } @@ -284,9 +258,9 @@ } } - private void Callback(ChangeEventArgs obj) + private void HandleInput(ChangeEventArgs obj) { - _prompt = obj.Value?.ToString()!; + // Handled by JS on send to ensure accuracy } } \ No newline at end of file diff --git a/src/MaIN.InferPage/wwwroot/editor.js b/src/MaIN.InferPage/wwwroot/editor.js new file mode 100644 index 00000000..d98aeeaf --- /dev/null +++ b/src/MaIN.InferPage/wwwroot/editor.js @@ -0,0 +1,8 @@ +window.editorManager = { + getInnerText: (element) => { + return element.innerText; + }, + clearContent: (element) => { + element.innerText = ""; + } +}; diff --git a/src/MaIN.InferPage/wwwroot/home.css b/src/MaIN.InferPage/wwwroot/home.css index 50421e1d..1f8e49e7 100644 --- a/src/MaIN.InferPage/wwwroot/home.css +++ b/src/MaIN.InferPage/wwwroot/home.css @@ -1,19 +1,82 @@ -.input-container { - width: 100%; +body { + height: 100vh; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.xd { + height: 100%; + display: flex; + flex-direction: column; + flex-grow: 1; +} + +.messages-container>div { + font-size: 72px; +} + +.navbar { +} + +.content { + flex-grow: 1; display: flex; - padding: 5px; + flex-direction: column; + overflow: hidden; } .messages-container { flex-grow: 1; overflow-y: auto; padding: 10px; - max-height: 80vh; - min-height: 80vh; display: flex; flex-direction: column; } +.input-container { + display: flex; + padding: 8px 12px 8px 16px; + align-items: flex-end; + gap: 8px; + background-color: var(--neutral-fill-input-rest); + border-radius: 26px; + border: 1px solid var(--neutral-stroke-rest); + margin: 10px 15px 20px 15px; + position: relative; +} + +.chat-input { + flex-grow: 1; + min-height: 24px; + max-height: 40vh; + overflow-y: auto; + white-space: pre-wrap; + word-break: break-word; + outline: none; + color: var(--neutral-foreground-rest); + font-size: 1rem; + line-height: 1.5; + padding: 4px 0; +} + +.chat-input[contenteditable]:empty::before { + content: attr(data-placeholder); + color: var(--neutral-foreground-hint); + pointer-events: none; + display: block; /* For some browsers */ +} + +.chat-input[contenteditable][disabled="true"] { + opacity: 0.5; + cursor: not-allowed; +} + +.send-button { + flex-shrink: 0; + margin-bottom: 2px; +} + .message-role-bot { align-self: flex-start; margin-bottom: 5px; @@ -57,4 +120,5 @@ .message-card p { margin: 0; -} \ No newline at end of file +} + From 7cbbc9abb5643293f7d89cc6d5f5c5d0531aa8a9 Mon Sep 17 00:00:00 2001 From: Magdalena Majta Date: Thu, 12 Feb 2026 10:25:19 +0100 Subject: [PATCH 02/22] file attachment --- .../Components/Pages/Home.razor | 234 ++++++++++++++---- src/MaIN.InferPage/Program.cs | 3 +- src/MaIN.InferPage/Utils.cs | 3 +- src/MaIN.InferPage/wwwroot/editor.js | 4 + src/MaIN.InferPage/wwwroot/home.css | 111 ++++++++- .../LLMService/Memory/DocumentProcessor.cs | 4 +- 6 files changed, 300 insertions(+), 59 deletions(-) diff --git a/src/MaIN.InferPage/Components/Pages/Home.razor b/src/MaIN.InferPage/Components/Pages/Home.razor index 78301429..dbde74b5 100644 --- a/src/MaIN.InferPage/Components/Pages/Home.razor +++ b/src/MaIN.InferPage/Components/Pages/Home.razor @@ -6,7 +6,6 @@ @using MaIN.Domain.Entities @using MaIN.Domain.Models @using Markdig -@using Microsoft.FluentUI.AspNetCore.Components.Icons.Regular @using Message = MaIN.Domain.Entities.Message @using MessageType = MaIN.Domain.Entities.MessageType @@ -40,7 +39,7 @@ style="cursor: -webkit-zoom-in; cursor: zoom-in;" target="_blank"> imageResponse + alt="imageResponse" /> @@ -48,8 +47,20 @@ } else { - + + @if (conversation.Message.Role == "User" && conversation.AttachedFiles.Any()) + { +
+ @foreach (var fileName in conversation.AttachedFiles) + { + + + @fileName + + } +
+ } @if (conversation.Message.Role == "User") {
@@ -63,11 +74,11 @@ {
- @((MarkupString)Markdown.ToHtml(string.Concat(conversation.Message.Tokens.Where(x => x.Type == TokenType.Reason).Select(x => x.Text)), _markdownPipeline)) + @((MarkupString)Markdown.ToHtml(GetReasoningContent(conversation.Message), _markdownPipeline))
-
+
} - @((MarkupString)Markdown.ToHtml(string.Concat(conversation.Message.Tokens.Where(x => x.Type == TokenType.Message).Select(x => x.Text)), _markdownPipeline)) + @((MarkupString)Markdown.ToHtml(GetMessageContent(conversation.Message), _markdownPipeline))
} @@ -111,22 +122,49 @@ }
-
-
- - - +
+ @if (_selectedFiles.Any()) + { +
+ @foreach (var file in _selectedFiles) + { +
+ @file.Name + + + +
+ } +
+ } +
+
+ + +
+ +
+
+
+ + + +
@@ -148,6 +186,8 @@ private List Messages { get; set; } = new(); private ElementReference? _bottomElement; private ElementReference _editorRef; + private InputFile? _inputFile; + private List _selectedFiles = new(); private int _inputKey = 0; private readonly MarkdownPipeline _markdownPipeline = new MarkdownPipelineBuilder() @@ -178,7 +218,14 @@ } else if (!Utils.OpenAi) { - _reasoning = !Utils.Visual && KnownModels.GetModel(Utils.Model!).HasReasoning(); + try + { + _reasoning = !Utils.Visual && KnownModels.GetModel(Utils.Model!).HasReasoning(); + } + catch + { + _reasoning = false; + } Utils.Reason = _reasoning; } @@ -198,62 +245,138 @@ if (_isLoading) return; var msg = await JS.InvokeAsync("editorManager.getInnerText", _editorRef); - if (!string.IsNullOrWhiteSpace(msg)) + if (!string.IsNullOrWhiteSpace(msg) || _selectedFiles.Any()) { await JS.InvokeVoidAsync("editorManager.clearContent", _editorRef); - await SendAsync(msg.Trim()); + await SendAsync(msg?.Trim() ?? string.Empty); } } + private void HandleFileSelected(InputFileChangeEventArgs e) + { + foreach (var file in e.GetMultipleFiles(10)) + { + _selectedFiles.Add(file); + } + StateHasChanged(); + } + + private void RemoveFile(IBrowserFile file) + { + _selectedFiles.Remove(file); + } + private async Task SendAsync(string msg) { - if (!string.IsNullOrWhiteSpace(msg)) + if (string.IsNullOrWhiteSpace(msg) && !_selectedFiles.Any()) + { + return; + } + + + + _isLoading = true; + StateHasChanged(); + + try { + var attachments = new List(); + // Buffer files to memory streams + foreach (var file in _selectedFiles) + { + try + { + var ms = new MemoryStream(); + await file.OpenReadStream(20 * 1024 * 1024).CopyToAsync(ms); // 20MB limit + ms.Position = 0; + attachments.Add(new MaIN.Domain.Entities.FileInfo + { + Name = file.Name, + Extension = System.IO.Path.GetExtension(file.Name), + StreamContent = ms + }); + } + catch (Exception ex) + { + Console.WriteLine($"Failed to read file {file.Name}: {ex.Message}"); + // Optionally notify user + } + } + + // Clear input after reading + _selectedFiles.Clear(); + _inputKey++; StateHasChanged(); + var newMsg = new Message { Role = "User", Content = msg, Type = Utils.OpenAi || Utils.Gemini ? MessageType.CloudLLM : MessageType.LocalLLM }; Chat.Messages.Add(newMsg); + + var attachedFileNames = attachments.Select(f => f.Name).ToList(); Messages.Add(new MessageExt() { - Message = newMsg + Message = newMsg, + AttachedFiles = attachedFileNames }); + Chat.Model = Utils.Model!; - _isLoading = true; Chat.Visual = Utils.Visual; - _inputKey++; _prompt = string.Empty; + StateHasChanged(); + bool wasAtBottom = await JS.InvokeAsync("scrollManager.isAtBottom", "messages-container"); - await ctx!.WithMessage(msg) - .CompleteAsync(changeOfValue: async message => + + var request = ctx!.WithMessage(msg); + if (attachments.Count != 0) + { + request.WithFiles(attachments); + } + + var response = await request.CompleteAsync(changeOfValue: async message => + { + if (message?.Type == TokenType.Reason) { - if (message?.Type == TokenType.Reason) - { - _isThinking = true; - _incomingReasoning += message.Text; - } - else if (message?.Type == TokenType.Message) - { - _isThinking = false; - _incomingMessage += message.Text; - } + _isThinking = true; + _incomingReasoning += message.Text; + } + else if (message?.Type == TokenType.Message) + { + _isThinking = false; + _incomingMessage += message.Text; + } - StateHasChanged(); - if (wasAtBottom) - { - await JS.InvokeVoidAsync("scrollManager.scrollToBottomSmooth", _bottomElement); - } - }); + StateHasChanged(); + if (wasAtBottom) + { + await JS.InvokeVoidAsync("scrollManager.scrollToBottomSmooth", _bottomElement); + } + }); - _isLoading = false; var currentChat = (await ctx.GetCurrentChat()); Chat.Messages.Add(currentChat.Messages.Last()); - Messages = Chat.Messages.Select(x => new MessageExt() + + // Preserve attached files information when rebuilding the list + var existingFilesMap = Messages + .Where(m => m.AttachedFiles.Any()) + .ToDictionary(m => m.Message, m => m.AttachedFiles); + + Messages = Chat.Messages.Select(x => new MessageExt { - Message = x + Message = x, + AttachedFiles = existingFilesMap.TryGetValue(x, out var files) ? files : new List() }).ToList(); _incomingReasoning = null; _incomingMessage = null; await JS.InvokeVoidAsync("scrollManager.scrollToBottomSmooth", _bottomElement); + } + catch (Exception ex) + { + Console.WriteLine($"Error sending message: {ex}"); + // In a production app, show a toast or error message here + } + finally + { + _isLoading = false; StateHasChanged(); } } @@ -263,4 +386,15 @@ // Handled by JS on send to ensure accuracy } + private string GetMessageContent(Message message) + { + var tokensContent = string.Concat(message.Tokens.Where(x => x.Type == TokenType.Message).Select(x => x.Text)); + return !string.IsNullOrEmpty(tokensContent) ? tokensContent : message.Content ?? string.Empty; + } + + private string GetReasoningContent(Message message) + { + return string.Concat(message.Tokens.Where(x => x.Type == TokenType.Reason).Select(x => x.Text)); + } + } \ No newline at end of file diff --git a/src/MaIN.InferPage/Program.cs b/src/MaIN.InferPage/Program.cs index cebe0267..1eb63f5d 100644 --- a/src/MaIN.InferPage/Program.cs +++ b/src/MaIN.InferPage/Program.cs @@ -181,5 +181,4 @@ app.MapRazorComponents() .AddInteractiveServerRenderMode(); - -app.Run(); +app.Run(); \ No newline at end of file diff --git a/src/MaIN.InferPage/Utils.cs b/src/MaIN.InferPage/Utils.cs index c00c6f59..905faad1 100644 --- a/src/MaIN.InferPage/Utils.cs +++ b/src/MaIN.InferPage/Utils.cs @@ -4,7 +4,7 @@ namespace MaIN.InferPage; public static class Utils { - public static string? Model = "gemma2:2b"; + public static string? Model = "gemma3:4b"; public static bool Visual => VisualModels.Contains(Model); private static readonly string[] VisualModels = ["FLUX.1_Shnell", "FLUX.1", "dall-e-3", "dall-e", "imagen", "imagen-3"]; //user might type different names public static bool OpenAi { get; set; } @@ -22,4 +22,5 @@ public class MessageExt { public required Message Message { get; set; } public bool ShowReason { get; set; } + public List AttachedFiles { get; set; } = new(); } diff --git a/src/MaIN.InferPage/wwwroot/editor.js b/src/MaIN.InferPage/wwwroot/editor.js index d98aeeaf..b9def1f1 100644 --- a/src/MaIN.InferPage/wwwroot/editor.js +++ b/src/MaIN.InferPage/wwwroot/editor.js @@ -4,5 +4,9 @@ window.editorManager = { }, clearContent: (element) => { element.innerText = ""; + }, + clickElement: (id) => { + const el = document.getElementById(id); + if (el) el.click(); } }; diff --git a/src/MaIN.InferPage/wwwroot/home.css b/src/MaIN.InferPage/wwwroot/home.css index 1f8e49e7..7a94d293 100644 --- a/src/MaIN.InferPage/wwwroot/home.css +++ b/src/MaIN.InferPage/wwwroot/home.css @@ -34,22 +34,103 @@ body { flex-direction: column; } +.chat-input-section { + display: flex; + flex-direction: column; + margin: 10px 15px 20px 15px; +} + .input-container { display: flex; - padding: 8px 12px 8px 16px; + padding: 8px 12px; align-items: flex-end; gap: 8px; background-color: var(--neutral-fill-input-rest); border-radius: 26px; border: 1px solid var(--neutral-stroke-rest); - margin: 10px 15px 20px 15px; position: relative; + z-index: 2; } -.chat-input { +.attachment-wrapper { + position: relative; + flex-shrink: 0; + cursor: pointer; +} + +.file-input-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + opacity: 0; + cursor: pointer; + z-index: 10; +} + +.attachment-button { + flex-shrink: 0; + margin-bottom: 2px; +} + +.input-content-wrapper { flex-grow: 1; + min-width: 0; + display: flex; + flex-direction: column; +} + +.selected-files-container { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 4px 12px; + margin-bottom: 2px; + width: 100%; +} + +.file-badge { + max-width: 200px; + height: 26px; + border-radius: 5px; + background-color: var(--neutral-layer-1); + border: 1px solid var(--neutral-stroke-rest); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 8px; + font-size: 12px; + color: var(--neutral-foreground-rest); + user-select: none; +} + +.file-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin-right: 4px; +} + +.dismiss-button { + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + opacity: 0.6; + flex-shrink: 0; +} + +.dismiss-button:hover { + opacity: 1; + color: var(--error-foreground-rest); /* Optional: explicit error color on hover */ +} + +.chat-input { + width: 100%; min-height: 24px; - max-height: 40vh; + max-height: 50vh; overflow-y: auto; white-space: pre-wrap; word-break: break-word; @@ -122,3 +203,25 @@ body { margin: 0; } +.attached-files-display { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 10px; + padding-bottom: 8px; + border-bottom: 1px solid var(--neutral-stroke-rest); +} + +.attached-file-tag { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + background-color: var(--neutral-fill-secondary-rest); + border: 1px solid var(--neutral-stroke-rest); + border-radius: 4px; + font-size: 11px; + color: var(--neutral-foreground-rest); +} + + diff --git a/src/MaIN.Services/Services/LLMService/Memory/DocumentProcessor.cs b/src/MaIN.Services/Services/LLMService/Memory/DocumentProcessor.cs index 9e7b0358..3a52ca21 100644 --- a/src/MaIN.Services/Services/LLMService/Memory/DocumentProcessor.cs +++ b/src/MaIN.Services/Services/LLMService/Memory/DocumentProcessor.cs @@ -41,7 +41,7 @@ public static async Task ConvertToFilesContent(ChatMemoryOptions optio foreach (var sData in options.StreamData) { - var path = Path.GetTempPath() + $".{sData.Key}"; + var path = Path.Combine(Path.GetTempPath(), sData.Key); await using var fileStream = new FileStream(path, FileMode.Create, FileAccess.Write); await sData.Value.CopyToAsync(fileStream); files.Add(path); @@ -49,7 +49,7 @@ public static async Task ConvertToFilesContent(ChatMemoryOptions optio foreach (var txt in options.TextData) { - var path = Path.GetTempPath() + $".{txt.Key}.txt"; + var path = Path.Combine(Path.GetTempPath(), $"{txt.Key}.txt"); await File.WriteAllTextAsync(path, txt.Value); files.Add(path); } From b7fad259928977dfa70a39a75b0a87f156374ef1 Mon Sep 17 00:00:00 2001 From: Magdalena Majta Date: Thu, 12 Feb 2026 11:46:20 +0100 Subject: [PATCH 03/22] stop button + some cleaning --- .../Components/Layout/MainLayout.razor | 3 +- .../Components/Layout/NavBar.razor | 1 - .../Components/Pages/Home.razor | 171 ++++++++++++++---- src/MaIN.InferPage/wwwroot/home.css | 15 +- 4 files changed, 151 insertions(+), 39 deletions(-) diff --git a/src/MaIN.InferPage/Components/Layout/MainLayout.razor b/src/MaIN.InferPage/Components/Layout/MainLayout.razor index fdf39a6d..0481fa40 100644 --- a/src/MaIN.InferPage/Components/Layout/MainLayout.razor +++ b/src/MaIN.InferPage/Components/Layout/MainLayout.razor @@ -1,5 +1,4 @@ -@using Microsoft.FluentUI.AspNetCore.Components.Icons.Regular -@inherits LayoutComponentBase +@inherits LayoutComponentBase
diff --git a/src/MaIN.InferPage/Components/Layout/NavBar.razor b/src/MaIN.InferPage/Components/Layout/NavBar.razor index 3bc5ea8e..5216c58e 100644 --- a/src/MaIN.InferPage/Components/Layout/NavBar.razor +++ b/src/MaIN.InferPage/Components/Layout/NavBar.razor @@ -1,5 +1,4 @@ @using Microsoft.FluentUI.AspNetCore.Components.Icons.Regular -@using Size32 = Microsoft.FluentUI.AspNetCore.Components.Icons.Filled.Size32 @inject NavigationManager _navigationManager @rendermode @(new InteractiveServerRenderMode(prerender: false)) diff --git a/src/MaIN.InferPage/Components/Pages/Home.razor b/src/MaIN.InferPage/Components/Pages/Home.razor index dbde74b5..0747213d 100644 --- a/src/MaIN.InferPage/Components/Pages/Home.razor +++ b/src/MaIN.InferPage/Components/Pages/Home.razor @@ -1,6 +1,7 @@ @page "/" @rendermode @(new InteractiveServerRenderMode(prerender: true)) @inject IJSRuntime JS +@implements IDisposable @using MaIN.Core.Hub @using MaIN.Core.Hub.Contexts @using MaIN.Domain.Entities @@ -12,7 +13,10 @@ MaIN Infer -
+
+ + +
@foreach (var conversation in Messages) { @@ -39,7 +43,7 @@ style="cursor: -webkit-zoom-in; cursor: zoom-in;" target="_blank"> imageResponse + alt="imageResponse"/>
@@ -55,7 +59,7 @@ @foreach (var fileName in conversation.AttachedFiles) { - + @fileName } @@ -76,7 +80,7 @@ style="border-radius: 10px; padding: 10px; border-width: 2px; background-color: var(--neutral-fill-hover)"> @((MarkupString)Markdown.ToHtml(GetReasoningContent(conversation.Message), _markdownPipeline))
-
+
} @((MarkupString)Markdown.ToHtml(GetMessageContent(conversation.Message), _markdownPipeline))
@@ -131,7 +135,7 @@
@file.Name - +
} @@ -139,12 +143,12 @@ }
- + + Appearance="Appearance.Lightweight"/>
@@ -159,36 +163,36 @@ + + +
- - -@* ReSharper disable once UnassignedField.Compiler *@ - @code { - private string _prompt = string.Empty; private bool _isLoading; private bool _isThinking; private bool _reasoning; - private string? _incomingMessage = null; - private string? _incomingReasoning = null; - private readonly string? _displayName = Utils.Model; + private string? _incomingMessage; + private string? _incomingReasoning; private ChatContext? ctx; + private CancellationTokenSource? _cancellationTokenSource; private Chat Chat { get; } = new() { Name = "MaIN Infer", Model = Utils.Model! }; private List Messages { get; set; } = new(); private ElementReference? _bottomElement; private ElementReference _editorRef; - private InputFile? _inputFile; private List _selectedFiles = new(); - private int _inputKey = 0; + private int _inputKey; private readonly MarkdownPipeline _markdownPipeline = new MarkdownPipelineBuilder() .UseAdvancedExtensions() @@ -266,6 +270,11 @@ _selectedFiles.Remove(file); } + private void HandleStop() + { + _cancellationTokenSource?.Cancel(); + } + private async Task SendAsync(string msg) { if (string.IsNullOrWhiteSpace(msg) && !_selectedFiles.Any()) @@ -273,7 +282,10 @@ return; } - + // Create new cancellation token source for this request + _cancellationTokenSource?.Dispose(); + _cancellationTokenSource = new CancellationTokenSource(); + var cancellationToken = _cancellationTokenSource.Token; _isLoading = true; StateHasChanged(); @@ -287,7 +299,7 @@ try { var ms = new MemoryStream(); - await file.OpenReadStream(20 * 1024 * 1024).CopyToAsync(ms); // 20MB limit + await file.OpenReadStream(20 * 1024 * 1024).CopyToAsync(ms, cancellationToken); // 20MB limit ms.Position = 0; attachments.Add(new MaIN.Domain.Entities.FileInfo { @@ -308,11 +320,16 @@ _inputKey++; StateHasChanged(); - var newMsg = new Message { Role = "User", Content = msg, Type = Utils.OpenAi || Utils.Gemini ? MessageType.CloudLLM : MessageType.LocalLLM }; + var newMsg = new Message + { + Role = "User", + Content = msg, + Type = GetMessageType() + }; Chat.Messages.Add(newMsg); var attachedFileNames = attachments.Select(f => f.Name).ToList(); - Messages.Add(new MessageExt() + Messages.Add(new MessageExt { Message = newMsg, AttachedFiles = attachedFileNames @@ -320,7 +337,6 @@ Chat.Model = Utils.Model!; Chat.Visual = Utils.Visual; - _prompt = string.Empty; StateHasChanged(); @@ -332,8 +348,16 @@ request.WithFiles(attachments); } - var response = await request.CompleteAsync(changeOfValue: async message => + // Check for cancellation before starting the request + cancellationToken.ThrowIfCancellationRequested(); + + var completionTask = request.CompleteAsync(changeOfValue: async message => { + if (cancellationToken.IsCancellationRequested) + { + return; + } + if (message?.Type == TokenType.Reason) { _isThinking = true; @@ -352,22 +376,76 @@ } }); + // Wait for completion or cancellation + var response = await completionTask.WaitAsync(cancellationToken); + var currentChat = (await ctx.GetCurrentChat()); Chat.Messages.Add(currentChat.Messages.Last()); - // Preserve attached files information when rebuilding the list - var existingFilesMap = Messages - .Where(m => m.AttachedFiles.Any()) - .ToDictionary(m => m.Message, m => m.AttachedFiles); + RebuildMessagesWithFiles(); + _incomingReasoning = null; + _incomingMessage = null; + await JS.InvokeVoidAsync("scrollManager.scrollToBottomSmooth", _bottomElement); + } + catch (OperationCanceledException) + { + Console.WriteLine("Request was cancelled by user"); + + // Check if any partial response was generated + bool hasPartialResponse = !string.IsNullOrWhiteSpace(_incomingMessage) || !string.IsNullOrWhiteSpace(_incomingReasoning); - Messages = Chat.Messages.Select(x => new MessageExt + if (hasPartialResponse) + { + // Create a message with the partial response + var partialMsg = new Message + { + Role = "Assistant", + Content = _incomingMessage ?? string.Empty, + Type = GetMessageType(), + Time = DateTime.Now + }; + + // Add reasoning tokens if they exist + if (!string.IsNullOrWhiteSpace(_incomingReasoning)) + { + partialMsg.Tokens.Add(new LLMTokenValue + { + Text = _incomingReasoning, + Type = TokenType.Reason + }); + } + + // Add message tokens + if (!string.IsNullOrWhiteSpace(_incomingMessage)) + { + partialMsg.Tokens.Add(new LLMTokenValue + { + Text = _incomingMessage, + Type = TokenType.Message + }); + } + + Chat.Messages.Add(partialMsg); + RebuildMessagesWithFiles(); + } + else { - Message = x, - AttachedFiles = existingFilesMap.TryGetValue(x, out var files) ? files : new List() - }).ToList(); + // No response generated, remove the user message and file info + if (Chat.Messages.Count > 0 && Chat.Messages.Last().Role == "User") + { + Chat.Messages.RemoveAt(Chat.Messages.Count - 1); + } + + if (Messages.Count > 0 && Messages.Last().Message.Role == "User") + { + Messages.RemoveAt(Messages.Count - 1); + } + } + + // Clean up incoming message buffers _incomingReasoning = null; _incomingMessage = null; - await JS.InvokeVoidAsync("scrollManager.scrollToBottomSmooth", _bottomElement); + StateHasChanged(); } catch (Exception ex) { @@ -397,4 +475,27 @@ return string.Concat(message.Tokens.Where(x => x.Type == TokenType.Reason).Select(x => x.Text)); } + private MessageType GetMessageType() + { + return Utils.OpenAi || Utils.Gemini ? MessageType.CloudLLM : MessageType.LocalLLM; + } + + private void RebuildMessagesWithFiles() + { + var existingFilesMap = Messages + .Where(m => m.AttachedFiles.Any()) + .ToDictionary(m => m.Message, m => m.AttachedFiles); + + Messages = Chat.Messages.Select(x => new MessageExt + { + Message = x, + AttachedFiles = existingFilesMap.TryGetValue(x, out var files) ? files : new List() + }).ToList(); + } + + public void Dispose() + { + _cancellationTokenSource?.Dispose(); + } + } \ No newline at end of file diff --git a/src/MaIN.InferPage/wwwroot/home.css b/src/MaIN.InferPage/wwwroot/home.css index 7a94d293..7ee3279a 100644 --- a/src/MaIN.InferPage/wwwroot/home.css +++ b/src/MaIN.InferPage/wwwroot/home.css @@ -5,11 +5,14 @@ body { overflow: hidden; } -.xd { +/* Main chat container */ +.chat-container { height: 100%; display: flex; flex-direction: column; flex-grow: 1; + gap: 4px; + padding-top: 4px; } .messages-container>div { @@ -158,6 +161,16 @@ body { margin-bottom: 2px; } +.stop-button { + flex-shrink: 0; + margin-bottom: 2px; + color: var(--error-foreground-rest); +} + +.stop-button:hover { + color: var(--error-fill-hover); +} + .message-role-bot { align-self: flex-start; margin-bottom: 5px; From 1a5589384f5bcb216b4f85ace7bd10063e18d865 Mon Sep 17 00:00:00 2001 From: Magdalena Majta Date: Thu, 12 Feb 2026 13:16:45 +0100 Subject: [PATCH 04/22] Use BackendType for backend config/UI Refactor backend selection to use BackendType everywhere and simplify API key handling. Added Extensions.GetApiKeyVariable to map backends to env vars; Program now sets Utils.BackendType from the CLI arg, prompts for missing API keys (and marks Utils.HasApiKey), and only registers MaIN services when a non-self backend is selected. Utils was simplified: removed per-backend booleans, added BackendType, HasApiKey, IsLocal helper and moved Reason flag. UI updates: NavBar shows backend and model badges (with color/display name logic including "Local Ollama"), and Home.razor now branches on BackendType and uses Utils.IsLocal for MessageType. Also trimmed launchSettings applicationUrl. --- src/MaIN.Domain/Extensions.cs | 22 +++ .../Components/Layout/NavBar.razor | 47 +++++-- .../Components/Pages/Home.razor | 9 +- src/MaIN.InferPage/Program.cs | 125 ++++-------------- .../Properties/launchSettings.json | 2 +- src/MaIN.InferPage/Utils.cs | 13 +- 6 files changed, 97 insertions(+), 121 deletions(-) create mode 100644 src/MaIN.Domain/Extensions.cs diff --git a/src/MaIN.Domain/Extensions.cs b/src/MaIN.Domain/Extensions.cs new file mode 100644 index 00000000..d95aba22 --- /dev/null +++ b/src/MaIN.Domain/Extensions.cs @@ -0,0 +1,22 @@ +using MaIN.Domain.Configuration; + +namespace MaIN.Domain; + +public static class Extensions +{ + public static string GetApiKeyVariable(this BackendType backendType) + { + return backendType switch + { + BackendType.Self => "", + BackendType.Anthropic => "ANTHROPIC_API_KEY", + BackendType.DeepSeek => "DEEPSEEK_API_KEY", + BackendType.Gemini => "GEMINI_API_KEY", + BackendType.GroqCloud => "GROQ_API_KEY", + BackendType.Ollama => "OLLAMA_API_KEY", + BackendType.OpenAi => "OPENAI_API_KEY", + BackendType.Xai => "XAI_API_KEY", + _ => throw new ArgumentOutOfRangeException(nameof(BackendType)) + }; + } +} \ No newline at end of file diff --git a/src/MaIN.InferPage/Components/Layout/NavBar.razor b/src/MaIN.InferPage/Components/Layout/NavBar.razor index 5216c58e..0a345526 100644 --- a/src/MaIN.InferPage/Components/Layout/NavBar.razor +++ b/src/MaIN.InferPage/Components/Layout/NavBar.razor @@ -1,4 +1,5 @@ @using Microsoft.FluentUI.AspNetCore.Components.Icons.Regular +@using MaIN.Domain.Configuration @inject NavigationManager _navigationManager @rendermode @(new InteractiveServerRenderMode(prerender: false)) @@ -6,22 +7,28 @@ StorageName="theme" /> @code { private DesignThemeModes Mode { get; set; } + private string AccentColor => Mode == DesignThemeModes.Dark ? "#00ffcc" : "#00cca3"; private void SetTheme() { @@ -67,4 +70,4 @@ _ => Utils.BackendType.ToString() }; } -} \ No newline at end of file +} diff --git a/src/MaIN.InferPage/Components/Pages/Home.razor b/src/MaIN.InferPage/Components/Pages/Home.razor index 9fac5c18..f955f5bc 100644 --- a/src/MaIN.InferPage/Components/Pages/Home.razor +++ b/src/MaIN.InferPage/Components/Pages/Home.razor @@ -16,7 +16,7 @@
- +
@foreach (var conversation in Messages) @@ -136,7 +136,7 @@
@file.Name - +
} @@ -146,8 +146,9 @@
@@ -162,7 +163,7 @@
("eval", "document.body.dataset.theme ?? ''"); + _accentColor = (theme == "dark" || theme == "system-dark") ? "#00ffcc" : "#00cca3"; + StateHasChanged(); + } + else { await JS.InvokeVoidAsync("scrollManager.restoreScrollPosition", "messages-container"); } @@ -492,7 +500,8 @@ Messages = Chat.Messages.Select(x => new MessageExt { Message = x, - AttachedFiles = existingFilesMap.TryGetValue(x, out var files) ? files : new List() + AttachedFiles = existingFilesMap.TryGetValue(x, out var files) ? files : new List(), + ShowReason = x.Tokens.Any(t => t.Type == TokenType.Reason) }).ToList(); } diff --git a/src/MaIN.InferPage/wwwroot/app.css b/src/MaIN.InferPage/wwwroot/app.css index f2f5dd6a..eb29837b 100644 --- a/src/MaIN.InferPage/wwwroot/app.css +++ b/src/MaIN.InferPage/wwwroot/app.css @@ -1,5 +1,28 @@ @import url('https://fonts.googleapis.com/css2?family=Tiny5&display=swap'); /* Importing a coding font */ +/* Kolor akcentu ciemny motyw */ +body[data-theme="dark"], +body[data-theme="system-dark"] { + --accent-base-color: #00ffcc !important; + --accent-fill-rest: #00ffcc !important; +} + +/* Lekko szare tło + kolor akcentu jasny motyw */ +body[data-theme="light"], +body[data-theme="system-light"], +body:not([data-theme="dark"]):not([data-theme="system-dark"]) { + --accent-base-color: #00cca3 !important; + --accent-fill-rest: #00cca3 !important; + --light-bg: #f0f0f0; + --neutral-layer-1: var(--light-bg) !important; + --neutral-fill-layer-rest: var(--light-bg) !important; + --neutral-fill-input-rest: var(--light-bg) !important; + --neutral-layer-2: #e8e8e8 !important; + --neutral-layer-3: #e0e0e0 !important; + --neutral-layer-4: #d8d8d8 !important; + --neutral-fill-input-hover: #ebebeb !important; + --neutral-fill-input-active: #e8e8e8 !important; +} body { --body-font: "Segoe UI Variable", "Segoe UI", sans-serif; @@ -210,8 +233,8 @@ code { font-weight: 400; font-size: 45px; font-style: normal; - color: var(--accent-fill-rest); - text-shadow: 0 0 5px #00ffcc; + color: var(--accent-base-color); + text-shadow: 0 0 5px var(--accent-base-color); } .navbar { From 828b724afb037c60ed07c9842aaf93b6493cd71b Mon Sep 17 00:00:00 2001 From: Magdalena Majta Date: Thu, 19 Feb 2026 12:26:25 +0100 Subject: [PATCH 06/22] fix: stream tokens progressively for file-based chat --- .../Components/Pages/Home.razor | 4 +-- .../Services/LLMService/DeepSeekService.cs | 2 +- .../Services/LLMService/GroqCloudService.cs | 2 +- .../Services/LLMService/LLMService.cs | 22 ++++++++++------ .../Services/LLMService/OllamaService.cs | 2 +- .../LLMService/OpenAiCompatibleService.cs | 25 ++++++++----------- .../Services/LLMService/XaiService.cs | 2 +- 7 files changed, 30 insertions(+), 29 deletions(-) diff --git a/src/MaIN.InferPage/Components/Pages/Home.razor b/src/MaIN.InferPage/Components/Pages/Home.razor index f955f5bc..123c4e97 100644 --- a/src/MaIN.InferPage/Components/Pages/Home.razor +++ b/src/MaIN.InferPage/Components/Pages/Home.razor @@ -378,10 +378,10 @@ _incomingMessage += message.Text; } - StateHasChanged(); + await InvokeAsync(StateHasChanged); if (wasAtBottom) { - await JS.InvokeVoidAsync("scrollManager.scrollToBottomSmooth", _bottomElement); + await InvokeAsync(async () => await JS.InvokeVoidAsync("scrollManager.scrollToBottomSmooth", _bottomElement)); } }); diff --git a/src/MaIN.Services/Services/LLMService/DeepSeekService.cs b/src/MaIN.Services/Services/LLMService/DeepSeekService.cs index ec617d5d..a3cd8aaa 100644 --- a/src/MaIN.Services/Services/LLMService/DeepSeekService.cs +++ b/src/MaIN.Services/Services/LLMService/DeepSeekService.cs @@ -62,7 +62,7 @@ protected override void ValidateApiKey() chat.Messages.Last().Content = message.Content; chat.Messages.Last().Files = []; - var result = await Send(chat, new ChatRequestOptions(), cancellationToken); + var result = await Send(chat, requestOptions, cancellationToken); chat.Messages.Last().Content = lastMsg.Content; return result; } diff --git a/src/MaIN.Services/Services/LLMService/GroqCloudService.cs b/src/MaIN.Services/Services/LLMService/GroqCloudService.cs index 79dbf304..3ee5cd78 100644 --- a/src/MaIN.Services/Services/LLMService/GroqCloudService.cs +++ b/src/MaIN.Services/Services/LLMService/GroqCloudService.cs @@ -55,7 +55,7 @@ protected override void ValidateApiKey() chat.Messages.Last().Content = message.Content; chat.Messages.Last().Files = []; - var result = await Send(chat, new ChatRequestOptions(), cancellationToken); + var result = await Send(chat, requestOptions, cancellationToken); chat.Messages.Last().Content = lastMsg.Content; return result; } diff --git a/src/MaIN.Services/Services/LLMService/LLMService.cs b/src/MaIN.Services/Services/LLMService/LLMService.cs index f45bcea7..c9c5462c 100644 --- a/src/MaIN.Services/Services/LLMService/LLMService.cs +++ b/src/MaIN.Services/Services/LLMService/LLMService.cs @@ -116,10 +116,12 @@ public Task CleanSessionCache(string? id) MemoryAnswer result; + var tokens = new List(); + if (requestOptions.InteractiveUpdates || requestOptions.TokenCallback != null) { var responseBuilder = new StringBuilder(); - + var searchOptions = new SearchOptions { Stream = true @@ -133,24 +135,27 @@ public Task CleanSessionCache(string? id) if (!string.IsNullOrEmpty(chunk.Result)) { responseBuilder.Append(chunk.Result); - + var tokenValue = new LLMTokenValue { Text = chunk.Result, Type = TokenType.Message }; - + + tokens.Add(tokenValue); + if (requestOptions.InteractiveUpdates) { await notificationService.DispatchNotification( NotificationMessageBuilder.CreateChatCompletion(chat.Id, tokenValue, false), ServiceConstants.Notifications.ReceiveMessageUpdate); } - - requestOptions.TokenCallback?.Invoke(tokenValue); + + if (requestOptions.TokenCallback != null) + await requestOptions.TokenCallback(tokenValue); } } - + result = new MemoryAnswer { Question = userMessage.Content, @@ -170,9 +175,9 @@ await notificationService.DispatchNotification( options: searchOptions, cancellationToken: cancellationToken); } - + await memory.km.DeleteIndexAsync(cancellationToken: cancellationToken); - + if (disableCache) { llmModel.Dispose(); @@ -191,6 +196,7 @@ await notificationService.DispatchNotification( Message = new Message { Content = memoryService.CleanResponseText(result.Result), + Tokens = tokens, Role = nameof(AuthorRole.Assistant), Type = MessageType.LocalLLM, } diff --git a/src/MaIN.Services/Services/LLMService/OllamaService.cs b/src/MaIN.Services/Services/LLMService/OllamaService.cs index f7229c21..7a96733e 100644 --- a/src/MaIN.Services/Services/LLMService/OllamaService.cs +++ b/src/MaIN.Services/Services/LLMService/OllamaService.cs @@ -54,7 +54,7 @@ protected override void ValidateApiKey() chat.Messages.Last().Content = message.Content; chat.Messages.Last().Files = []; - var result = await Send(chat, new ChatRequestOptions(), cancellationToken); + var result = await Send(chat, requestOptions, cancellationToken); chat.Messages.Last().Content = lastMsg.Content; return result; } diff --git a/src/MaIN.Services/Services/LLMService/OpenAiCompatibleService.cs b/src/MaIN.Services/Services/LLMService/OpenAiCompatibleService.cs index f3494b7f..c2a9af56 100644 --- a/src/MaIN.Services/Services/LLMService/OpenAiCompatibleService.cs +++ b/src/MaIN.Services/Services/LLMService/OpenAiCompatibleService.cs @@ -72,15 +72,7 @@ public abstract class OpenAiCompatibleService( resultBuilder.Append(memoryResult!.Message.Content); lastMessage.MarkProcessed(); UpdateSessionCache(chat.Id, resultBuilder.ToString(), options.CreateSession); - if (options.TokenCallback != null) - { - await InvokeTokenCallbackAsync(options.TokenCallback, new LLMTokenValue() - { - Text = resultBuilder.ToString(), - Type = TokenType.FullAnswer - }); - } - return CreateChatResult(chat, resultBuilder.ToString(), tokens); + return CreateChatResult(chat, resultBuilder.ToString(), memoryResult.Message.Tokens); } if (chat.ToolsConfiguration?.Tools != null && chat.ToolsConfiguration.Tools.Any()) @@ -457,11 +449,12 @@ await _notificationService.DispatchNotification( } MemoryAnswer retrievedContext; + var tokens = new List(); if (requestOptions.InteractiveUpdates || requestOptions.TokenCallback != null) { var responseBuilder = new StringBuilder(); - + var searchOptions = new SearchOptions { Stream = true @@ -475,13 +468,15 @@ await _notificationService.DispatchNotification( if (!string.IsNullOrEmpty(chunk.Result)) { responseBuilder.Append(chunk.Result); - + var tokenValue = new LLMTokenValue { Text = chunk.Result, Type = TokenType.Message }; + tokens.Add(tokenValue); + if (requestOptions.InteractiveUpdates) { await notificationService.DispatchNotification( @@ -489,10 +484,10 @@ await notificationService.DispatchNotification( ServiceConstants.Notifications.ReceiveMessageUpdate); } - requestOptions.TokenCallback?.Invoke(tokenValue); + await InvokeTokenCallbackAsync(requestOptions.TokenCallback, tokenValue); } } - + retrievedContext = new MemoryAnswer { Question = userQuery, @@ -512,9 +507,9 @@ await notificationService.DispatchNotification( options: searchOptions, cancellationToken: cancellationToken); } - + await kernel.DeleteIndexAsync(cancellationToken: cancellationToken); - return CreateChatResult(chat, retrievedContext.Result, []); + return CreateChatResult(chat, retrievedContext.Result, tokens); } public virtual async Task GetCurrentModels() diff --git a/src/MaIN.Services/Services/LLMService/XaiService.cs b/src/MaIN.Services/Services/LLMService/XaiService.cs index 9d7095f7..cd5f8af7 100644 --- a/src/MaIN.Services/Services/LLMService/XaiService.cs +++ b/src/MaIN.Services/Services/LLMService/XaiService.cs @@ -55,7 +55,7 @@ protected override void ValidateApiKey() chat.Messages.Last().Content = message.Content; chat.Messages.Last().Files = []; - var result = await Send(chat, new ChatRequestOptions(), cancellationToken); + var result = await Send(chat, requestOptions, cancellationToken); chat.Messages.Last().Content = lastMsg.Content; return result; } From 14d3a20eadb87306c86cdafa50e28eaeb8b0618e Mon Sep 17 00:00:00 2001 From: Magdalena Majta Date: Thu, 19 Feb 2026 12:40:41 +0100 Subject: [PATCH 07/22] fix theme color change (disco problem) --- .../Components/Layout/MainLayout.razor | 1 - src/MaIN.InferPage/Components/Layout/NavBar.razor | 15 +++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/MaIN.InferPage/Components/Layout/MainLayout.razor b/src/MaIN.InferPage/Components/Layout/MainLayout.razor index 0481fa40..b0f6917a 100644 --- a/src/MaIN.InferPage/Components/Layout/MainLayout.razor +++ b/src/MaIN.InferPage/Components/Layout/MainLayout.razor @@ -1,5 +1,4 @@ @inherits LayoutComponentBase -
diff --git a/src/MaIN.InferPage/Components/Layout/NavBar.razor b/src/MaIN.InferPage/Components/Layout/NavBar.razor index 4fcb73fd..bb6fbd9d 100644 --- a/src/MaIN.InferPage/Components/Layout/NavBar.razor +++ b/src/MaIN.InferPage/Components/Layout/NavBar.razor @@ -1,6 +1,7 @@ @using Microsoft.FluentUI.AspNetCore.Components.Icons.Regular @using MaIN.Domain.Configuration @inject NavigationManager _navigationManager +@inject IJSRuntime JS @rendermode @(new InteractiveServerRenderMode(prerender: false)) Mode == DesignThemeModes.Dark ? "#00ffcc" : "#00cca3"; + private bool _isChangingTheme = false; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + var stored = await JS.InvokeAsync("eval", "localStorage.getItem('theme') ?? ''"); + Mode = stored == "dark" ? DesignThemeModes.Dark : DesignThemeModes.Light; + StateHasChanged(); + } + } private void SetTheme() { + if (_isChangingTheme) return; + _isChangingTheme = true; Mode = Mode == DesignThemeModes.Dark ? DesignThemeModes.Light : DesignThemeModes.Dark; + _isChangingTheme = false; } private void Reload(MouseEventArgs obj) From 0f50ad949c4709a5c4a3342926c161e254eba40d Mon Sep 17 00:00:00 2001 From: Magdalena Majta Date: Thu, 19 Feb 2026 17:58:07 +0100 Subject: [PATCH 08/22] post merge fixes --- .../Components/Pages/Home.razor | 55 +++++++++---------- src/MaIN.InferPage/Program.cs | 54 +++++++++--------- src/MaIN.Services/Services/ChatService.cs | 2 +- .../Services/LLMService/LLMService.cs | 2 +- 4 files changed, 57 insertions(+), 56 deletions(-) diff --git a/src/MaIN.InferPage/Components/Pages/Home.razor b/src/MaIN.InferPage/Components/Pages/Home.razor index 3ea8200b..5d8fcddb 100644 --- a/src/MaIN.InferPage/Components/Pages/Home.razor +++ b/src/MaIN.InferPage/Components/Pages/Home.razor @@ -3,11 +3,12 @@ @inject IJSRuntime JS @implements IDisposable @using MaIN.Core.Hub -@using MaIN.Core.Hub.Contexts +@using MaIN.Core.Hub.Contexts.Interfaces.ChatContext @using MaIN.Domain.Configuration @using MaIN.Domain.Entities @using MaIN.Domain.Exceptions @using MaIN.Domain.Models +@using MaIN.Domain.Models.Abstract @using Markdig @using Message = MaIN.Domain.Entities.Message @using MessageType = MaIN.Domain.Entities.MessageType @@ -163,7 +164,7 @@
@@ -197,9 +198,9 @@ private string? _incomingMessage; private string? _incomingReasoning; private readonly string? _displayName = Utils.Model; - private ChatContext? ctx; + private IChatMessageBuilder? ctx; private CancellationTokenSource? _cancellationTokenSource; - private Chat Chat { get; } = new() { Name = "MaIN Infer", Model = Utils.Model! }; + private Chat Chat { get; } = new() { Name = "MaIN Infer", ModelId = Utils.Model! }; private List Messages { get; set; } = new(); private ElementReference? _bottomElement; private ElementReference _editorRef; @@ -227,29 +228,26 @@ protected override Task OnInitializedAsync() { - ctx = Utils.Visual - ? AIHub.Chat().EnableVisual() - : Utils.Path != null - ? AIHub.Chat().WithCustomModel(model: Utils.Model!, path: Utils.Path) - : AIHub.Chat().WithModel(Utils.Model!); - - if (Utils.BackendType == BackendType.DeepSeek) + try { - _reasoning = Utils.Model!.ToLower().Contains("reasoner"); - Utils.Reason = _reasoning; + ctx = Utils.Visual + ? AIHub.Chat().EnableVisual() + : Utils.BackendType == BackendType.Self && Utils.Path != null + ? AIHub.Chat().WithModel(new GenericLocalModel(FileName: Utils.Model!, CustomPath: Utils.Path)) + : AIHub.Chat().WithModel(ModelRegistry.GetById(Utils.Model!)); } - else if (Utils.BackendType == BackendType.OpenAi) + catch (MaINCustomException ex) { - try - { - _reasoning = !Utils.Visual && KnownModels.GetModel(Utils.Model!).HasReasoning(); - } - catch - { - _reasoning = false; - } - Utils.Reason = _reasoning; + _errorMessage = ex.PublicErrorMessage; } + catch (Exception ex) + { + _errorMessage = ex.Message; + } + + var model = ModelRegistry.TryGetById(Utils.Model!, out var foundModel) ? foundModel : null; + _reasoning = !Utils.Visual && model?.HasReasoning == true; + Utils.Reason = _reasoning; return base.OnInitializedAsync(); } @@ -309,7 +307,7 @@ try { - var attachments = new List(); + var attachments = new List(); foreach (var file in _selectedFiles) { try @@ -317,10 +315,10 @@ var ms = new MemoryStream(); await file.OpenReadStream(20 * 1024 * 1024).CopyToAsync(ms, cancellationToken); ms.Position = 0; - attachments.Add(new MaIN.Domain.Entities.FileInfo + attachments.Add(new FileInfo { Name = file.Name, - Extension = System.IO.Path.GetExtension(file.Name), + Extension = Path.GetExtension(file.Name), StreamContent = ms }); } @@ -349,7 +347,7 @@ AttachedFiles = attachedFileNames }); - Chat.Model = Utils.Model!; + Chat.ModelId = Utils.Model!; Chat.Visual = Utils.Visual; StateHasChanged(); @@ -385,8 +383,7 @@ await InvokeAsync(StateHasChanged); if (wasAtBottom) { - await InvokeAsync(async () => - await JS.InvokeVoidAsync("scrollManager.scrollToBottomSmooth", _bottomElement)); + await InvokeAsync(async () => await JS.InvokeVoidAsync("scrollManager.scrollToBottomSmooth", cancellationToken, _bottomElement)); } }); diff --git a/src/MaIN.InferPage/Program.cs b/src/MaIN.InferPage/Program.cs index 56f8ede0..09b2be78 100644 --- a/src/MaIN.InferPage/Program.cs +++ b/src/MaIN.InferPage/Program.cs @@ -1,7 +1,7 @@ using MaIN.Core; using MaIN.Domain; using MaIN.Domain.Configuration; -using MaIN.Domain.Models; +using MaIN.Domain.Models.Abstract; using Microsoft.FluentUI.AspNetCore.Components; using MaIN.InferPage.Components; using Utils = MaIN.InferPage.Utils; @@ -17,29 +17,6 @@ var modelPathArg = builder.Configuration["path"]; var backendArg = builder.Configuration["backend"]; - if (!string.IsNullOrEmpty(modelArg)) - { - Utils.Model = modelArg; - - if (string.IsNullOrEmpty(modelPathArg)) - { - Console.WriteLine("Error: A model path must be provided using --path when a model is specified."); - return; - } - Utils.Path = modelPathArg; - - var envModelsPath = Environment.GetEnvironmentVariable("MaIN_ModelsPath"); - if (string.IsNullOrEmpty(envModelsPath)) - { - Console.Write("Please enter the MaIN_ModelsPath: "); - envModelsPath = Console.ReadLine(); - Environment.SetEnvironmentVariable("MaIN_ModelsPath", envModelsPath); - } - } - else - { - Console.WriteLine("No model argument provided. Continuing without model configuration."); - } if (backendArg != null) { @@ -73,6 +50,33 @@ } } } + + if (!string.IsNullOrEmpty(modelArg)) + { + Utils.Model = modelArg; + Utils.Path = modelPathArg; + + if (Utils.BackendType == BackendType.Self) + { + if (string.IsNullOrEmpty(modelPathArg)) + { + Console.WriteLine("Error: A model path must be provided using --path when a local model is specified."); + return; + } + + var envModelsPath = Environment.GetEnvironmentVariable("MaIN_ModelsPath"); + if (string.IsNullOrEmpty(envModelsPath)) + { + Console.Write("Please enter the MaIN_ModelsPath: "); + envModelsPath = Console.ReadLine(); + Environment.SetEnvironmentVariable("MaIN_ModelsPath", envModelsPath); + } + } + } + else + { + Console.WriteLine("No model argument provided. Continuing without model configuration."); + } } catch (Exception ex) { @@ -89,7 +93,7 @@ } else { - if (Utils.Path == null && !KnownModels.IsModelSupported(Utils.Model!)) + if (Utils.Path == null && !ModelRegistry.Exists(Utils.Model!)) { Console.WriteLine($"Model: {Utils.Model} is not supported"); Environment.Exit(0); diff --git a/src/MaIN.Services/Services/ChatService.cs b/src/MaIN.Services/Services/ChatService.cs index 2003b291..2ecd32e2 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; + chat.Backend ??= chat.ModelInstance?.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 63fe96e3..2591e8a3 100644 --- a/src/MaIN.Services/Services/LLMService/LLMService.cs +++ b/src/MaIN.Services/Services/LLMService/LLMService.cs @@ -190,7 +190,7 @@ await notificationService.DispatchNotification( cancellationToken: cancellationToken); } - await memory.km.DeleteIndexAsync(cancellationToken: cancellationToken); + await km.DeleteIndexAsync(cancellationToken: cancellationToken); if (disableCache) { From 0542c125223289190b75473bcaaf37a8c1b4e2b0 Mon Sep 17 00:00:00 2001 From: Magdalena Majta Date: Thu, 19 Feb 2026 21:19:01 +0100 Subject: [PATCH 09/22] update show-reasoning button --- .../Components/Pages/Home.razor | 43 +++++++++---------- src/MaIN.InferPage/wwwroot/home.css | 31 +++++++++++++ 2 files changed, 52 insertions(+), 22 deletions(-) diff --git a/src/MaIN.InferPage/Components/Pages/Home.razor b/src/MaIN.InferPage/Components/Pages/Home.razor index 5d8fcddb..c5956f43 100644 --- a/src/MaIN.InferPage/Components/Pages/Home.razor +++ b/src/MaIN.InferPage/Components/Pages/Home.razor @@ -78,18 +78,23 @@ else {
- @if (conversation.ShowReason) - { -
- @((MarkupString)Markdown.ToHtml(GetReasoningContent(conversation.Message), _markdownPipeline)) -
-
- } @if (_reasoning && conversation.Message.Role == "Assistant") { - +
+ + + + @if (conversation.ShowReason) + { +
+ @((MarkupString)Markdown.ToHtml(GetReasoningContent(conversation.Message), _markdownPipeline)) +
+ } +
+
} @((MarkupString)Markdown.ToHtml(GetMessageContent(conversation.Message), _markdownPipeline))
@@ -102,21 +107,10 @@ { @if (Chat.Visual) { - @_displayName This might take a while... - } else { - - @_displayName - - @if (_isThinking) - { - Thinking... - } - @if (_incomingMessage != null || _incomingReasoning != null) { @@ -193,6 +187,7 @@ private bool _isLoading; private bool _isThinking; private bool _reasoning; + private bool _preserveScroll; private string _accentColor = "#00cca3"; private string? _errorMessage; private string? _incomingMessage; @@ -220,10 +215,14 @@ _accentColor = (theme == "dark" || theme == "system-dark") ? "#00ffcc" : "#00cca3"; StateHasChanged(); } - else + else if (!_preserveScroll) { await JS.InvokeVoidAsync("scrollManager.restoreScrollPosition", "messages-container"); } + else + { + _preserveScroll = false; + } } protected override Task OnInitializedAsync() diff --git a/src/MaIN.InferPage/wwwroot/home.css b/src/MaIN.InferPage/wwwroot/home.css index bda0e93a..5892b0c9 100644 --- a/src/MaIN.InferPage/wwwroot/home.css +++ b/src/MaIN.InferPage/wwwroot/home.css @@ -205,6 +205,37 @@ body { font-size: smaller !important; } +.reasoning-row { + display: flex; + flex-direction: row; + align-items: flex-start; + gap: 8px; + margin-bottom: 6px; +} + +.brain-toggle { + display: inline-flex; + align-items: center; + flex-shrink: 0; + margin-top: 10px; + cursor: pointer; + opacity: 0.85; + transition: opacity 0.15s; +} + +.brain-toggle:hover { + opacity: 1; +} + +.reasoning-text { + flex: 1; + min-width: 0; +} + +.reasoning-hr { + margin: 6px 0; +} + .message-card-img { margin-bottom: 15px; width: 80%; From 658020fb6e35c2cd90aa389fcba28186d4caaa55 Mon Sep 17 00:00:00 2001 From: Magdalena Majta Date: Fri, 20 Feb 2026 00:16:22 +0100 Subject: [PATCH 10/22] fix stop button --- src/MaIN.Core/Hub/Contexts/ChatContext.cs | 5 +++-- .../ChatContext/IChatConfigurationBuilder.cs | 2 +- src/MaIN.InferPage/Components/Pages/Home.razor | 2 +- src/MaIN.InferPage/Utils.cs | 2 +- src/MaIN.Services/Services/Abstract/IChatService.cs | 3 ++- src/MaIN.Services/Services/ChatService.cs | 11 ++++++----- .../Services/LLMService/OpenAiCompatibleService.cs | 2 ++ 7 files changed, 16 insertions(+), 11 deletions(-) diff --git a/src/MaIN.Core/Hub/Contexts/ChatContext.cs b/src/MaIN.Core/Hub/Contexts/ChatContext.cs index 01bc9477..e62e2e35 100644 --- a/src/MaIN.Core/Hub/Contexts/ChatContext.cs +++ b/src/MaIN.Core/Hub/Contexts/ChatContext.cs @@ -198,7 +198,8 @@ public IChatConfigurationBuilder DisableCache() public async Task CompleteAsync( bool translate = false, // Move to WithTranslate bool interactive = false, // Move to WithInteractive - Func? changeOfValue = null) + Func? changeOfValue = null, + CancellationToken cancellationToken = default) { if (_chat.ModelInstance is null) { @@ -219,7 +220,7 @@ public async Task CompleteAsync( { await _chatService.Create(_chat); } - var result = await _chatService.Completions(_chat, translate, interactive, changeOfValue); + var result = await _chatService.Completions(_chat, translate, interactive, changeOfValue, cancellationToken); _files = []; return result; } diff --git a/src/MaIN.Core/Hub/Contexts/Interfaces/ChatContext/IChatConfigurationBuilder.cs b/src/MaIN.Core/Hub/Contexts/Interfaces/ChatContext/IChatConfigurationBuilder.cs index fa9d24a9..5c3c1788 100644 --- a/src/MaIN.Core/Hub/Contexts/Interfaces/ChatContext/IChatConfigurationBuilder.cs +++ b/src/MaIN.Core/Hub/Contexts/Interfaces/ChatContext/IChatConfigurationBuilder.cs @@ -104,5 +104,5 @@ public interface IChatConfigurationBuilder : IChatActions /// A flag indicating whether the chat session should be interactive. Default is false. /// An optional callback invoked whenever a new token or update is received during streaming. /// A object containing the result of the completed chat session. - Task CompleteAsync(bool translate = false, bool interactive = false, Func? changeOfValue = null); + Task CompleteAsync(bool translate = false, bool interactive = false, Func? changeOfValue = null, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/src/MaIN.InferPage/Components/Pages/Home.razor b/src/MaIN.InferPage/Components/Pages/Home.razor index c5956f43..a6828720 100644 --- a/src/MaIN.InferPage/Components/Pages/Home.razor +++ b/src/MaIN.InferPage/Components/Pages/Home.razor @@ -384,7 +384,7 @@ { await InvokeAsync(async () => await JS.InvokeVoidAsync("scrollManager.scrollToBottomSmooth", cancellationToken, _bottomElement)); } - }); + }, cancellationToken: cancellationToken); await completionTask.WaitAsync(cancellationToken); diff --git a/src/MaIN.InferPage/Utils.cs b/src/MaIN.InferPage/Utils.cs index c995d5ae..a4aacf56 100644 --- a/src/MaIN.InferPage/Utils.cs +++ b/src/MaIN.InferPage/Utils.cs @@ -8,7 +8,7 @@ public static class Utils public static BackendType BackendType { get; set; } = BackendType.Self; public static bool HasApiKey { get; set; } public static bool IsLocal => BackendType == BackendType.Self || (BackendType == BackendType.Ollama && !HasApiKey); - public static string? Model = "gemma3:4b"; + public static string? Model = "gemma3-4b"; public static bool Reason { get; set; } public static bool Visual => VisualModels.Contains(Model); private static readonly string[] VisualModels = ["FLUX.1_Shnell", "FLUX.1", "dall-e-3", "dall-e", "imagen", "imagen-3"]; //user might type different names diff --git a/src/MaIN.Services/Services/Abstract/IChatService.cs b/src/MaIN.Services/Services/Abstract/IChatService.cs index c5ffb5f6..0bbd013f 100644 --- a/src/MaIN.Services/Services/Abstract/IChatService.cs +++ b/src/MaIN.Services/Services/Abstract/IChatService.cs @@ -11,7 +11,8 @@ Task Completions( Chat chat, bool translatePrompt = false, bool interactiveUpdates = false, - Func? changeOfValue = null); + Func? changeOfValue = null, + CancellationToken cancellationToken = default); Task Delete(string id); Task GetById(string id); Task> GetAll(); diff --git a/src/MaIN.Services/Services/ChatService.cs b/src/MaIN.Services/Services/ChatService.cs index 2ecd32e2..7fa7853d 100644 --- a/src/MaIN.Services/Services/ChatService.cs +++ b/src/MaIN.Services/Services/ChatService.cs @@ -30,13 +30,14 @@ public async Task Completions( Chat chat, bool translate = false, bool interactiveUpdates = false, - Func? changeOfValue = null) + Func? changeOfValue = null, + CancellationToken cancellationToken = default) { if (chat.ModelId == ImageGenService.LocalImageModels.FLUX) { chat.Visual = true; // TODO: add IImageGenModel interface and check for that instead } - chat.Backend ??= chat.ModelInstance?.Backend ?? settings.BackendType; + 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); @@ -59,13 +60,13 @@ public async Task Completions( }))]; } - var result = chat.Visual - ? await imageGenServiceFactory.CreateService(chat.Backend.Value)!.Send(chat) + 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 - }); + }, cancellationToken); if (translate) { diff --git a/src/MaIN.Services/Services/LLMService/OpenAiCompatibleService.cs b/src/MaIN.Services/Services/LLMService/OpenAiCompatibleService.cs index 31b8c9f8..e9d0aaea 100644 --- a/src/MaIN.Services/Services/LLMService/OpenAiCompatibleService.cs +++ b/src/MaIN.Services/Services/LLMService/OpenAiCompatibleService.cs @@ -626,6 +626,8 @@ private async Task ProcessStreamingChatAsync( while (!reader.EndOfStream) { + cancellationToken.ThrowIfCancellationRequested(); + var line = await reader.ReadLineAsync(cancellationToken); if (string.IsNullOrWhiteSpace(line)) continue; From e0375fac9a9b18276495ff597ca0db9215ec32ea Mon Sep 17 00:00:00 2001 From: Magdalena Majta Date: Fri, 20 Feb 2026 01:18:04 +0100 Subject: [PATCH 11/22] Add themeManager and replace eval-based theme access Introduce a small JS themeManager in App.razor that bootstraps theme on page load (reads localStorage, parses JSON, and sets documentElement data-theme for dark mode) and exposes save/load helpers. Replace prior eval-based localStorage/document access in NavBar.razor and Home.razor with calls to themeManager.load, and update component logic to derive UI mode/accent color from the returned value. This centralizes theme persistence, avoids using eval, and provides safer parsing and fallbacks. --- src/MaIN.InferPage/Components/App.razor | 26 +++++++++++++++++++ .../Components/Layout/NavBar.razor | 2 +- .../Components/Pages/Home.razor | 2 +- 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/MaIN.InferPage/Components/App.razor b/src/MaIN.InferPage/Components/App.razor index b8ab8de5..889b8ac3 100644 --- a/src/MaIN.InferPage/Components/App.razor +++ b/src/MaIN.InferPage/Components/App.razor @@ -9,6 +9,17 @@ + @@ -16,6 +27,21 @@ + \ No newline at end of file diff --git a/src/MaIN.InferPage/Components/Layout/NavBar.razor b/src/MaIN.InferPage/Components/Layout/NavBar.razor index bb6fbd9d..778b8c58 100644 --- a/src/MaIN.InferPage/Components/Layout/NavBar.razor +++ b/src/MaIN.InferPage/Components/Layout/NavBar.razor @@ -52,7 +52,7 @@ { if (firstRender) { - var stored = await JS.InvokeAsync("eval", "localStorage.getItem('theme') ?? ''"); + var stored = await JS.InvokeAsync("themeManager.load"); Mode = stored == "dark" ? DesignThemeModes.Dark : DesignThemeModes.Light; StateHasChanged(); } diff --git a/src/MaIN.InferPage/Components/Pages/Home.razor b/src/MaIN.InferPage/Components/Pages/Home.razor index a6828720..5d8d2c00 100644 --- a/src/MaIN.InferPage/Components/Pages/Home.razor +++ b/src/MaIN.InferPage/Components/Pages/Home.razor @@ -211,7 +211,7 @@ { if (firstRender) { - var theme = await JS.InvokeAsync("eval", "document.body.dataset.theme ?? ''"); + var theme = await JS.InvokeAsync("themeManager.load"); _accentColor = (theme == "dark" || theme == "system-dark") ? "#00ffcc" : "#00cca3"; StateHasChanged(); } From b6056bca32ed8b3858fe17316d0a1cd5ab0dc6cc Mon Sep 17 00:00:00 2001 From: Magdalena Majta Date: Fri, 20 Feb 2026 10:51:10 +0100 Subject: [PATCH 12/22] Use LLMApiRegistry for API keys in IferPage Program.cs Remove the old BackendType extension and centralize API key metadata in LLMApiRegistry (moved to MaIN.Domain.Models.Concrete). Program.cs now looks up the registry entry for each BackendType to read ApiKeyEnvName instead of calling GetApiKeyVariable. Updated numerous LLM and image service files (and McpService) to reference the new namespace. This change consolidates API key configuration and removes the duplicated extension method. --- src/MaIN.Core.UnitTests/ChatContextTests.cs | 6 ++--- src/MaIN.Domain/Extensions.cs | 22 ------------------- .../Models/Concrete}/LLMApiRegistry.cs | 16 +++++++++++++- src/MaIN.InferPage/Program.cs | 4 ++-- .../ImageGenServices/GeminiImageGenService.cs | 1 + .../ImageGenServices/ImageGenDalleService.cs | 1 + .../ImageGenServices/XaiImageGenService.cs | 1 + .../Services/LLMService/AnthropicService.cs | 1 + .../Services/LLMService/DeepSeekService.cs | 1 + .../Services/LLMService/GeminiService.cs | 1 + .../Services/LLMService/GroqCloudService.cs | 1 + .../Services/LLMService/OllamaService.cs | 1 + .../Services/LLMService/OpenAiService.cs | 1 + .../Services/LLMService/XaiService.cs | 1 + src/MaIN.Services/Services/McpService.cs | 1 + 15 files changed, 31 insertions(+), 28 deletions(-) delete mode 100644 src/MaIN.Domain/Extensions.cs rename src/{MaIN.Services/Services/LLMService/Utils => MaIN.Domain/Models/Concrete}/LLMApiRegistry.cs (60%) diff --git a/src/MaIN.Core.UnitTests/ChatContextTests.cs b/src/MaIN.Core.UnitTests/ChatContextTests.cs index d83f4040..261c6982 100644 --- a/src/MaIN.Core.UnitTests/ChatContextTests.cs +++ b/src/MaIN.Core.UnitTests/ChatContextTests.cs @@ -88,7 +88,7 @@ public async Task CompleteAsync_ShouldCallChatService() }; - _mockChatService.Setup(s => s.Completions(It.IsAny(), It.IsAny(), It.IsAny(), null)) + _mockChatService.Setup(s => s.Completions(It.IsAny(), It.IsAny(), It.IsAny(), null, It.IsAny())) .ReturnsAsync(chatResult); _chatContext.WithMessage("User message"); @@ -98,7 +98,7 @@ public async Task CompleteAsync_ShouldCallChatService() var result = await _chatContext.CompleteAsync(); // Assert - _mockChatService.Verify(s => s.Completions(It.IsAny(), false, false, null), Times.Once); + _mockChatService.Verify(s => s.Completions(It.IsAny(), false, false, null, It.IsAny()), Times.Once); Assert.Equal(chatResult, result); } @@ -128,6 +128,6 @@ await _chatContext.WithModel(model) .CompleteAsync(); // Assert - _mockChatService.Verify(s => s.Completions(It.Is(c => c.ModelId == _testModelId && c.ModelInstance == model), It.IsAny(), It.IsAny(), null), Times.Once); + _mockChatService.Verify(s => s.Completions(It.Is(c => c.ModelId == _testModelId && c.ModelInstance == model), It.IsAny(), It.IsAny(), null, It.IsAny()), Times.Once); } } diff --git a/src/MaIN.Domain/Extensions.cs b/src/MaIN.Domain/Extensions.cs deleted file mode 100644 index d95aba22..00000000 --- a/src/MaIN.Domain/Extensions.cs +++ /dev/null @@ -1,22 +0,0 @@ -using MaIN.Domain.Configuration; - -namespace MaIN.Domain; - -public static class Extensions -{ - public static string GetApiKeyVariable(this BackendType backendType) - { - return backendType switch - { - BackendType.Self => "", - BackendType.Anthropic => "ANTHROPIC_API_KEY", - BackendType.DeepSeek => "DEEPSEEK_API_KEY", - BackendType.Gemini => "GEMINI_API_KEY", - BackendType.GroqCloud => "GROQ_API_KEY", - BackendType.Ollama => "OLLAMA_API_KEY", - BackendType.OpenAi => "OPENAI_API_KEY", - BackendType.Xai => "XAI_API_KEY", - _ => throw new ArgumentOutOfRangeException(nameof(BackendType)) - }; - } -} \ No newline at end of file diff --git a/src/MaIN.Services/Services/LLMService/Utils/LLMApiRegistry.cs b/src/MaIN.Domain/Models/Concrete/LLMApiRegistry.cs similarity index 60% rename from src/MaIN.Services/Services/LLMService/Utils/LLMApiRegistry.cs rename to src/MaIN.Domain/Models/Concrete/LLMApiRegistry.cs index 302e73cf..726aa434 100644 --- a/src/MaIN.Services/Services/LLMService/Utils/LLMApiRegistry.cs +++ b/src/MaIN.Domain/Models/Concrete/LLMApiRegistry.cs @@ -1,4 +1,6 @@ -namespace MaIN.Services.Services.LLMService.Utils; +using MaIN.Domain.Configuration; + +namespace MaIN.Domain.Models.Concrete; public static class LLMApiRegistry { @@ -9,6 +11,18 @@ public static class LLMApiRegistry public static readonly LLMApiRegistryEntry Anthropic = new("Anthropic", "ANTHROPIC_API_KEY"); public static readonly LLMApiRegistryEntry Xai = new("Xai", "XAI_API_KEY"); public static readonly LLMApiRegistryEntry Ollama = new("Ollama", "OLLAMA_API_KEY"); + + public static LLMApiRegistryEntry? GetEntry(BackendType backendType) => backendType switch + { + BackendType.OpenAi => OpenAi, + BackendType.Gemini => Gemini, + BackendType.DeepSeek => Deepseek, + BackendType.GroqCloud => Groq, + BackendType.Anthropic => Anthropic, + BackendType.Xai => Xai, + BackendType.Ollama => Ollama, + _ => null + }; } public record LLMApiRegistryEntry(string ApiName, string ApiKeyEnvName); \ No newline at end of file diff --git a/src/MaIN.InferPage/Program.cs b/src/MaIN.InferPage/Program.cs index 09b2be78..ebc54449 100644 --- a/src/MaIN.InferPage/Program.cs +++ b/src/MaIN.InferPage/Program.cs @@ -1,6 +1,6 @@ using MaIN.Core; -using MaIN.Domain; using MaIN.Domain.Configuration; +using MaIN.Domain.Models.Concrete; using MaIN.Domain.Models.Abstract; using Microsoft.FluentUI.AspNetCore.Components; using MaIN.InferPage.Components; @@ -34,7 +34,7 @@ if (Utils.BackendType != BackendType.Self) { - var apiKeyVariable = Utils.BackendType.GetApiKeyVariable(); + var apiKeyVariable = LLMApiRegistry.GetEntry(Utils.BackendType)?.ApiKeyEnvName ?? string.Empty; var key = Environment.GetEnvironmentVariable(apiKeyVariable); if (string.IsNullOrEmpty(key) && !string.IsNullOrEmpty(apiKeyVariable)) diff --git a/src/MaIN.Services/Services/ImageGenServices/GeminiImageGenService.cs b/src/MaIN.Services/Services/ImageGenServices/GeminiImageGenService.cs index dd223e6b..bae97232 100644 --- a/src/MaIN.Services/Services/ImageGenServices/GeminiImageGenService.cs +++ b/src/MaIN.Services/Services/ImageGenServices/GeminiImageGenService.cs @@ -7,6 +7,7 @@ using System.Net.Http.Json; using System.Text.Json.Serialization; using MaIN.Domain.Exceptions; +using MaIN.Domain.Models.Concrete; using MaIN.Services.Services.LLMService.Utils; namespace MaIN.Services.Services.ImageGenServices; diff --git a/src/MaIN.Services/Services/ImageGenServices/ImageGenDalleService.cs b/src/MaIN.Services/Services/ImageGenServices/ImageGenDalleService.cs index 066bdafd..96c66cbb 100644 --- a/src/MaIN.Services/Services/ImageGenServices/ImageGenDalleService.cs +++ b/src/MaIN.Services/Services/ImageGenServices/ImageGenDalleService.cs @@ -3,6 +3,7 @@ using MaIN.Domain.Configuration; using MaIN.Domain.Entities; using MaIN.Domain.Exceptions; +using MaIN.Domain.Models.Concrete; using MaIN.Services.Constants; using MaIN.Services.Services.Abstract; using MaIN.Services.Services.LLMService.Utils; diff --git a/src/MaIN.Services/Services/ImageGenServices/XaiImageGenService.cs b/src/MaIN.Services/Services/ImageGenServices/XaiImageGenService.cs index 3b931b44..bf10ff53 100644 --- a/src/MaIN.Services/Services/ImageGenServices/XaiImageGenService.cs +++ b/src/MaIN.Services/Services/ImageGenServices/XaiImageGenService.cs @@ -7,6 +7,7 @@ using System.Net.Http.Json; using System.Text.Json; using MaIN.Domain.Exceptions; +using MaIN.Domain.Models.Concrete; using MaIN.Services.Services.LLMService.Utils; namespace MaIN.Services.Services.ImageGenServices; diff --git a/src/MaIN.Services/Services/LLMService/AnthropicService.cs b/src/MaIN.Services/Services/LLMService/AnthropicService.cs index 03724568..68c51cec 100644 --- a/src/MaIN.Services/Services/LLMService/AnthropicService.cs +++ b/src/MaIN.Services/Services/LLMService/AnthropicService.cs @@ -12,6 +12,7 @@ using MaIN.Domain.Configuration; using MaIN.Domain.Entities.Tools; using MaIN.Domain.Exceptions; +using MaIN.Domain.Models.Concrete; using MaIN.Services.Services.LLMService.Utils; namespace MaIN.Services.Services.LLMService; diff --git a/src/MaIN.Services/Services/LLMService/DeepSeekService.cs b/src/MaIN.Services/Services/LLMService/DeepSeekService.cs index 5e48bc85..a68696db 100644 --- a/src/MaIN.Services/Services/LLMService/DeepSeekService.cs +++ b/src/MaIN.Services/Services/LLMService/DeepSeekService.cs @@ -10,6 +10,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using MaIN.Domain.Exceptions; +using MaIN.Domain.Models.Concrete; using MaIN.Services.Services.LLMService.Utils; namespace MaIN.Services.Services.LLMService; diff --git a/src/MaIN.Services/Services/LLMService/GeminiService.cs b/src/MaIN.Services/Services/LLMService/GeminiService.cs index 5d741498..677fd85f 100644 --- a/src/MaIN.Services/Services/LLMService/GeminiService.cs +++ b/src/MaIN.Services/Services/LLMService/GeminiService.cs @@ -11,6 +11,7 @@ using MaIN.Domain.Entities; using MaIN.Domain.Exceptions; using MaIN.Domain.Models; +using MaIN.Domain.Models.Concrete; using MaIN.Services.Services.LLMService.Utils; using MaIN.Services.Utils; diff --git a/src/MaIN.Services/Services/LLMService/GroqCloudService.cs b/src/MaIN.Services/Services/LLMService/GroqCloudService.cs index fa9a34a9..8379082d 100644 --- a/src/MaIN.Services/Services/LLMService/GroqCloudService.cs +++ b/src/MaIN.Services/Services/LLMService/GroqCloudService.cs @@ -2,6 +2,7 @@ using MaIN.Domain.Configuration; using MaIN.Domain.Entities; using MaIN.Domain.Exceptions; +using MaIN.Domain.Models.Concrete; using MaIN.Services.Services.Abstract; using Microsoft.Extensions.Logging; using MaIN.Services.Services.LLMService.Memory; diff --git a/src/MaIN.Services/Services/LLMService/OllamaService.cs b/src/MaIN.Services/Services/LLMService/OllamaService.cs index dc779847..74da470e 100644 --- a/src/MaIN.Services/Services/LLMService/OllamaService.cs +++ b/src/MaIN.Services/Services/LLMService/OllamaService.cs @@ -1,6 +1,7 @@ using System.Text; using MaIN.Domain.Configuration; using MaIN.Domain.Entities; +using MaIN.Domain.Models.Concrete; using MaIN.Services.Constants; using MaIN.Services.Services.Abstract; using MaIN.Services.Services.LLMService.Memory; diff --git a/src/MaIN.Services/Services/LLMService/OpenAiService.cs b/src/MaIN.Services/Services/LLMService/OpenAiService.cs index e64c9fd9..461cda56 100644 --- a/src/MaIN.Services/Services/LLMService/OpenAiService.cs +++ b/src/MaIN.Services/Services/LLMService/OpenAiService.cs @@ -1,5 +1,6 @@ using MaIN.Domain.Configuration; using MaIN.Domain.Exceptions; +using MaIN.Domain.Models.Concrete; using MaIN.Services.Services.Abstract; using Microsoft.Extensions.Logging; using MaIN.Services.Services.LLMService.Memory; diff --git a/src/MaIN.Services/Services/LLMService/XaiService.cs b/src/MaIN.Services/Services/LLMService/XaiService.cs index 38f84ea5..13271496 100644 --- a/src/MaIN.Services/Services/LLMService/XaiService.cs +++ b/src/MaIN.Services/Services/LLMService/XaiService.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.Logging; using System.Text; using MaIN.Domain.Exceptions; +using MaIN.Domain.Models.Concrete; using MaIN.Services.Services.LLMService.Utils; namespace MaIN.Services.Services.LLMService; diff --git a/src/MaIN.Services/Services/McpService.cs b/src/MaIN.Services/Services/McpService.cs index 4521d1b4..9572b40b 100644 --- a/src/MaIN.Services/Services/McpService.cs +++ b/src/MaIN.Services/Services/McpService.cs @@ -1,5 +1,6 @@ using MaIN.Domain.Configuration; using MaIN.Domain.Entities; +using MaIN.Domain.Models.Concrete; using MaIN.Services.Services.Abstract; using MaIN.Services.Services.LLMService.Utils; using MaIN.Services.Services.Models; From 80e043e2f0b84796dedee37d8962d0294d1045cf Mon Sep 17 00:00:00 2001 From: Magdalena Majta Date: Fri, 20 Feb 2026 11:42:57 +0100 Subject: [PATCH 13/22] fix MemoryStream leaks + multi-attachments issue --- .../Components/Pages/Home.razor | 51 ++++++++++--------- .../Services/LLMService/DeepSeekService.cs | 2 +- .../Services/LLMService/GroqCloudService.cs | 2 +- .../LLMService/Memory/DocumentProcessor.cs | 10 ++-- .../Services/LLMService/XaiService.cs | 2 +- 5 files changed, 34 insertions(+), 33 deletions(-) diff --git a/src/MaIN.InferPage/Components/Pages/Home.razor b/src/MaIN.InferPage/Components/Pages/Home.razor index 5d8d2c00..a2a3cf4f 100644 --- a/src/MaIN.InferPage/Components/Pages/Home.razor +++ b/src/MaIN.InferPage/Components/Pages/Home.razor @@ -199,7 +199,7 @@ private List Messages { get; set; } = new(); private ElementReference? _bottomElement; private ElementReference _editorRef; - private List _selectedFiles = new(); + private List _selectedFiles = new(); private int _inputKey; private readonly MarkdownPipeline _markdownPipeline = new MarkdownPipelineBuilder() @@ -271,17 +271,34 @@ } } - private void HandleFileSelected(InputFileChangeEventArgs e) + private async Task HandleFileSelected(InputFileChangeEventArgs e) { foreach (var file in e.GetMultipleFiles(10)) { - _selectedFiles.Add(file); + var ms = new MemoryStream(); + try + { + await file.OpenReadStream(20 * 1024 * 1024).CopyToAsync(ms); + ms.Position = 0; + _selectedFiles.Add(new FileInfo + { + Name = file.Name, + Extension = Path.GetExtension(file.Name), + StreamContent = ms + }); + } + catch (Exception ex) + { + await ms.DisposeAsync(); + _errorMessage = $"Failed to read file {file.Name}: {ex.Message}"; + } } StateHasChanged(); } - private void RemoveFile(IBrowserFile file) + private void RemoveFile(FileInfo file) { + file.StreamContent?.Dispose(); _selectedFiles.Remove(file); } @@ -304,29 +321,9 @@ _isLoading = true; StateHasChanged(); + var attachments = new List(_selectedFiles); try { - var attachments = new List(); - foreach (var file in _selectedFiles) - { - try - { - var ms = new MemoryStream(); - await file.OpenReadStream(20 * 1024 * 1024).CopyToAsync(ms, cancellationToken); - ms.Position = 0; - attachments.Add(new FileInfo - { - Name = file.Name, - Extension = Path.GetExtension(file.Name), - StreamContent = ms - }); - } - catch (Exception ex) - { - _errorMessage = $"Failed to read file {file.Name}: {ex.Message}"; - } - } - _selectedFiles.Clear(); _inputKey++; StateHasChanged(); @@ -445,6 +442,10 @@ } finally { + foreach (var attachment in attachments) + attachment.StreamContent?.Dispose(); + attachments.Clear(); + _isLoading = false; _isThinking = false; StateHasChanged(); diff --git a/src/MaIN.Services/Services/LLMService/DeepSeekService.cs b/src/MaIN.Services/Services/LLMService/DeepSeekService.cs index a68696db..f64eb3df 100644 --- a/src/MaIN.Services/Services/LLMService/DeepSeekService.cs +++ b/src/MaIN.Services/Services/LLMService/DeepSeekService.cs @@ -57,7 +57,7 @@ protected override void ValidateApiKey() CancellationToken cancellationToken = default) { var lastMsg = chat.Messages.Last(); - var filePaths = await DocumentProcessor.ConvertToFilesContent(memoryOptions); + var filePaths = await DocumentProcessor.ConvertToFilesContent(memoryOptions, cancellationToken); var message = new Message() { Role = ServiceConstants.Roles.User, diff --git a/src/MaIN.Services/Services/LLMService/GroqCloudService.cs b/src/MaIN.Services/Services/LLMService/GroqCloudService.cs index 8379082d..e580780f 100644 --- a/src/MaIN.Services/Services/LLMService/GroqCloudService.cs +++ b/src/MaIN.Services/Services/LLMService/GroqCloudService.cs @@ -51,7 +51,7 @@ protected override void ValidateApiKey() CancellationToken cancellationToken = default) { var lastMsg = chat.Messages.Last(); - var filePaths = await DocumentProcessor.ConvertToFilesContent(memoryOptions); + var filePaths = await DocumentProcessor.ConvertToFilesContent(memoryOptions, cancellationToken); var message = new Message() { Role = ServiceConstants.Roles.User, diff --git a/src/MaIN.Services/Services/LLMService/Memory/DocumentProcessor.cs b/src/MaIN.Services/Services/LLMService/Memory/DocumentProcessor.cs index 3a52ca21..da604f56 100644 --- a/src/MaIN.Services/Services/LLMService/Memory/DocumentProcessor.cs +++ b/src/MaIN.Services/Services/LLMService/Memory/DocumentProcessor.cs @@ -31,7 +31,7 @@ public static string ProcessDocument(string filePath) }; } - public static async Task ConvertToFilesContent(ChatMemoryOptions options) + public static async Task ConvertToFilesContent(ChatMemoryOptions options, CancellationToken cancellationToken = default) { var files = new List(); foreach (var fData in options.FilesData) @@ -43,14 +43,14 @@ public static async Task ConvertToFilesContent(ChatMemoryOptions optio { var path = Path.Combine(Path.GetTempPath(), sData.Key); await using var fileStream = new FileStream(path, FileMode.Create, FileAccess.Write); - await sData.Value.CopyToAsync(fileStream); + await sData.Value.CopyToAsync(fileStream, cancellationToken); files.Add(path); } foreach (var txt in options.TextData) { var path = Path.Combine(Path.GetTempPath(), $"{txt.Key}.txt"); - await File.WriteAllTextAsync(path, txt.Value); + await File.WriteAllTextAsync(path, txt.Value, cancellationToken); files.Add(path); } @@ -60,8 +60,8 @@ public static async Task ConvertToFilesContent(ChatMemoryOptions optio foreach (var web in options.WebUrls) { var path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.html"); - var html = await client.GetStringAsync(web); - await File.WriteAllTextAsync(path, html); + var html = await client.GetStringAsync(web, cancellationToken); + await File.WriteAllTextAsync(path, html, cancellationToken); files.Add(path); } } diff --git a/src/MaIN.Services/Services/LLMService/XaiService.cs b/src/MaIN.Services/Services/LLMService/XaiService.cs index 13271496..69e3ce3b 100644 --- a/src/MaIN.Services/Services/LLMService/XaiService.cs +++ b/src/MaIN.Services/Services/LLMService/XaiService.cs @@ -50,7 +50,7 @@ protected override void ValidateApiKey() CancellationToken cancellationToken = default) { var lastMsg = chat.Messages.Last(); - var filePaths = await DocumentProcessor.ConvertToFilesContent(memoryOptions); + var filePaths = await DocumentProcessor.ConvertToFilesContent(memoryOptions, cancellationToken); var message = new Message() { Role = ServiceConstants.Roles.User, From c4a0f572b35f78af8f81e965e381dc1914842554 Mon Sep 17 00:00:00 2001 From: Magdalena Majta Date: Fri, 20 Feb 2026 12:36:16 +0100 Subject: [PATCH 14/22] smarter scroll --- .../Components/Pages/Home.razor | 30 ++++++++---- src/MaIN.InferPage/wwwroot/scroll.js | 48 +++++++++++++------ 2 files changed, 54 insertions(+), 24 deletions(-) diff --git a/src/MaIN.InferPage/Components/Pages/Home.razor b/src/MaIN.InferPage/Components/Pages/Home.razor index a2a3cf4f..4ee61437 100644 --- a/src/MaIN.InferPage/Components/Pages/Home.razor +++ b/src/MaIN.InferPage/Components/Pages/Home.razor @@ -82,7 +82,7 @@ {
+ @onclick="@(() => ToggleReasoning(conversation))"> @@ -213,15 +213,14 @@ { var theme = await JS.InvokeAsync("themeManager.load"); _accentColor = (theme == "dark" || theme == "system-dark") ? "#00ffcc" : "#00cca3"; - StateHasChanged(); - } - else if (!_preserveScroll) - { + await JS.InvokeVoidAsync("scrollManager.attachScrollListener", "messages-container"); await JS.InvokeVoidAsync("scrollManager.restoreScrollPosition", "messages-container"); + StateHasChanged(); } - else + else if (_preserveScroll) { _preserveScroll = false; + await JS.InvokeVoidAsync("scrollManager.restoreScrollPosition", "messages-container"); } } @@ -346,9 +345,12 @@ Chat.ModelId = Utils.Model!; Chat.Visual = Utils.Visual; + bool wasAtBottom = await JS.InvokeAsync("scrollManager.isAtBottom", "messages-container"); + StateHasChanged(); - bool wasAtBottom = await JS.InvokeAsync("scrollManager.isAtBottom", "messages-container"); + if (wasAtBottom) + await JS.InvokeVoidAsync("scrollManager.scrollToBottomSmooth", "messages-container"); var request = ctx!.WithMessage(msg); if (attachments.Count != 0) @@ -379,7 +381,7 @@ await InvokeAsync(StateHasChanged); if (wasAtBottom) { - await InvokeAsync(async () => await JS.InvokeVoidAsync("scrollManager.scrollToBottomSmooth", cancellationToken, _bottomElement)); + await InvokeAsync(async () => await JS.InvokeVoidAsync("scrollManager.scrollToBottomSmooth", "messages-container")); } }, cancellationToken: cancellationToken); @@ -388,10 +390,11 @@ var currentChat = await ctx.GetCurrentChat(); Chat.Messages.Add(currentChat.Messages.Last()); + await JS.InvokeVoidAsync("scrollManager.saveScrollPosition", "messages-container"); + _preserveScroll = true; RebuildMessagesWithFiles(); _incomingReasoning = null; _incomingMessage = null; - await JS.InvokeVoidAsync("scrollManager.scrollToBottomSmooth", _bottomElement); } catch (OperationCanceledException) { @@ -475,6 +478,13 @@ : MessageType.CloudLLM; } + private async Task ToggleReasoning(MessageExt conversation) + { + await JS.InvokeVoidAsync("scrollManager.saveScrollPosition", "messages-container"); + _preserveScroll = true; + conversation.ShowReason = !conversation.ShowReason; + } + private void RebuildMessagesWithFiles() { var existingFilesMap = Messages @@ -485,7 +495,7 @@ { Message = x, AttachedFiles = existingFilesMap.TryGetValue(x, out var files) ? files : new List(), - ShowReason = x.Tokens.Any(t => t.Type == TokenType.Reason) + ShowReason = false }).ToList(); } diff --git a/src/MaIN.InferPage/wwwroot/scroll.js b/src/MaIN.InferPage/wwwroot/scroll.js index a2bc21a9..f05bfbb9 100644 --- a/src/MaIN.InferPage/wwwroot/scroll.js +++ b/src/MaIN.InferPage/wwwroot/scroll.js @@ -1,16 +1,25 @@ window.scrollManager = { - isUserScrolling: false, + userScrolledUp: false, + isProgrammaticScroll: false, + _savedScrollTop: null, saveScrollPosition: (containerId) => { const container = document.getElementById(containerId); if (!container) return; - sessionStorage.setItem("scrollTop", container.scrollTop); + window.scrollManager._savedScrollTop = container.scrollTop; + container.style.overflowY = 'hidden'; }, restoreScrollPosition: (containerId) => { const container = document.getElementById(containerId); if (!container) return; - container.scrollTop = 9999; + if (window.scrollManager._savedScrollTop !== null) { + container.scrollTop = window.scrollManager._savedScrollTop; + window.scrollManager._savedScrollTop = null; + } else { + container.scrollTop = container.scrollHeight; + } + container.style.overflowY = ''; }, isAtBottom: (containerId) => { @@ -19,24 +28,35 @@ window.scrollManager = { return container.scrollHeight - container.scrollTop <= container.clientHeight + 50; }, - scrollToBottomSmooth: (bottomElement) => { - if (!bottomElement) return; - if (!window.scrollManager.isUserScrolling) { - bottomElement.scrollIntoView({ behavior: 'smooth' }); - } + scrollToBottomSmooth: (containerId) => { + if (window.scrollManager.userScrolledUp) return; + const container = document.getElementById(containerId); + if (!container) return; + window.scrollManager.isProgrammaticScroll = true; + container.scrollTop = container.scrollHeight; + window.scrollManager.isProgrammaticScroll = false; }, attachScrollListener: (containerId) => { const container = document.getElementById(containerId); if (!container) return; + container.addEventListener("wheel", (e) => { + if (e.deltaY < 0) { + window.scrollManager.userScrolledUp = true; + } + }); + + container.addEventListener("touchmove", () => { + window.scrollManager.userScrolledUp = true; + }); + container.addEventListener("scroll", () => { - window.scrollManager.isUserScrolling = - container.scrollHeight - container.scrollTop > container.clientHeight + 50; + if (window.scrollManager.isProgrammaticScroll) return; + const atBottom = container.scrollHeight - container.scrollTop <= container.clientHeight + 50; + if (atBottom) { + window.scrollManager.userScrolledUp = false; + } }); } }; - -document.addEventListener("DOMContentLoaded", () => { - window.scrollManager.attachScrollListener("bottom"); -}); From ed05edfefdd4943f80d2814e2e435dfe2ab6cbdd Mon Sep 17 00:00:00 2001 From: Magdalena Majta Date: Tue, 24 Feb 2026 14:10:02 +0100 Subject: [PATCH 15/22] Handle unregistered ai models; Support images input in cloud LLM Add multi-image support and extract image bytes from uploaded files for LLM services; improve model ID/instance handling and model selection flow. - Message: replace single byte[] Image with List Images and keep a backward-compatible Image getter/setter. - Chat: preserve raw ModelId string, safely try to resolve model instance (no throws), and sync ModelInstance with internal id field. - Home.razor: unify model resolution into a local variable and choose GenericLocalModel/GenericCloudModel when registry lookup fails. - AnthropicService & OpenAiCompatibleService: add ExtractImageFromFiles to load image file bytes into Message.Images, remove consumed file entries, update HasImages/BuildMessageContent to iterate images, and extend image type detection (HEIC/HEIF, AVIF and more extensions). These changes enable passing uploaded images to compatible LLM backends while maintaining backward compatibility and preventing exceptions when models are missing. --- src/MaIN.Domain/Entities/Chat.cs | 12 ++- src/MaIN.Domain/Entities/Message.cs | 14 ++- .../Components/Pages/Home.razor | 20 ++++- .../Services/LLMService/AnthropicService.cs | 43 +++++++++- .../LLMService/OpenAiCompatibleService.cs | 85 +++++++++++++++---- 5 files changed, 148 insertions(+), 26 deletions(-) diff --git a/src/MaIN.Domain/Entities/Chat.cs b/src/MaIN.Domain/Entities/Chat.cs index 892a6ff4..60a23314 100644 --- a/src/MaIN.Domain/Entities/Chat.cs +++ b/src/MaIN.Domain/Entities/Chat.cs @@ -9,25 +9,31 @@ public class Chat { public string Id { get; init; } = string.Empty; public required string Name { get; init; } + private string? _modelId; public required string ModelId { - get => _modelInstance?.Id ?? string.Empty; + get => _modelInstance?.Id ?? _modelId ?? string.Empty; set { + _modelId = value; if (string.IsNullOrEmpty(value)) { _modelInstance = null; return; } - _modelInstance = ModelRegistry.GetById(value); + ModelRegistry.TryGetById(value, out _modelInstance); } } private AIModel? _modelInstance; public AIModel? ModelInstance { get => _modelInstance; - set => (_modelInstance, ModelId) = (value, value?.Id ?? string.Empty); + set + { + _modelInstance = value; + _modelId = value?.Id ?? string.Empty; + } } public List Messages { get; set; } = []; public ChatType Type { get; set; } = ChatType.Conversation; diff --git a/src/MaIN.Domain/Entities/Message.cs b/src/MaIN.Domain/Entities/Message.cs index 5c54f71b..8efeebeb 100644 --- a/src/MaIN.Domain/Entities/Message.cs +++ b/src/MaIN.Domain/Entities/Message.cs @@ -19,7 +19,19 @@ public Message() public List Tokens { get; set; } = []; public bool Tool { get; init; } public DateTime Time { get; set; } - public byte[]? Image { get; init; } + public List? Images { get; set; } + + // Backward-compat wrapper – single image access + public byte[]? Image + { + get => Images?.Count > 0 ? Images[0] : null; + set + { + if (value == null) Images = null; + else Images = [value]; + } + } + public byte[]? Speech { get; set; } public List? Files { get; set; } public Dictionary Properties { get; set; } = []; diff --git a/src/MaIN.InferPage/Components/Pages/Home.razor b/src/MaIN.InferPage/Components/Pages/Home.razor index 4ee61437..9d60c3d9 100644 --- a/src/MaIN.InferPage/Components/Pages/Home.razor +++ b/src/MaIN.InferPage/Components/Pages/Home.razor @@ -226,13 +226,26 @@ protected override Task OnInitializedAsync() { + AIModel? model = null; + try { + if (Utils.BackendType == BackendType.Self && Utils.Path != null) + { + model = ModelRegistry.TryGetById(Utils.Model!, out var foundModel1) + ? foundModel1! + : new GenericLocalModel($"{Utils.Model}.gguf"); + } + else + { + model = ModelRegistry.TryGetById(Utils.Model!, out var foundModel1) + ? foundModel1! + : new GenericCloudModel(Id: Utils.Model!, Backend: Utils.BackendType); + } + ctx = Utils.Visual ? AIHub.Chat().EnableVisual() - : Utils.BackendType == BackendType.Self && Utils.Path != null - ? AIHub.Chat().WithModel(new GenericLocalModel(FileName: Utils.Model!, CustomPath: Utils.Path)) - : AIHub.Chat().WithModel(ModelRegistry.GetById(Utils.Model!)); + : AIHub.Chat().WithModel(model); } catch (MaINCustomException ex) { @@ -243,7 +256,6 @@ _errorMessage = ex.Message; } - var model = ModelRegistry.TryGetById(Utils.Model!, out var foundModel) ? foundModel : null; _reasoning = !Utils.Visual && model?.HasReasoning == true; Utils.Reason = _reasoning; diff --git a/src/MaIN.Services/Services/LLMService/AnthropicService.cs b/src/MaIN.Services/Services/LLMService/AnthropicService.cs index 68c51cec..bbf1d879 100644 --- a/src/MaIN.Services/Services/LLMService/AnthropicService.cs +++ b/src/MaIN.Services/Services/LLMService/AnthropicService.cs @@ -26,6 +26,7 @@ public sealed class AnthropicService( { private readonly MaINSettings _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + private static readonly HashSet AnthropicImageExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp"]; private static readonly ConcurrentDictionary> SessionCache = new(); private const string CompletionsUrl = ServiceConstants.ApiUrls.AnthropicChatMessages; @@ -64,12 +65,14 @@ private void ValidateApiKey() return null; var apiKey = GetApiKey(); + + var lastMessage = chat.Messages.Last(); + await ExtractImageFromFiles(lastMessage); + var conversation = GetOrCreateConversation(chat, options.CreateSession); var resultBuilder = new StringBuilder(); var tokens = new List(); - var lastMessage = chat.Messages.Last(); - if (HasFiles(lastMessage)) { var result = ChatHelper.ExtractMemoryOptions(lastMessage); @@ -632,6 +635,42 @@ private static bool HasFiles(Message message) return message.Files != null && message.Files.Count > 0; } + private static async Task ExtractImageFromFiles(Message message) + { + if (message.Files == null || message.Files.Count == 0) + return; + + var imageFiles = message.Files + .Where(f => AnthropicImageExtensions.Contains(f.Extension.ToLowerInvariant())) + .ToList(); + + if (imageFiles.Count == 0) + return; + + var imageBytesList = new List(); + foreach (var imageFile in imageFiles) + { + if (imageFile.StreamContent != null) + { + using var ms = new MemoryStream(); + imageFile.StreamContent.Position = 0; + await imageFile.StreamContent.CopyToAsync(ms); + imageBytesList.Add(ms.ToArray()); + } + else if (imageFile.Path != null) + { + imageBytesList.Add(await File.ReadAllBytesAsync(imageFile.Path)); + } + + message.Files.Remove(imageFile); + } + + message.Images = imageBytesList; + + if (message.Files.Count == 0) + message.Files = null; + } + private async Task ProcessStreamingChatAsync( Chat chat, List conversation, diff --git a/src/MaIN.Services/Services/LLMService/OpenAiCompatibleService.cs b/src/MaIN.Services/Services/LLMService/OpenAiCompatibleService.cs index e9d0aaea..241db3d5 100644 --- a/src/MaIN.Services/Services/LLMService/OpenAiCompatibleService.cs +++ b/src/MaIN.Services/Services/LLMService/OpenAiCompatibleService.cs @@ -28,16 +28,13 @@ public abstract class OpenAiCompatibleService( ILogger? logger = null) : ILLMService { - private readonly INotificationService _notificationService = - notificationService ?? throw new ArgumentNullException(nameof(notificationService)); - - private readonly IHttpClientFactory _httpClientFactory = - httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); + private readonly INotificationService _notificationService = notificationService ?? throw new ArgumentNullException(nameof(notificationService)); + private readonly IHttpClientFactory _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); private static readonly ConcurrentDictionary> SessionCache = new(); + private static readonly HashSet ImageExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tiff", ".tif", ".heic", ".heif", ".avif"]; - private static readonly JsonSerializerOptions DefaultJsonSerializerOptions = - new() { PropertyNameCaseInsensitive = true }; + private static readonly JsonSerializerOptions DefaultJsonSerializerOptions = new() { PropertyNameCaseInsensitive = true }; private const string ToolCallsProperty = "ToolCalls"; private const string ToolCallIdProperty = "ToolCallId"; @@ -63,10 +60,11 @@ public abstract class OpenAiCompatibleService( List tokens = new(); string apiKey = GetApiKey(); + var lastMessage = chat.Messages.Last(); + await ExtractImageFromFiles(lastMessage); + List conversation = GetOrCreateConversation(chat, options.CreateSession); StringBuilder resultBuilder = new(); - - var lastMessage = chat.Messages.Last(); if (HasFiles(lastMessage)) { var result = ChatHelper.ExtractMemoryOptions(lastMessage); @@ -576,6 +574,42 @@ private void UpdateSessionCache(string chatId, string assistantResponse, bool cr } } + private static async Task ExtractImageFromFiles(Message message) + { + if (message.Files == null || message.Files.Count == 0) + return; + + var imageFiles = message.Files + .Where(f => ImageExtensions.Contains(f.Extension.ToLowerInvariant())) + .ToList(); + + if (imageFiles.Count == 0) + return; + + var imageBytesList = new List(); + foreach (var imageFile in imageFiles) + { + if (imageFile.StreamContent != null) + { + using var ms = new MemoryStream(); + imageFile.StreamContent.Position = 0; + await imageFile.StreamContent.CopyToAsync(ms); + imageBytesList.Add(ms.ToArray()); + } + else if (imageFile.Path != null) + { + imageBytesList.Add(await File.ReadAllBytesAsync(imageFile.Path)); + } + + message.Files.Remove(imageFile); + } + + message.Images = imageBytesList; + + if (message.Files.Count == 0) + message.Files = null; + } + private static bool HasFiles(Message message) { return message.Files != null && message.Files.Count > 0; @@ -912,7 +946,7 @@ private static async Task InvokeTokenCallbackAsync(Func? ca private static bool HasImages(Message message) { - return message.Image != null && message.Image.Length > 0; + return message.Images?.Count > 0; } private static object BuildMessageContent(Message message, ImageType imageType) @@ -933,10 +967,10 @@ private static object BuildMessageContent(Message message, ImageType imageType) }); } - if (message.Image != null && message.Image.Length > 0) + foreach (var imageBytes in message.Images!) { - var base64Data = Convert.ToBase64String(message.Image); - var mimeType = DetectImageMimeType(message.Image); + var base64Data = Convert.ToBase64String(imageBytes); + var mimeType = DetectImageMimeType(imageBytes); switch (imageType) { @@ -976,17 +1010,17 @@ private static string DetectImageMimeType(byte[] imageBytes) if (imageBytes[0] == 0xFF && imageBytes[1] == 0xD8) return "image/jpeg"; - + if (imageBytes.Length >= 8 && imageBytes[0] == 0x89 && imageBytes[1] == 0x50 && imageBytes[2] == 0x4E && imageBytes[3] == 0x47) return "image/png"; - + if (imageBytes.Length >= 6 && imageBytes[0] == 0x47 && imageBytes[1] == 0x49 && imageBytes[2] == 0x46 && imageBytes[3] == 0x38) return "image/gif"; - + if (imageBytes.Length >= 12 && imageBytes[0] == 0x52 && imageBytes[1] == 0x49 && imageBytes[2] == 0x46 && imageBytes[3] == 0x46 && @@ -994,6 +1028,25 @@ private static string DetectImageMimeType(byte[] imageBytes) imageBytes[10] == 0x42 && imageBytes[11] == 0x50) return "image/webp"; + // HEIC/HEIF format (iPhone photos) + if (imageBytes.Length >= 12 && + imageBytes[4] == 0x66 && imageBytes[5] == 0x74 && + imageBytes[6] == 0x79 && imageBytes[7] == 0x70) + { + // Check for heic/heif brands + if ((imageBytes[8] == 0x68 && imageBytes[9] == 0x65 && imageBytes[10] == 0x69 && imageBytes[11] == 0x63) || + (imageBytes[8] == 0x68 && imageBytes[9] == 0x65 && imageBytes[10] == 0x69 && imageBytes[11] == 0x66)) + return "image/heic"; + } + + // AVIF format + if (imageBytes.Length >= 12 && + imageBytes[4] == 0x66 && imageBytes[5] == 0x74 && + imageBytes[6] == 0x79 && imageBytes[7] == 0x70 && + imageBytes[8] == 0x61 && imageBytes[9] == 0x76 && + imageBytes[10] == 0x69 && imageBytes[11] == 0x66) + return "image/avif"; + return "image/jpeg"; } } From 580d7ac3bbb80bd2ce8a0432cc2f49ecc2fc34bd Mon Sep 17 00:00:00 2001 From: Magdalena Majta Date: Tue, 24 Feb 2026 15:37:00 +0100 Subject: [PATCH 16/22] paste and drag&drop files/images --- .../Components/Pages/Home.razor | 48 ++++++++- src/MaIN.InferPage/wwwroot/editor.js | 101 ++++++++++++++++++ src/MaIN.InferPage/wwwroot/home.css | 36 +++++++ 3 files changed, 184 insertions(+), 1 deletion(-) diff --git a/src/MaIN.InferPage/Components/Pages/Home.razor b/src/MaIN.InferPage/Components/Pages/Home.razor index 9d60c3d9..4b0b7ac7 100644 --- a/src/MaIN.InferPage/Components/Pages/Home.razor +++ b/src/MaIN.InferPage/Components/Pages/Home.razor @@ -18,7 +18,16 @@ -
+
+ @if (_isDragging) + { +
+
+ + Drop files here +
+
+ } @@ -186,6 +195,7 @@ @code { private bool _isLoading; private bool _isThinking; + private bool _isDragging; private bool _reasoning; private bool _preserveScroll; private string _accentColor = "#00cca3"; @@ -199,6 +209,7 @@ private List Messages { get; set; } = new(); private ElementReference? _bottomElement; private ElementReference _editorRef; + private DotNetObjectReference? _dotNetRef; private List _selectedFiles = new(); private int _inputKey; @@ -215,6 +226,11 @@ _accentColor = (theme == "dark" || theme == "system-dark") ? "#00ffcc" : "#00cca3"; await JS.InvokeVoidAsync("scrollManager.attachScrollListener", "messages-container"); await JS.InvokeVoidAsync("scrollManager.restoreScrollPosition", "messages-container"); + + _dotNetRef = DotNetObjectReference.Create(this); + await JS.InvokeVoidAsync("editorManager.attachPasteHandler", _editorRef, _dotNetRef); + await JS.InvokeVoidAsync("editorManager.attachDropZone", "chat-container", _dotNetRef); + StateHasChanged(); } else if (_preserveScroll) @@ -313,6 +329,35 @@ _selectedFiles.Remove(file); } + [JSInvokable] + public async Task OnFilePasted(string fileName, string extension, string base64Data) + { + var bytes = Convert.FromBase64String(base64Data); + var ms = new MemoryStream(bytes); + _selectedFiles.Add(new FileInfo + { + Name = fileName, + Extension = extension, + StreamContent = ms + }); + _isDragging = false; + await InvokeAsync(StateHasChanged); + } + + [JSInvokable] + public async Task OnDragEnter() + { + _isDragging = true; + await InvokeAsync(StateHasChanged); + } + + [JSInvokable] + public async Task OnDragLeave() + { + _isDragging = false; + await InvokeAsync(StateHasChanged); + } + private void HandleStop() { _cancellationTokenSource?.Cancel(); @@ -514,5 +559,6 @@ public void Dispose() { _cancellationTokenSource?.Dispose(); + _dotNetRef?.Dispose(); } } diff --git a/src/MaIN.InferPage/wwwroot/editor.js b/src/MaIN.InferPage/wwwroot/editor.js index b9def1f1..db18024f 100644 --- a/src/MaIN.InferPage/wwwroot/editor.js +++ b/src/MaIN.InferPage/wwwroot/editor.js @@ -8,5 +8,106 @@ window.editorManager = { clickElement: (id) => { const el = document.getElementById(id); if (el) el.click(); + }, + attachPasteHandler: (element, dotNetHelper) => { + // Handle paste only + element.addEventListener('paste', async (e) => { + let imageFile = null; + + if (e.clipboardData?.files?.length > 0) { + for (const file of e.clipboardData.files) { + if (file.type.startsWith('image/')) { + imageFile = file; + break; + } + } + } + + if (!imageFile && e.clipboardData?.items) { + for (const item of e.clipboardData.items) { + if (item.type.startsWith('image/')) { + imageFile = item.getAsFile(); + break; + } + } + } + + if (!imageFile) return; + + e.preventDefault(); + await editorManager._processFile(imageFile, dotNetHelper); + }); + }, + attachDropZone: (containerId, dotNetHelper) => { + const container = document.getElementById(containerId); + if (!container) return; + + let dragCounter = 0; + + container.addEventListener('dragenter', async (e) => { + e.preventDefault(); + e.stopPropagation(); + dragCounter++; + if (dragCounter === 1) { + await dotNetHelper.invokeMethodAsync('OnDragEnter'); + } + }); + + container.addEventListener('dragleave', async (e) => { + e.preventDefault(); + e.stopPropagation(); + dragCounter--; + if (dragCounter === 0) { + await dotNetHelper.invokeMethodAsync('OnDragLeave'); + } + }); + + container.addEventListener('dragover', (e) => { + e.preventDefault(); + e.stopPropagation(); + }); + + container.addEventListener('drop', async (e) => { + e.preventDefault(); + e.stopPropagation(); + dragCounter = 0; + + const files = e.dataTransfer?.files; + if (!files || files.length === 0) { + await dotNetHelper.invokeMethodAsync('OnDragLeave'); + return; + } + + for (const file of files) { + await editorManager._processFile(file, dotNetHelper); + } + }); + }, + _processFile: async (file, dotNetHelper) => { + try { + const arrayBuffer = await file.arrayBuffer(); + const uint8Array = new Uint8Array(arrayBuffer); + + // Convert to base64 - much smaller than int array + let binary = ''; + for (let i = 0; i < uint8Array.length; i++) { + binary += String.fromCharCode(uint8Array[i]); + } + const base64 = btoa(binary); + + let extension = ''; + const lastDot = file.name.lastIndexOf('.'); + if (lastDot > 0) { + extension = file.name.substring(lastDot); + } else if (file.type) { + extension = '.' + file.type.split('/')[1].replace('jpeg', 'jpg'); + } + + const fileName = file.name || `file-${Date.now()}${extension}`; + + await dotNetHelper.invokeMethodAsync('OnFilePasted', fileName, extension, base64); + } catch { + // Silent fail + } } }; diff --git a/src/MaIN.InferPage/wwwroot/home.css b/src/MaIN.InferPage/wwwroot/home.css index 5892b0c9..bc055c54 100644 --- a/src/MaIN.InferPage/wwwroot/home.css +++ b/src/MaIN.InferPage/wwwroot/home.css @@ -13,6 +13,42 @@ body { flex-grow: 1; gap: 4px; padding-top: 4px; + position: relative; +} + +/* Drop overlay */ +.drop-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 204, 163, 0.15); + border: 3px dashed var(--accent-base-color); + border-radius: 8px; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + backdrop-filter: blur(2px); +} + +.drop-overlay-content { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding: 24px 48px; + background: var(--neutral-layer-1); + border-radius: 12px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2); +} + +.drop-overlay-content span { + font-size: 18px; + font-weight: 500; + color: var(--accent-base-color); } .messages-container>div { From 6c0af424e7645d86746832a35cdeecf86cc6d53c Mon Sep 17 00:00:00 2001 From: Magdalena Majta Date: Tue, 24 Feb 2026 20:16:20 +0100 Subject: [PATCH 17/22] Add image attachment support and previews Add client-side support for image attachments: show inline thumbnails for selected images, history image previews, paste handling, dismiss buttons, and update input/send logic to include images alongside files. Introduce _selectedImages and ImageExtensions, update MessageExt to store AttachedImages, and ensure proper disposal of image streams. Add CSS for image-preview and history-image-preview styling. On the service side, route messages that include images through a SearchAsync + context-enhanced chat flow (streaming and non-streaming) and adjust token handling/return values accordingly. --- .../Components/Pages/Home.razor | 122 ++++++++++++++---- src/MaIN.InferPage/Utils.cs | 1 + src/MaIN.InferPage/wwwroot/editor.js | 24 ++-- src/MaIN.InferPage/wwwroot/home.css | 56 +++++++- .../LLMService/OpenAiCompatibleService.cs | 72 ++++++++++- 5 files changed, 231 insertions(+), 44 deletions(-) diff --git a/src/MaIN.InferPage/Components/Pages/Home.razor b/src/MaIN.InferPage/Components/Pages/Home.razor index 4b0b7ac7..b993ae9b 100644 --- a/src/MaIN.InferPage/Components/Pages/Home.razor +++ b/src/MaIN.InferPage/Components/Pages/Home.razor @@ -66,9 +66,15 @@ else { - @if (conversation.Message.Role == "User" && conversation.AttachedFiles.Any()) + @if (conversation.Message.Role == "User" && (conversation.AttachedFiles.Any() || conversation.AttachedImages.Any())) {
+ @foreach (var image in conversation.AttachedImages) + { +
+ @image.Name +
+ } @foreach (var fileName in conversation.AttachedFiles) { @@ -140,9 +146,18 @@
- @if (_selectedFiles.Any()) + @if (_selectedImages.Any() || _selectedFiles.Any()) { -
+
+ @foreach (var image in _selectedImages) + { +
+ @image.File.Name + + + +
+ } @foreach (var file in _selectedFiles) {
@@ -202,7 +217,6 @@ private string? _errorMessage; private string? _incomingMessage; private string? _incomingReasoning; - private readonly string? _displayName = Utils.Model; private IChatMessageBuilder? ctx; private CancellationTokenSource? _cancellationTokenSource; private Chat Chat { get; } = new() { Name = "MaIN Infer", ModelId = Utils.Model! }; @@ -211,8 +225,11 @@ private ElementReference _editorRef; private DotNetObjectReference? _dotNetRef; private List _selectedFiles = new(); + private List<(FileInfo File, string Base64Preview)> _selectedImages = new(); private int _inputKey; + private static readonly HashSet ImageExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"]; + private readonly MarkdownPipeline _markdownPipeline = new MarkdownPipelineBuilder() .UseAdvancedExtensions() .UseSoftlineBreakAsHardlineBreak() @@ -291,7 +308,7 @@ if (_isLoading) return; var msg = await JS.InvokeAsync("editorManager.getInnerText", _editorRef); - if (!string.IsNullOrWhiteSpace(msg) || _selectedFiles.Any()) + if (!string.IsNullOrWhiteSpace(msg) || _selectedFiles.Any() || _selectedImages.Any()) { await JS.InvokeVoidAsync("editorManager.clearContent", _editorRef); await SendAsync(msg?.Trim() ?? string.Empty); @@ -307,12 +324,25 @@ { await file.OpenReadStream(20 * 1024 * 1024).CopyToAsync(ms); ms.Position = 0; - _selectedFiles.Add(new FileInfo + + var extension = Path.GetExtension(file.Name).ToLowerInvariant(); + var fileInfo = new FileInfo { Name = file.Name, - Extension = Path.GetExtension(file.Name), + Extension = extension, StreamContent = ms - }); + }; + + if (ImageExtensions.Contains(extension)) + { + var base64 = Convert.ToBase64String(ms.ToArray()); + ms.Position = 0; + _selectedImages.Add((fileInfo, base64)); + } + else + { + _selectedFiles.Add(fileInfo); + } } catch (Exception ex) { @@ -329,17 +359,40 @@ _selectedFiles.Remove(file); } + private void RemoveImage((FileInfo File, string Base64Preview) image) + { + image.File.StreamContent?.Dispose(); + _selectedImages.Remove(image); + } + [JSInvokable] - public async Task OnFilePasted(string fileName, string extension, string base64Data) + public async Task OnFileReceived(string fileName, string extension, string base64Data) { - var bytes = Convert.FromBase64String(base64Data); - var ms = new MemoryStream(bytes); - _selectedFiles.Add(new FileInfo + try { - Name = fileName, - Extension = extension, - StreamContent = ms - }); + var bytes = Convert.FromBase64String(base64Data); + var ms = new MemoryStream(bytes); + var fileInfo = new FileInfo + { + Name = fileName, + Extension = extension, + StreamContent = ms + }; + + if (ImageExtensions.Contains(extension.ToLowerInvariant())) + { + _selectedImages.Add((fileInfo, base64Data)); + } + else + { + _selectedFiles.Add(fileInfo); + } + } + catch (Exception ex) + { + _errorMessage = $"Failed to load file {fileName}: {ex.Message}"; + } + _isDragging = false; await InvokeAsync(StateHasChanged); } @@ -365,7 +418,7 @@ private async Task SendAsync(string msg) { - if (string.IsNullOrWhiteSpace(msg) && !_selectedFiles.Any()) + if (string.IsNullOrWhiteSpace(msg) && !_selectedFiles.Any() && !_selectedImages.Any()) { return; } @@ -377,10 +430,12 @@ _isLoading = true; StateHasChanged(); - var attachments = new List(_selectedFiles); + var attachedFiles = new List(_selectedFiles); + var attachedImages = new List<(FileInfo File, string Base64Preview)>(_selectedImages); try { _selectedFiles.Clear(); + _selectedImages.Clear(); _inputKey++; StateHasChanged(); @@ -392,11 +447,11 @@ }; Chat.Messages.Add(newMsg); - var attachedFileNames = attachments.Select(f => f.Name).ToList(); Messages.Add(new MessageExt { Message = newMsg, - AttachedFiles = attachedFileNames + AttachedFiles = attachedFiles.Select(f => f.Name).ToList(), + AttachedImages = attachedImages.Select(i => (i.File.Name, i.Base64Preview)).ToList() }); Chat.ModelId = Utils.Model!; @@ -410,9 +465,14 @@ await JS.InvokeVoidAsync("scrollManager.scrollToBottomSmooth", "messages-container"); var request = ctx!.WithMessage(msg); - if (attachments.Count != 0) + + // Combine files and images - images go as files, ExtractImageFromFiles will handle them + var allFiles = new List(attachedFiles); + allFiles.AddRange(attachedImages.Select(i => i.File)); + + if (allFiles.Count != 0) { - request.WithFiles(attachments); + request.WithFiles(allFiles); } cancellationToken.ThrowIfCancellationRequested(); @@ -502,9 +562,13 @@ } finally { - foreach (var attachment in attachments) + foreach (var attachment in attachedFiles) attachment.StreamContent?.Dispose(); - attachments.Clear(); + attachedFiles.Clear(); + + foreach (var image in attachedImages) + image.File.StreamContent?.Dispose(); + attachedImages.Clear(); _isLoading = false; _isThinking = false; @@ -548,11 +612,19 @@ .Where(m => m.AttachedFiles.Any()) .ToDictionary(m => m.Message, m => m.AttachedFiles); + var existingImagesMap = Messages + .Where(m => m.AttachedImages.Any()) + .ToDictionary(m => m.Message, m => m.AttachedImages); + + var existingReasonMap = Messages + .ToDictionary(m => m.Message, m => m.ShowReason); + Messages = Chat.Messages.Select(x => new MessageExt { Message = x, AttachedFiles = existingFilesMap.TryGetValue(x, out var files) ? files : new List(), - ShowReason = false + AttachedImages = existingImagesMap.TryGetValue(x, out var images) ? images : new List<(string, string)>(), + ShowReason = existingReasonMap.TryGetValue(x, out var show) && show }).ToList(); } diff --git a/src/MaIN.InferPage/Utils.cs b/src/MaIN.InferPage/Utils.cs index a4aacf56..75181864 100644 --- a/src/MaIN.InferPage/Utils.cs +++ b/src/MaIN.InferPage/Utils.cs @@ -20,4 +20,5 @@ public class MessageExt public required Message Message { get; set; } public bool ShowReason { get; set; } public List AttachedFiles { get; set; } = new(); + public List<(string Name, string Base64)> AttachedImages { get; set; } = new(); } diff --git a/src/MaIN.InferPage/wwwroot/editor.js b/src/MaIN.InferPage/wwwroot/editor.js index db18024f..ede3e3b6 100644 --- a/src/MaIN.InferPage/wwwroot/editor.js +++ b/src/MaIN.InferPage/wwwroot/editor.js @@ -81,19 +81,19 @@ window.editorManager = { for (const file of files) { await editorManager._processFile(file, dotNetHelper); } + + // Ensure overlay is always dismissed after drop + try { await dotNetHelper.invokeMethodAsync('OnDragLeave'); } catch {} }); }, _processFile: async (file, dotNetHelper) => { try { - const arrayBuffer = await file.arrayBuffer(); - const uint8Array = new Uint8Array(arrayBuffer); - - // Convert to base64 - much smaller than int array - let binary = ''; - for (let i = 0; i < uint8Array.length; i++) { - binary += String.fromCharCode(uint8Array[i]); - } - const base64 = btoa(binary); + const base64 = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result.split(',')[1]); + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(file); + }); let extension = ''; const lastDot = file.name.lastIndexOf('.'); @@ -105,9 +105,9 @@ window.editorManager = { const fileName = file.name || `file-${Date.now()}${extension}`; - await dotNetHelper.invokeMethodAsync('OnFilePasted', fileName, extension, base64); - } catch { - // Silent fail + await dotNetHelper.invokeMethodAsync('OnFileReceived', fileName, extension, base64); + } catch (err) { + try { await dotNetHelper.invokeMethodAsync('OnDragLeave'); } catch {} } } }; diff --git a/src/MaIN.InferPage/wwwroot/home.css b/src/MaIN.InferPage/wwwroot/home.css index bc055c54..c08edf22 100644 --- a/src/MaIN.InferPage/wwwroot/home.css +++ b/src/MaIN.InferPage/wwwroot/home.css @@ -120,13 +120,49 @@ body { flex-direction: column; } -.selected-files-container { +.selected-attachments-container { display: flex; flex-wrap: wrap; gap: 8px; padding: 4px 12px; margin-bottom: 2px; width: 100%; + align-items: flex-end; +} + +/* Image preview thumbnail */ +.image-preview { + position: relative; + width: 48px; + height: 48px; + border-radius: 6px; + overflow: hidden; + border: 1px solid var(--neutral-stroke-rest); + background-color: var(--neutral-layer-1); +} + +.image-preview img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.image-dismiss-button { + position: absolute; + top: 2px; + right: 2px; + width: 16px; + height: 16px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + opacity: 0.6; +} + +.image-dismiss-button:hover { + opacity: 1; } .file-badge { @@ -290,6 +326,24 @@ body { margin-bottom: 10px; padding-bottom: 8px; border-bottom: 1px solid var(--neutral-stroke-rest); + align-items: center; +} + +/* History image preview (in sent messages) */ +.history-image-preview { + width: 40px; + height: 40px; + border-radius: 4px; + overflow: hidden; + border: 1px solid var(--neutral-stroke-rest); + background-color: var(--neutral-layer-1); + flex-shrink: 0; +} + +.history-image-preview img { + width: 100%; + height: 100%; + object-fit: cover; } .attached-file-tag { diff --git a/src/MaIN.Services/Services/LLMService/OpenAiCompatibleService.cs b/src/MaIN.Services/Services/LLMService/OpenAiCompatibleService.cs index 241db3d5..355e8391 100644 --- a/src/MaIN.Services/Services/LLMService/OpenAiCompatibleService.cs +++ b/src/MaIN.Services/Services/LLMService/OpenAiCompatibleService.cs @@ -444,19 +444,79 @@ await _notificationService.DispatchNotification( return null; var kernel = memoryFactory.CreateMemoryWithOpenAi(GetApiKey(), chat.MemoryParams); - await memoryService.ImportDataToMemory((kernel, null), memoryOptions, cancellationToken); - var userQuery = chat.Messages.Last().Content; + var lastMessage = chat.Messages.Last(); + var userQuery = lastMessage.Content; + if (chat.MemoryParams.Grammar != null) { var jsonGrammarConverter = new GrammarToJsonConverter(); var jsonGrammar = jsonGrammarConverter.ConvertToJson(chat.MemoryParams.Grammar); userQuery = $"{userQuery} | Respond only using the following JSON format: \n{jsonGrammar}\n. Do not add explanations, code tags, or any extra content."; } - + + // If there are images, use SearchAsync + regular chat with images + if (HasImages(lastMessage)) + { + var searchResult = await kernel.SearchAsync(userQuery, cancellationToken: cancellationToken); + await kernel.DeleteIndexAsync(cancellationToken: cancellationToken); + + // Build context from search results + var contextBuilder = new StringBuilder(); + foreach (var citation in searchResult.Results.SelectMany(r => r.Partitions)) + { + contextBuilder.AppendLine(citation.Text); + } + + // Create a temporary message with context + original query + var contextEnhancedContent = string.IsNullOrEmpty(contextBuilder.ToString()) + ? userQuery + : $"Use the following context to answer the question:\n\n{contextBuilder}\n\nQuestion: {userQuery}"; + + // Create conversation with context-enhanced message + var conversation = new List + { + new(ServiceConstants.Roles.User, contextEnhancedContent) + { + OriginalMessage = new Message + { + Role = "User", + Content = contextEnhancedContent, + Type = MessageType.CloudLLM, + Images = lastMessage.Images + } + } + }; + + var tokens = new List(); + var resultBuilder = new StringBuilder(); + + if (requestOptions.InteractiveUpdates || requestOptions.TokenCallback != null) + { + await ProcessStreamingChatAsync(chat, conversation, GetApiKey(), tokens, resultBuilder, requestOptions, cancellationToken); + } + else + { + await ProcessNonStreamingChatAsync(chat, conversation, GetApiKey(), resultBuilder, requestOptions, cancellationToken); + } + + var finalToken = new LLMTokenValue { Text = resultBuilder.ToString(), Type = TokenType.FullAnswer }; + tokens.Add(finalToken); + + if (requestOptions.InteractiveUpdates) + { + await _notificationService.DispatchNotification( + NotificationMessageBuilder.CreateChatCompletion(chat.Id, finalToken, true), + ServiceConstants.Notifications.ReceiveMessageUpdate); + } + + return CreateChatResult(chat, resultBuilder.ToString(), tokens); + } + + // No images - use standard AskAsync flow MemoryAnswer retrievedContext; - var tokens = new List(); + var standardTokens = new List(); if (requestOptions.InteractiveUpdates || requestOptions.TokenCallback != null) { @@ -482,7 +542,7 @@ await _notificationService.DispatchNotification( Type = TokenType.Message }; - tokens.Add(tokenValue); + standardTokens.Add(tokenValue); if (requestOptions.InteractiveUpdates) { @@ -516,7 +576,7 @@ await notificationService.DispatchNotification( } await kernel.DeleteIndexAsync(cancellationToken: cancellationToken); - return CreateChatResult(chat, retrievedContext.Result, tokens); + return CreateChatResult(chat, retrievedContext.Result, standardTokens); } public virtual async Task GetCurrentModels() From 07034d12d3ff72c87969eac1d2f7d582b714b084 Mon Sep 17 00:00:00 2001 From: Magdalena Majta Date: Thu, 26 Feb 2026 14:42:48 +0100 Subject: [PATCH 18/22] Add image-generation support and UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce image-generation capability across the app: add IImageGenerationModel and HasImageGeneration on AIModel; mark cloud models (DALL·E3, new gpt-image-1 and grok-2-image) as image generators. Update UI to render generated images with download and copy-to-clipboard actions (Home.razor changes, CopyImageToClipboard interop + editor.js). Improve visual/model detection in Utils to use ModelRegistry with a fallback set of known image-generation IDs. Increase SignalR hub max message size to 10MB (Program.cs) to allow larger image transfers and add CSS for generated image layout and controls. --- src/MaIN.Domain/Models/Abstract/AIModel.cs | 3 + .../Models/Abstract/IModelCapabilities.cs | 5 + .../Models/Concrete/CloudModels.cs | 16 ++- .../Components/Layout/NavBar.razor | 9 ++ .../Components/Pages/Home.razor | 112 ++++++++++-------- src/MaIN.InferPage/Program.cs | 6 +- src/MaIN.InferPage/Utils.cs | 24 +++- src/MaIN.InferPage/wwwroot/editor.js | 5 + src/MaIN.InferPage/wwwroot/home.css | 55 +++++++++ 9 files changed, 178 insertions(+), 57 deletions(-) diff --git a/src/MaIN.Domain/Models/Abstract/AIModel.cs b/src/MaIN.Domain/Models/Abstract/AIModel.cs index 610fc8e8..38296866 100644 --- a/src/MaIN.Domain/Models/Abstract/AIModel.cs +++ b/src/MaIN.Domain/Models/Abstract/AIModel.cs @@ -34,6 +34,9 @@ public abstract record AIModel( /// Checks if model supports vision/image input. public bool HasVision => this is IVisionModel; + + /// Checks if model generates images from text prompts. + public bool HasImageGeneration => this is IImageGenerationModel; } /// Base class for local models. diff --git a/src/MaIN.Domain/Models/Abstract/IModelCapabilities.cs b/src/MaIN.Domain/Models/Abstract/IModelCapabilities.cs index bb703b45..b35cb5dc 100644 --- a/src/MaIN.Domain/Models/Abstract/IModelCapabilities.cs +++ b/src/MaIN.Domain/Models/Abstract/IModelCapabilities.cs @@ -45,3 +45,8 @@ public interface IEmbeddingModel /// Interface for models that support text-to-speech. /// public interface ITTSModel; + +/// +/// Interface for models that generate images from text prompts. +/// +public interface IImageGenerationModel; diff --git a/src/MaIN.Domain/Models/Concrete/CloudModels.cs b/src/MaIN.Domain/Models/Concrete/CloudModels.cs index e03b57aa..eaf0ff8e 100644 --- a/src/MaIN.Domain/Models/Concrete/CloudModels.cs +++ b/src/MaIN.Domain/Models/Concrete/CloudModels.cs @@ -31,7 +31,14 @@ public sealed record DallE3() : CloudModel( BackendType.OpenAi, "DALL-E 3", 4000, - "Advanced image generation model from OpenAI"); + "Advanced image generation model from OpenAI"), IImageGenerationModel; + +public sealed record GptImage1() : CloudModel( + "gpt-image-1", + BackendType.OpenAi, + "GPT Image 1", + 4000, + "OpenAI's latest image generation model"), IImageGenerationModel; // ===== Anthropic Models ===== @@ -74,6 +81,13 @@ public sealed record Grok3Beta() : CloudModel( ModelDefaults.DefaultMaxContextWindow, "xAI latest Grok model in beta testing phase"); +public sealed record GrokImage() : CloudModel( + "grok-2-image", + BackendType.Xai, + "Grok 2 Image", + 4000, + "xAI image generation model"), IImageGenerationModel; + // ===== GroqCloud Models ===== public sealed record Llama3_1_8bInstant() : CloudModel( diff --git a/src/MaIN.InferPage/Components/Layout/NavBar.razor b/src/MaIN.InferPage/Components/Layout/NavBar.razor index 778b8c58..c64a16eb 100644 --- a/src/MaIN.InferPage/Components/Layout/NavBar.razor +++ b/src/MaIN.InferPage/Components/Layout/NavBar.razor @@ -29,6 +29,15 @@ Color="#000" Style="margin-left: 10px">Reasoning ✨ } + @if (Utils.Visual) + { + Visual 🎨 + }
- @(conversation.Message.Role == "User" ? "User" : Utils.Model) - + @if (conversation.Message.Role == "User") { - - @conversation.Message.Content - - } - else - { - -
- - imageResponse - -
-
- } - } - else - { - - @if (conversation.Message.Role == "User" && (conversation.AttachedFiles.Any() || conversation.AttachedImages.Any())) + @if (conversation.AttachedFiles.Any() || conversation.AttachedImages.Any()) {
@foreach (var image in conversation.AttachedImages) @@ -84,10 +57,36 @@ }
} - @if (conversation.Message.Role == "User") +
+ @((MarkupString)Markdown.ToHtml(conversation.Message.Content ?? string.Empty, _markdownPipeline)) +
+ } + else + { + @if (conversation.Message.Images?.Any() == true) { -
- @((MarkupString)Markdown.ToHtml(conversation.Message.Content ?? string.Empty, _markdownPipeline)) +
+ @foreach (var imageBytes in conversation.Message.Images) + { + var b64 = Convert.ToBase64String(imageBytes); +
+ + generated image + +
+ + + + + + +
+
+ }
} else @@ -114,33 +113,30 @@ @((MarkupString)Markdown.ToHtml(GetMessageContent(conversation.Message), _markdownPipeline))
} -
- } + } +
} } @if (_isLoading) { - @if (Chat.Visual) + @if (Utils.Visual) { This might take a while... } - else + else if (_incomingMessage != null || _incomingReasoning != null) { - @if (_incomingMessage != null || _incomingReasoning != null) - { - - @if (_isThinking) - { - - @((MarkupString)Markdown.ToHtml(_incomingReasoning ?? string.Empty, _markdownPipeline)) - - } - else - { - @((MarkupString)Markdown.ToHtml(_incomingMessage ?? string.Empty, _markdownPipeline)) - } - - } + + @if (_isThinking) + { + + @((MarkupString)Markdown.ToHtml(_incomingReasoning ?? string.Empty, _markdownPipeline)) + + } + else + { + @((MarkupString)Markdown.ToHtml(_incomingMessage ?? string.Empty, _markdownPipeline)) + } + } }
@@ -277,7 +273,7 @@ } ctx = Utils.Visual - ? AIHub.Chat().EnableVisual() + ? AIHub.Chat().WithModel(model).EnableVisual() : AIHub.Chat().WithModel(model); } catch (MaINCustomException ex) @@ -628,6 +624,18 @@ }).ToList(); } + private async Task CopyImageToClipboard(string base64) + { + try + { + await JS.InvokeVoidAsync("editorManager.copyImageToClipboard", base64); + } + catch + { + // silently ignore — clipboard API may not be available in all browsers + } + } + public void Dispose() { _cancellationTokenSource?.Dispose(); diff --git a/src/MaIN.InferPage/Program.cs b/src/MaIN.InferPage/Program.cs index ebc54449..581e0b7b 100644 --- a/src/MaIN.InferPage/Program.cs +++ b/src/MaIN.InferPage/Program.cs @@ -8,7 +8,11 @@ var builder = WebApplication.CreateBuilder(args); builder.Services.AddRazorComponents() - .AddInteractiveServerComponents(); + .AddInteractiveServerComponents() + .AddHubOptions(options => + { + options.MaximumReceiveMessageSize = 10 * 1024 * 1024; // 10 MB + }); builder.Services.AddFluentUIComponents(); try diff --git a/src/MaIN.InferPage/Utils.cs b/src/MaIN.InferPage/Utils.cs index 75181864..7782b764 100644 --- a/src/MaIN.InferPage/Utils.cs +++ b/src/MaIN.InferPage/Utils.cs @@ -1,5 +1,6 @@ using MaIN.Domain.Configuration; using MaIN.Domain.Entities; +using MaIN.Domain.Models.Abstract; namespace MaIN.InferPage; @@ -7,12 +8,29 @@ public static class Utils { public static BackendType BackendType { get; set; } = BackendType.Self; public static bool HasApiKey { get; set; } + public static string? Path { get; set; } public static bool IsLocal => BackendType == BackendType.Self || (BackendType == BackendType.Ollama && !HasApiKey); public static string? Model = "gemma3-4b"; public static bool Reason { get; set; } - public static bool Visual => VisualModels.Contains(Model); - private static readonly string[] VisualModels = ["FLUX.1_Shnell", "FLUX.1", "dall-e-3", "dall-e", "imagen", "imagen-3"]; //user might type different names - public static string? Path { get; set; } + public static bool Visual + { + get + { + if (string.IsNullOrEmpty(Model)) return false; + if (ModelRegistry.TryGetById(Model, out var m)) + return m is IImageGenerationModel; + return ImageGenerationModels.Contains(Model); // fallback for unregistered models (e.g. FLUX via separate server) + } + } + + private static readonly HashSet ImageGenerationModels = + [ + "FLUX.1_Shnell", "FLUX.1", + "dall-e-3", "dall-e", + "gpt-image-1", + "imagen", "imagen-3", "imagen-4", "imagen-4-fast", + "grok-2-image" + ]; } public class MessageExt diff --git a/src/MaIN.InferPage/wwwroot/editor.js b/src/MaIN.InferPage/wwwroot/editor.js index ede3e3b6..485afa0c 100644 --- a/src/MaIN.InferPage/wwwroot/editor.js +++ b/src/MaIN.InferPage/wwwroot/editor.js @@ -109,5 +109,10 @@ window.editorManager = { } catch (err) { try { await dotNetHelper.invokeMethodAsync('OnDragLeave'); } catch {} } + }, + copyImageToClipboard: async (base64) => { + const res = await fetch(`data:image/png;base64,${base64}`); + const blob = await res.blob(); + await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]); } }; diff --git a/src/MaIN.InferPage/wwwroot/home.css b/src/MaIN.InferPage/wwwroot/home.css index c08edf22..f9df84df 100644 --- a/src/MaIN.InferPage/wwwroot/home.css +++ b/src/MaIN.InferPage/wwwroot/home.css @@ -406,3 +406,58 @@ body { 30%, 50%, 70% { transform: translate3d(-4px, 0, 0); } 40%, 60% { transform: translate3d(4px, 0, 0); } } + +/* Generated image display */ +.generated-images { + display: flex; + flex-direction: column; + gap: 12px; + width: 100%; +} + +.image-wrapper { + position: relative; + width: 100%; +} + +/* Fix: is inline by default — make it block so width: 100% on works correctly */ +.image-wrapper > a { + display: block; + width: 100%; +} + +.generated-image { + width: 100%; + height: auto; + border-radius: 8px; + display: block; + cursor: zoom-in; +} + +/* Action buttons overlaid on bottom-left corner of the image */ +.image-actions { + display: flex; + gap: 6px; + position: absolute; + bottom: 10px; + left: 10px; +} + +.image-action-btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 8px; + border-radius: 8px; + opacity: 0.85; + transition: opacity 0.15s, background-color 0.15s; + color: var(--accent-base-color); + text-decoration: none; + cursor: pointer; + background-color: rgba(0, 0, 0, 0.4); +} + +.image-action-btn:hover { + opacity: 1; + background-color: rgba(0, 0, 0, 0.6); +} From 5688c30d3d1922be68f0487bf11ae24d4ebc5b3d Mon Sep 17 00:00:00 2001 From: Magdalena Majta Date: Fri, 27 Feb 2026 01:29:56 +0100 Subject: [PATCH 19/22] Replace Visual flag with ImageGen and add vision support Rename the old "Visual" concept to a clearer "ImageGen" across the codebase and add vision detection/flags. Key changes: - Domain & storage: Chat.Visual -> Chat.ImageGen, ChatDocument.ImageGen, DTO and DB mappings updated (SQL/SQLite repos). - API: removed EnableVisual(); added WithModel(AIModel model, bool? imageGen = null) to allow explicit imageGen override (defaults to model capability). - Interfaces: removed EnableVisual from builder interfaces. - Services/handlers: ChatService, AgentService, StartCommandHandler, AnswerCommandHandler and step handlers updated to use ImageGen logic when routing to image-gen or LLM services; TTS gating now checks ImageGen. - Mappers: ChatMapper and DTO mappings updated to use ImageGen. - UI: InferPage and NavBar updated to show Image Gen and Vision badges; Home.razor now computes message content/reasoning via MessageExt.ComputedContent/ComputedReasoning; MessageExt gains HasReasoning and computed fields. - Utils: Reason is now computed from registered model capabilities and ImageGen is mutually exclusive with reasoning; added Vision detection and model lists. - Examples & tests: updated to call WithModel(..., imageGen: true) or model-based API accordingly. Why: clarifies semantics between image generation and visual/vision capabilities, centralizes model-driven behavior, and enables explicit overrides for image generation behavior. --- .../Examples/Chat/ChatWithImageGenExample.cs | 3 +- .../Chat/ChatWithImageGenGeminiExample.cs | 4 +- .../Chat/ChatWithImageGenOpenAiExample.cs | 1 - MaIN.Core.IntegrationTests/ChatTests.cs | 3 +- src/MaIN.Core/Hub/Contexts/ChatContext.cs | 15 ++------ .../ChatContext/IChatBuilderEntryPoint.cs | 15 ++------ .../ChatContext/IChatMessageBuilder.cs | 7 ---- src/MaIN.Domain/Entities/Chat.cs | 2 +- .../Components/Layout/NavBar.razor | 13 ++++++- .../Components/Pages/Home.razor | 23 +++++------ src/MaIN.InferPage/Utils.cs | 38 ++++++++++++++++++- .../Models/ChatDocument.cs | 2 +- .../Repositories/Sql/SqlChatRepository.cs | 4 +- .../Sqlite/SqliteChatRepository.cs | 4 +- src/MaIN.Services/Dtos/ChatDto.cs | 4 +- src/MaIN.Services/Mappers/ChatMapper.cs | 8 ++-- src/MaIN.Services/Services/AgentService.cs | 2 +- src/MaIN.Services/Services/ChatService.cs | 6 +-- .../Steps/Commands/AnswerCommandHandler.cs | 2 +- .../Steps/Commands/StartCommandHandler.cs | 2 +- 20 files changed, 90 insertions(+), 68 deletions(-) diff --git a/Examples/Examples/Chat/ChatWithImageGenExample.cs b/Examples/Examples/Chat/ChatWithImageGenExample.cs index 6467af9b..d8923d42 100644 --- a/Examples/Examples/Chat/ChatWithImageGenExample.cs +++ b/Examples/Examples/Chat/ChatWithImageGenExample.cs @@ -1,5 +1,6 @@ using Examples.Utils; using MaIN.Core.Hub; +using MaIN.Domain.Models.Abstract; namespace Examples.Chat; @@ -10,7 +11,7 @@ public async Task Start() Console.WriteLine("ChatExample with image gen is running!"); var result = await AIHub.Chat() - .EnableVisual() + .WithModel(new GenericLocalModel("FLUX.1_Shnell"), imageGen: true) .WithMessage("Generate cyberpunk godzilla cat warrior") .CompleteAsync(); diff --git a/Examples/Examples/Chat/ChatWithImageGenGeminiExample.cs b/Examples/Examples/Chat/ChatWithImageGenGeminiExample.cs index db38f548..c037d2dc 100644 --- a/Examples/Examples/Chat/ChatWithImageGenGeminiExample.cs +++ b/Examples/Examples/Chat/ChatWithImageGenGeminiExample.cs @@ -1,5 +1,7 @@ using Examples.Utils; using MaIN.Core.Hub; +using MaIN.Domain.Configuration; +using MaIN.Domain.Models.Abstract; namespace Examples.Chat; @@ -11,7 +13,7 @@ public async Task Start() GeminiExample.Setup(); // We need to provide Gemini API key var result = await AIHub.Chat() - .EnableVisual() + .WithModel(new GenericCloudModel("imagen-3", BackendType.Gemini), imageGen: true) .WithMessage("Generate hamster as a astronaut on the moon") .CompleteAsync(); diff --git a/Examples/Examples/Chat/ChatWithImageGenOpenAiExample.cs b/Examples/Examples/Chat/ChatWithImageGenOpenAiExample.cs index d7460c6c..c7ecba3e 100644 --- a/Examples/Examples/Chat/ChatWithImageGenOpenAiExample.cs +++ b/Examples/Examples/Chat/ChatWithImageGenOpenAiExample.cs @@ -13,7 +13,6 @@ public async Task Start() var result = await AIHub.Chat() .WithModel() - .EnableVisual() .WithMessage("Generate rock style cow playing guitar") .CompleteAsync(); diff --git a/MaIN.Core.IntegrationTests/ChatTests.cs b/MaIN.Core.IntegrationTests/ChatTests.cs index e43a5f00..a823c1c0 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.Abstract; using MaIN.Domain.Models.Concrete; namespace MaIN.Core.IntegrationTests; @@ -88,7 +89,7 @@ public async Task Should_GenerateImage_BasedOnPrompt() const string extension = "png"; var result = await AIHub.Chat() - .EnableVisual() + .WithModel(new GenericLocalModel("FLUX.1_Shnell"), imageGen: true) .WithMessage("Generate cat in Rome. Sightseeing, colloseum, ancient builidngs, Italy.") .CompleteAsync(); diff --git a/src/MaIN.Core/Hub/Contexts/ChatContext.cs b/src/MaIN.Core/Hub/Contexts/ChatContext.cs index e62e2e35..4d8a07ef 100644 --- a/src/MaIN.Core/Hub/Contexts/ChatContext.cs +++ b/src/MaIN.Core/Hub/Contexts/ChatContext.cs @@ -39,9 +39,10 @@ internal ChatContext(IChatService chatService, Chat existingChat) _chat = existingChat; } - public IChatMessageBuilder WithModel(AIModel model) + public IChatMessageBuilder WithModel(AIModel model, bool? imageGen = null) { SetModel(model); + _chat.ImageGen = imageGen ?? model is IImageGenerationModel; return this; } @@ -81,14 +82,9 @@ private void SetModel(AIModel model) _chat.ModelId = model.Id; _chat.ModelInstance = model; _chat.Backend = model.Backend; + _chat.ImageGen = model.HasImageGeneration; } - public IChatMessageBuilder EnableVisual() - { - _chat.Visual = true; - return this; - } - public IChatConfigurationBuilder WithInferenceParams(InferenceParams inferenceParams) { _chat.InterferenceParams = inferenceParams; @@ -109,7 +105,7 @@ public IChatConfigurationBuilder WithMemoryParams(MemoryParams memoryParams) public IChatConfigurationBuilder Speak(TextToSpeechParams speechParams) { - _chat.Visual = false; + _chat.ImageGen = false; _chat.TextToSpeechParams = speechParams; return this; } @@ -246,9 +242,6 @@ private async Task ChatExists(string id) } } - IChatMessageBuilder IChatMessageBuilder.EnableVisual() => EnableVisual(); - - public string GetChatId() => _chat.Id; public async Task GetCurrentChat() diff --git a/src/MaIN.Core/Hub/Contexts/Interfaces/ChatContext/IChatBuilderEntryPoint.cs b/src/MaIN.Core/Hub/Contexts/Interfaces/ChatContext/IChatBuilderEntryPoint.cs index 86bfcff6..c3abc5f5 100644 --- a/src/MaIN.Core/Hub/Contexts/Interfaces/ChatContext/IChatBuilderEntryPoint.cs +++ b/src/MaIN.Core/Hub/Contexts/Interfaces/ChatContext/IChatBuilderEntryPoint.cs @@ -1,4 +1,4 @@ -namespace MaIN.Core.Hub.Contexts.Interfaces.ChatContext; +namespace MaIN.Core.Hub.Contexts.Interfaces.ChatContext; public interface IChatBuilderEntryPoint : IChatActions { @@ -9,7 +9,7 @@ public interface IChatBuilderEntryPoint : IChatActions /// The name of the AI model to be used. /// The context instance implementing for method chaining. IChatMessageBuilder WithModel(string model); - + /// /// Configures a custom model with a specific path and project context. /// @@ -18,18 +18,11 @@ public interface IChatBuilderEntryPoint : IChatActions /// Optional multi-modal project identifier. /// The context instance implementing for method chaining. IChatMessageBuilder WithCustomModel(string model, string path, string? mmProject = null); - - /// - /// Enables visual/image generation mode. Use this method now if you do not plan to explicitly define the model. - /// Otherwise, you will be able to use this method in the next step, after defining the model. - /// - /// The context instance implementing for method chaining. - IChatMessageBuilder EnableVisual(); - + /// /// Loads an existing chat session from the database using its unique identifier. /// /// The GUID of the existing chat. /// The context instance implementing for method chaining. Task FromExisting(string chatId); -} \ No newline at end of file +} diff --git a/src/MaIN.Core/Hub/Contexts/Interfaces/ChatContext/IChatMessageBuilder.cs b/src/MaIN.Core/Hub/Contexts/Interfaces/ChatContext/IChatMessageBuilder.cs index 4896169a..5be40af8 100644 --- a/src/MaIN.Core/Hub/Contexts/Interfaces/ChatContext/IChatMessageBuilder.cs +++ b/src/MaIN.Core/Hub/Contexts/Interfaces/ChatContext/IChatMessageBuilder.cs @@ -4,13 +4,6 @@ namespace MaIN.Core.Hub.Contexts.Interfaces.ChatContext; public interface IChatMessageBuilder : IChatActions { - /// - /// Enables the visual output for the current chat session. This flag allows the AI to generate and return visual content, - /// such as images or charts, as part of its response. - /// - /// The context instance implementing for method chaining. - IChatMessageBuilder EnableVisual(); - /// /// Adds a user message to the chat. This method captures the message content and assigns the "User" role to it. /// It also timestamps the message for proper ordering. diff --git a/src/MaIN.Domain/Entities/Chat.cs b/src/MaIN.Domain/Entities/Chat.cs index 60a23314..2f905775 100644 --- a/src/MaIN.Domain/Entities/Chat.cs +++ b/src/MaIN.Domain/Entities/Chat.cs @@ -37,7 +37,7 @@ public AIModel? ModelInstance } public List Messages { get; set; } = []; public ChatType Type { get; set; } = ChatType.Conversation; - public bool Visual { get; set; } + public bool ImageGen { get; set; } public InferenceParams InterferenceParams { get; set; } = new(); public MemoryParams MemoryParams { get; set; } = new(); public ToolsConfiguration? ToolsConfiguration { get; set; } diff --git a/src/MaIN.InferPage/Components/Layout/NavBar.razor b/src/MaIN.InferPage/Components/Layout/NavBar.razor index c64a16eb..752cfe74 100644 --- a/src/MaIN.InferPage/Components/Layout/NavBar.razor +++ b/src/MaIN.InferPage/Components/Layout/NavBar.razor @@ -29,14 +29,23 @@ Color="#000" Style="margin-left: 10px">Reasoning ✨ } - @if (Utils.Visual) + @if (Utils.ImageGen) { Visual 🎨 + Style="margin-left: 10px">Image Gen 🎨 + } + @if (Utils.Vision) + { + Vision 👁️ }
- @if (_reasoning && conversation.Message.Role == "Assistant") + @if (conversation.HasReasoning) {
- @((MarkupString)Markdown.ToHtml(GetReasoningContent(conversation.Message), _markdownPipeline)) + @((MarkupString)Markdown.ToHtml(conversation.ComputedReasoning, _markdownPipeline))
}

} - @((MarkupString)Markdown.ToHtml(GetMessageContent(conversation.Message), _markdownPipeline)) + @((MarkupString)Markdown.ToHtml(conversation.ComputedContent, _markdownPipeline))
} } @@ -119,7 +119,7 @@ } @if (_isLoading) { - @if (Utils.Visual) + @if (Utils.ImageGen) { This might take a while... } @@ -207,7 +207,6 @@ private bool _isLoading; private bool _isThinking; private bool _isDragging; - private bool _reasoning; private bool _preserveScroll; private string _accentColor = "#00cca3"; private string? _errorMessage; @@ -272,9 +271,7 @@ : new GenericCloudModel(Id: Utils.Model!, Backend: Utils.BackendType); } - ctx = Utils.Visual - ? AIHub.Chat().WithModel(model).EnableVisual() - : AIHub.Chat().WithModel(model); + ctx = AIHub.Chat().WithModel(model, imageGen: Utils.ImageGen); } catch (MaINCustomException ex) { @@ -285,9 +282,6 @@ _errorMessage = ex.Message; } - _reasoning = !Utils.Visual && model?.HasReasoning == true; - Utils.Reason = _reasoning; - return base.OnInitializedAsync(); } @@ -451,7 +445,7 @@ }); Chat.ModelId = Utils.Model!; - Chat.Visual = Utils.Visual; + Chat.ImageGen = Utils.ImageGen; bool wasAtBottom = await JS.InvokeAsync("scrollManager.isAtBottom", "messages-container"); @@ -620,7 +614,10 @@ Message = x, AttachedFiles = existingFilesMap.TryGetValue(x, out var files) ? files : new List(), AttachedImages = existingImagesMap.TryGetValue(x, out var images) ? images : new List<(string, string)>(), - ShowReason = existingReasonMap.TryGetValue(x, out var show) && show + ShowReason = existingReasonMap.TryGetValue(x, out var show) && show, + HasReasoning = x.Tokens.Any(t => t.Type == TokenType.Reason), + ComputedContent = GetMessageContent(x), + ComputedReasoning = GetReasoningContent(x) }).ToList(); } diff --git a/src/MaIN.InferPage/Utils.cs b/src/MaIN.InferPage/Utils.cs index 7782b764..a4fc2872 100644 --- a/src/MaIN.InferPage/Utils.cs +++ b/src/MaIN.InferPage/Utils.cs @@ -11,8 +11,18 @@ public static class Utils public static string? Path { get; set; } public static bool IsLocal => BackendType == BackendType.Self || (BackendType == BackendType.Ollama && !HasApiKey); public static string? Model = "gemma3-4b"; - public static bool Reason { get; set; } - public static bool Visual + public static bool Reason + { + get + { + if (string.IsNullOrEmpty(Model)) return false; + if (ModelRegistry.TryGetById(Model, out var m)) + return m is IReasoningModel && !ImageGen; // reasoning and image gen are mutually exclusive + return false; + } + } + + public static bool ImageGen { get { @@ -23,6 +33,17 @@ public static bool Visual } } + public static bool Vision + { + get + { + if (string.IsNullOrEmpty(Model)) return false; + if (ModelRegistry.TryGetById(Model, out var m)) + return m is IVisionModel; + return VisionModels.Contains(Model); // fallback for unregistered models + } + } + private static readonly HashSet ImageGenerationModels = [ "FLUX.1_Shnell", "FLUX.1", @@ -31,12 +52,25 @@ public static bool Visual "imagen", "imagen-3", "imagen-4", "imagen-4-fast", "grok-2-image" ]; + + private static readonly HashSet VisionModels = + [ + "gpt-4o", "gpt-4o-mini", + "claude-3-opus", "claude-3-sonnet", "claude-3-haiku", + "claude-3-5-sonnet", "claude-3-5-haiku", "claude-3-7-sonnet", + "gemini-1.5-pro", "gemini-1.5-flash", "gemini-2.0-flash", "gemini-2.0-flash-lite", + "llava", "llava-1.6", "llava-phi3", + "gemma3", "gemma3-4b", "gemma3-12b", "gemma3-27b" + ]; } public class MessageExt { public required Message Message { get; set; } public bool ShowReason { get; set; } + public bool HasReasoning { get; set; } + public string ComputedContent { get; set; } = string.Empty; + public string ComputedReasoning { get; set; } = string.Empty; public List AttachedFiles { get; set; } = new(); public List<(string Name, string Base64)> AttachedImages { get; set; } = new(); } diff --git a/src/MaIN.Infrastructure/Models/ChatDocument.cs b/src/MaIN.Infrastructure/Models/ChatDocument.cs index b8543890..b7847355 100644 --- a/src/MaIN.Infrastructure/Models/ChatDocument.cs +++ b/src/MaIN.Infrastructure/Models/ChatDocument.cs @@ -14,7 +14,7 @@ public class ChatDocument public ChatTypeDocument Type { get; init; } public required Dictionary Properties { get; init; } = []; public BackendType? Backend { get; set; } - public bool Visual { get; init; } + public bool ImageGen { get; init; } public bool Interactive { get; init; } public bool Translate { get; init; } public InferenceParamsDocument? InferenceParams { get; init; } diff --git a/src/MaIN.Infrastructure/Repositories/Sql/SqlChatRepository.cs b/src/MaIN.Infrastructure/Repositories/Sql/SqlChatRepository.cs index c2b1a743..5b9fe47e 100644 --- a/src/MaIN.Infrastructure/Repositories/Sql/SqlChatRepository.cs +++ b/src/MaIN.Infrastructure/Repositories/Sql/SqlChatRepository.cs @@ -39,7 +39,7 @@ private ChatDocument MapChatDocument(dynamic row) Properties = row.Properties != null ? JsonSerializer.Deserialize>(row.Properties.ToString(), _jsonOptions) : new Dictionary(), - Visual = row.Visual, + ImageGen = row.Visual, Backend = (BackendType)row.BackendType, Interactive = row.Interactive }; @@ -62,7 +62,7 @@ private object MapChatToParameters(ChatDocument chat) InferenceParams = JsonSerializer.Serialize(chat.InferenceParams, _jsonOptions), MemoryParams = JsonSerializer.Serialize(chat.MemoryParams, _jsonOptions), Properties = JsonSerializer.Serialize(chat.Properties, _jsonOptions), - chat.Visual, + Visual = chat.ImageGen, BackendType = chat.Backend ?? 0, chat.Interactive }; diff --git a/src/MaIN.Infrastructure/Repositories/Sqlite/SqliteChatRepository.cs b/src/MaIN.Infrastructure/Repositories/Sqlite/SqliteChatRepository.cs index 0fbdfdfa..a09dd7ec 100644 --- a/src/MaIN.Infrastructure/Repositories/Sqlite/SqliteChatRepository.cs +++ b/src/MaIN.Infrastructure/Repositories/Sqlite/SqliteChatRepository.cs @@ -39,7 +39,7 @@ private ChatDocument MapChatDocument(dynamic row) Properties = row.Properties != null ? JsonSerializer.Deserialize>(row.Properties, _jsonOptions) : new Dictionary(), - Visual = Convert.ToBoolean(row.Visual), + ImageGen = Convert.ToBoolean(row.Visual), Backend = row.BackendType, Interactive = Convert.ToBoolean(row.Interactive) }; @@ -59,7 +59,7 @@ private object MapChatToParameters(ChatDocument chat) MemoryParams = JsonSerializer.Serialize(chat.MemoryParams, _jsonOptions), Type = JsonSerializer.Serialize(chat.Type, _jsonOptions), Properties = JsonSerializer.Serialize(chat.Properties, _jsonOptions), - Visual = chat.Visual ? 1 : 0, + Visual = chat.ImageGen ? 1 : 0, BackendType = chat.Backend ?? 0, Interactive = chat.Interactive ? 1 : 0 }; diff --git a/src/MaIN.Services/Dtos/ChatDto.cs b/src/MaIN.Services/Dtos/ChatDto.cs index e2bee191..92b0821c 100644 --- a/src/MaIN.Services/Dtos/ChatDto.cs +++ b/src/MaIN.Services/Dtos/ChatDto.cs @@ -20,6 +20,6 @@ public record ChatDto [JsonPropertyName("properties")] public Dictionary Properties { get; init; } = []; - [JsonPropertyName("visual")] - public bool Visual { get; set; } + [JsonPropertyName("imageGen")] + public bool ImageGen { get; set; } } \ No newline at end of file diff --git a/src/MaIN.Services/Mappers/ChatMapper.cs b/src/MaIN.Services/Mappers/ChatMapper.cs index ff9775eb..1bdf0398 100644 --- a/src/MaIN.Services/Mappers/ChatMapper.cs +++ b/src/MaIN.Services/Mappers/ChatMapper.cs @@ -17,7 +17,7 @@ public static ChatDto ToDto(this Chat chat) Name = chat.Name, Model = chat.ModelId, Messages = chat.Messages.Select(m => m.ToDto()).ToList(), - Visual = chat.Visual, + ImageGen = chat.ImageGen, Type = Enum.Parse(chat.Type.ToString()), Properties = chat.Properties }; @@ -46,7 +46,7 @@ public static Chat ToDomain(this ChatDto chat) Name = chat.Name!, ModelId = chat.Model!, Messages = chat.Messages?.Select(m => m.ToDomain()).ToList()!, - Visual = chat.Model == ImageGenService.LocalImageModels.FLUX, + ImageGen = chat.Model == ImageGenService.LocalImageModels.FLUX, Type = Enum.Parse(chat.Type.ToString()), Properties = chat.Properties }; @@ -89,7 +89,7 @@ public static ChatDocument ToDocument(this Chat chat) Name = chat.Name, Model = chat.ModelId, Messages = chat.Messages.Select(m => m.ToDocument()).ToList(), - Visual = chat.Visual, + ImageGen = chat.ImageGen, Backend = chat.Backend, ToolsConfiguration = chat.ToolsConfiguration, MemoryParams = chat.MemoryParams.ToDocument(), @@ -108,7 +108,7 @@ public static Chat ToDomain(this ChatDocument chat) Name = chat.Name, ModelId = chat.Model, Messages = chat.Messages.Select(m => m.ToDomain()).ToList(), - Visual = chat.Visual, + ImageGen = chat.ImageGen, Backend = chat.Backend, Properties = chat.Properties, ToolsConfiguration = chat.ToolsConfiguration, diff --git a/src/MaIN.Services/Services/AgentService.cs b/src/MaIN.Services/Services/AgentService.cs index 5e932833..fc06c719 100644 --- a/src/MaIN.Services/Services/AgentService.cs +++ b/src/MaIN.Services/Services/AgentService.cs @@ -101,7 +101,7 @@ public async Task CreateAgent(Agent agent, bool flow = false, bool intera Id = Guid.NewGuid().ToString(), ModelId = agent.Model, Name = agent.Name, - Visual = agent.Model == ImageGenService.LocalImageModels.FLUX, + ImageGen = agent.Model == ImageGenService.LocalImageModels.FLUX, ToolsConfiguration = agent.ToolsConfiguration, InterferenceParams = inferenceParams ?? new InferenceParams(), MemoryParams = memoryParams ?? new MemoryParams(), diff --git a/src/MaIN.Services/Services/ChatService.cs b/src/MaIN.Services/Services/ChatService.cs index 7fa7853d..d805f4a0 100644 --- a/src/MaIN.Services/Services/ChatService.cs +++ b/src/MaIN.Services/Services/ChatService.cs @@ -35,7 +35,7 @@ public async Task Completions( { if (chat.ModelId == ImageGenService.LocalImageModels.FLUX) { - chat.Visual = true; // TODO: add IImageGenModel interface and check for that instead + chat.ImageGen = true; } chat.Backend = settings.BackendType; @@ -60,7 +60,7 @@ public async Task Completions( }))]; } - var result = chat.Visual + var result = chat.ImageGen ? await imageGenServiceFactory.CreateService(chat.Backend.Value)!.Send(chat) : await llmServiceFactory.CreateService(chat.Backend.Value).Send(chat, new ChatRequestOptions() { @@ -74,7 +74,7 @@ public async Task Completions( result.Message.Time = DateTime.Now; } - if (!chat.Visual && chat.TextToSpeechParams is not null) + if (!chat.ImageGen && chat.TextToSpeechParams is not null) { var speechBytes = await ttsServiceFactory .CreateService(chat.Backend.Value).Send(result!.Message, chat.TextToSpeechParams.Model, diff --git a/src/MaIN.Services/Services/Steps/Commands/AnswerCommandHandler.cs b/src/MaIN.Services/Services/Steps/Commands/AnswerCommandHandler.cs index 036941f4..4178f6d4 100644 --- a/src/MaIN.Services/Services/Steps/Commands/AnswerCommandHandler.cs +++ b/src/MaIN.Services/Services/Steps/Commands/AnswerCommandHandler.cs @@ -51,7 +51,7 @@ public class AnswerCommandHandler( return await ProcessKnowledgeQuery(command.Knowledge, command.Chat, command.AgentId); } - result = command.Chat.Visual + result = command.Chat.ImageGen ? await imageGenService!.Send(command.Chat) : await llmService.Send(command.Chat, new ChatRequestOptions { InteractiveUpdates = command.Chat.Interactive, TokenCallback = command.Callback, ToolCallback = command.ToolCallback }); diff --git a/src/MaIN.Services/Services/Steps/Commands/StartCommandHandler.cs b/src/MaIN.Services/Services/Steps/Commands/StartCommandHandler.cs index d74b89d4..436f2931 100644 --- a/src/MaIN.Services/Services/Steps/Commands/StartCommandHandler.cs +++ b/src/MaIN.Services/Services/Steps/Commands/StartCommandHandler.cs @@ -9,7 +9,7 @@ public class StartCommandHandler : ICommandHandler { public Task HandleAsync(StartCommand command) { - if (command.Chat.Visual) + if (command.Chat.ImageGen) { return Task.FromResult(null); } From 37a1f5fa35bb499dde44b1fedebe8b3ec0f3e048 Mon Sep 17 00:00:00 2001 From: Magdalena Majta Date: Fri, 27 Feb 2026 21:47:32 +0100 Subject: [PATCH 20/22] Handle base64 images in OpenAiImageGenService Rename ImageGenDalleService to OpenAiImageGenService and update response handling to support OpenAI base64 (b64_json) image payloads. ProcessOpenAiResponse now returns byte[] and will decode b64_json or download from a URL; callers now receive image bytes directly. Add JsonPropertyName for b64_json, reorder/add necessary usings, and remove unused MaIN.Services.Services.LLMService.Utils imports from Gemini, OpenAi and Xai image services. These changes enable handling both base64 and URL image responses and standardize the OpenAI image service name. --- .../ImageGenServices/GeminiImageGenService.cs | 1 - ...lleService.cs => OpenAiImageGenService.cs} | 29 +++++++++++++------ .../ImageGenServices/XaiImageGenService.cs | 1 - 3 files changed, 20 insertions(+), 11 deletions(-) rename src/MaIN.Services/Services/ImageGenServices/{ImageGenDalleService.cs => OpenAiImageGenService.cs} (80%) diff --git a/src/MaIN.Services/Services/ImageGenServices/GeminiImageGenService.cs b/src/MaIN.Services/Services/ImageGenServices/GeminiImageGenService.cs index bae97232..1cd58b92 100644 --- a/src/MaIN.Services/Services/ImageGenServices/GeminiImageGenService.cs +++ b/src/MaIN.Services/Services/ImageGenServices/GeminiImageGenService.cs @@ -8,7 +8,6 @@ using System.Text.Json.Serialization; using MaIN.Domain.Exceptions; using MaIN.Domain.Models.Concrete; -using MaIN.Services.Services.LLMService.Utils; namespace MaIN.Services.Services.ImageGenServices; diff --git a/src/MaIN.Services/Services/ImageGenServices/ImageGenDalleService.cs b/src/MaIN.Services/Services/ImageGenServices/OpenAiImageGenService.cs similarity index 80% rename from src/MaIN.Services/Services/ImageGenServices/ImageGenDalleService.cs rename to src/MaIN.Services/Services/ImageGenServices/OpenAiImageGenService.cs index 96c66cbb..da42e658 100644 --- a/src/MaIN.Services/Services/ImageGenServices/ImageGenDalleService.cs +++ b/src/MaIN.Services/Services/ImageGenServices/OpenAiImageGenService.cs @@ -1,13 +1,13 @@ -using System.Net.Http.Headers; -using System.Net.Http.Json; using MaIN.Domain.Configuration; using MaIN.Domain.Entities; using MaIN.Domain.Exceptions; using MaIN.Domain.Models.Concrete; using MaIN.Services.Constants; using MaIN.Services.Services.Abstract; -using MaIN.Services.Services.LLMService.Utils; using MaIN.Services.Services.Models; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json.Serialization; namespace MaIN.Services.Services.ImageGenServices; @@ -34,8 +34,8 @@ public class OpenAiImageGenService( }; using var response = await client.PostAsJsonAsync(ServiceConstants.ApiUrls.OpenAiImageGenerations, requestBody); - var imageUrl = await ProcessOpenAiResponse(response); - byte[] imageBytes = await DownloadImageAsync(imageUrl); + + byte[] imageBytes = await ProcessOpenAiResponse(response); return CreateChatResult(imageBytes); } @@ -55,13 +55,22 @@ private async Task DownloadImageAsync(string imageUrl) return await imageResponse.Content.ReadAsByteArrayAsync(); } - - private async Task ProcessOpenAiResponse(HttpResponseMessage response) + + private async Task ProcessOpenAiResponse(HttpResponseMessage response) { response.EnsureSuccessStatusCode(); var responseData = await response.Content.ReadFromJsonAsync(); - return responseData?.Data.FirstOrDefault()?.Url - ?? throw new InvalidOperationException("No image URL returned from OpenAI"); + + var imageData = responseData?.Data.FirstOrDefault() + ?? throw new InvalidOperationException("No image data returned from OpenAI"); + + if (!string.IsNullOrEmpty(imageData.B64Json)) + return Convert.FromBase64String(imageData.B64Json); + + if (!string.IsNullOrEmpty(imageData.Url)) + return await DownloadImageAsync(imageData.Url); + + throw new InvalidOperationException("No image URL or base64 data returned from OpenAI"); } private static ChatResult CreateChatResult(byte[] imageBytes) @@ -95,4 +104,6 @@ file class OpenAiImageResponse file class ImageData { public string Url { get; set; } = string.Empty; + [JsonPropertyName("b64_json")] + public string B64Json { get; set; } = string.Empty; } \ No newline at end of file diff --git a/src/MaIN.Services/Services/ImageGenServices/XaiImageGenService.cs b/src/MaIN.Services/Services/ImageGenServices/XaiImageGenService.cs index bf10ff53..92e0a199 100644 --- a/src/MaIN.Services/Services/ImageGenServices/XaiImageGenService.cs +++ b/src/MaIN.Services/Services/ImageGenServices/XaiImageGenService.cs @@ -8,7 +8,6 @@ using System.Text.Json; using MaIN.Domain.Exceptions; using MaIN.Domain.Models.Concrete; -using MaIN.Services.Services.LLMService.Utils; namespace MaIN.Services.Services.ImageGenServices; From 53ded116a603f8eb34a35d6c414947709867eac3 Mon Sep 17 00:00:00 2001 From: Magdalena Majta Date: Fri, 27 Feb 2026 21:55:30 +0100 Subject: [PATCH 21/22] Add missing IVisionModel in cloud models --- .../Models/Concrete/CloudModels.cs | 45 +++++++++++++++---- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/src/MaIN.Domain/Models/Concrete/CloudModels.cs b/src/MaIN.Domain/Models/Concrete/CloudModels.cs index eaf0ff8e..2ea7bb6a 100644 --- a/src/MaIN.Domain/Models/Concrete/CloudModels.cs +++ b/src/MaIN.Domain/Models/Concrete/CloudModels.cs @@ -10,21 +10,30 @@ public sealed record Gpt4oMini() : CloudModel( BackendType.OpenAi, "GPT-4o Mini", ModelDefaults.DefaultMaxContextWindow, - "Fast and affordable OpenAI model for everyday tasks"); + "Fast and affordable OpenAI model for everyday tasks"), IVisionModel +{ + public string? MMProjectName => null; +} public sealed record Gpt4_1Mini() : CloudModel( "gpt-4.1-mini", BackendType.OpenAi, "GPT-4.1 Mini", ModelDefaults.DefaultMaxContextWindow, - "Updated mini model with improved capabilities"); + "Updated mini model with improved capabilities"), IVisionModel +{ + public string? MMProjectName => null; +} public sealed record Gpt5Nano() : CloudModel( "gpt-5-nano", BackendType.OpenAi, "GPT-5 Nano", ModelDefaults.DefaultMaxContextWindow, - "Next generation OpenAI nano model"); + "Next generation OpenAI nano model"), IVisionModel +{ + public string? MMProjectName => null; +} public sealed record DallE3() : CloudModel( "dall-e-3", @@ -47,14 +56,20 @@ public sealed record ClaudeSonnet4() : CloudModel( BackendType.Anthropic, "Claude Sonnet 4", 200000, - "Latest Claude model with enhanced reasoning capabilities"); + "Latest Claude model with enhanced reasoning capabilities"), IVisionModel +{ + public string? MMProjectName => null; +} 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"); + "Advanced Claude model with superior performance and extended context"), IVisionModel +{ + public string? MMProjectName => null; +} // ===== Gemini Models ===== @@ -63,14 +78,20 @@ public sealed record Gemini2_5Flash() : CloudModel( BackendType.Gemini, "Gemini 2.5 Flash", 1000000, - "Fast and efficient Google Gemini model for quick responses"); + "Fast and efficient Google Gemini model for quick responses"), IVisionModel +{ + public string? MMProjectName => null; +} 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"); + "Google Gemini 2.0 flash model optimized for speed and efficiency"), IVisionModel +{ + public string? MMProjectName => null; +} // ===== xAI Models ===== @@ -79,7 +100,10 @@ public sealed record Grok3Beta() : CloudModel( BackendType.Xai, "Grok 3 Beta", ModelDefaults.DefaultMaxContextWindow, - "xAI latest Grok model in beta testing phase"); + "xAI latest Grok model in beta testing phase"), IVisionModel +{ + public string? MMProjectName => null; +} public sealed record GrokImage() : CloudModel( "grok-2-image", @@ -124,4 +148,7 @@ public sealed record OllamaGemma3_4b() : CloudModel( BackendType.Ollama, "Gemma3 4B (Ollama)", 8192, - "Balanced 4B model running on Ollama for writing, analysis, and mathematical reasoning"); + "Balanced 4B model running on Ollama for writing, analysis, and mathematical reasoning"), IVisionModel +{ + public string? MMProjectName => null; +} From 2541958478b13380bd72ed5fd0ed8aaf995c6241 Mon Sep 17 00:00:00 2001 From: Magdalena Majta Date: Fri, 27 Feb 2026 22:11:47 +0100 Subject: [PATCH 22/22] versioning --- Releases/0.10.0.md | 7 +++++++ src/MaIN.Core/.nuspec | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 Releases/0.10.0.md diff --git a/Releases/0.10.0.md b/Releases/0.10.0.md new file mode 100644 index 00000000..b72e87b1 --- /dev/null +++ b/Releases/0.10.0.md @@ -0,0 +1,7 @@ +# 0.10.0 release + +Improve InfraPage: +- Refreshed chat UI layout with improved theming and smarter scroll behavior +- Extended attachments (drag & drop, paste), image previews, and improved image generation +- Added support for unregistered models and vision-based image handling (no OCR) +- Stability fixes, proper cancellation support, and internal service refactoring \ No newline at end of file diff --git a/src/MaIN.Core/.nuspec b/src/MaIN.Core/.nuspec index 096e4fc2..80867d51 100644 --- a/src/MaIN.Core/.nuspec +++ b/src/MaIN.Core/.nuspec @@ -2,7 +2,7 @@ MaIN.NET - 0.9.3 + 0.10.0 Wisedev Wisedev favicon.png