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