From 56e08b22817b8cdf85de87651473c9fc595e2e40 Mon Sep 17 00:00:00 2001 From: Magdalena Majta Date: Wed, 11 Feb 2026 12:40:13 +0100 Subject: [PATCH 01/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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); +}