Skip to content

Commit 6c0af42

Browse files
committed
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.
1 parent 580d7ac commit 6c0af42

File tree

5 files changed

+231
-44
lines changed

5 files changed

+231
-44
lines changed

src/MaIN.InferPage/Components/Pages/Home.razor

Lines changed: 97 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,15 @@
6666
else
6767
{
6868
<FluentCard class="@(conversation.Message.Role == "User" ? "message-card user-message" : "message-card bot-message")">
69-
@if (conversation.Message.Role == "User" && conversation.AttachedFiles.Any())
69+
@if (conversation.Message.Role == "User" && (conversation.AttachedFiles.Any() || conversation.AttachedImages.Any()))
7070
{
7171
<div class="attached-files-display">
72+
@foreach (var image in conversation.AttachedImages)
73+
{
74+
<div class="history-image-preview" title="@image.Name">
75+
<img src="data:image/png;base64,@image.Base64" alt="@image.Name" />
76+
</div>
77+
}
7278
@foreach (var fileName in conversation.AttachedFiles)
7379
{
7480
<span class="attached-file-tag">
@@ -140,9 +146,18 @@
140146
<div id="bottom" @ref="_bottomElement"></div>
141147
</div>
142148
<div class="chat-input-section">
143-
@if (_selectedFiles.Any())
149+
@if (_selectedImages.Any() || _selectedFiles.Any())
144150
{
145-
<div class="selected-files-container">
151+
<div class="selected-attachments-container">
152+
@foreach (var image in _selectedImages)
153+
{
154+
<div class="image-preview" title="@image.File.Name">
155+
<img src="data:image/@image.File.Extension.TrimStart('.');base64,@image.Base64Preview" alt="@image.File.Name" />
156+
<span class="image-dismiss-button" @onclick="@(() => RemoveImage(image))">
157+
<FluentIcon Value="@(new Icons.Regular.Size12.Dismiss())" Style="fill: var(--accent-base-color);" />
158+
</span>
159+
</div>
160+
}
146161
@foreach (var file in _selectedFiles)
147162
{
148163
<div class="file-badge" title="@file.Name">
@@ -202,7 +217,6 @@
202217
private string? _errorMessage;
203218
private string? _incomingMessage;
204219
private string? _incomingReasoning;
205-
private readonly string? _displayName = Utils.Model;
206220
private IChatMessageBuilder? ctx;
207221
private CancellationTokenSource? _cancellationTokenSource;
208222
private Chat Chat { get; } = new() { Name = "MaIN Infer", ModelId = Utils.Model! };
@@ -211,8 +225,11 @@
211225
private ElementReference _editorRef;
212226
private DotNetObjectReference<Home>? _dotNetRef;
213227
private List<FileInfo> _selectedFiles = new();
228+
private List<(FileInfo File, string Base64Preview)> _selectedImages = new();
214229
private int _inputKey;
215230

231+
private static readonly HashSet<string> ImageExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"];
232+
216233
private readonly MarkdownPipeline _markdownPipeline = new MarkdownPipelineBuilder()
217234
.UseAdvancedExtensions()
218235
.UseSoftlineBreakAsHardlineBreak()
@@ -291,7 +308,7 @@
291308
if (_isLoading) return;
292309

293310
var msg = await JS.InvokeAsync<string>("editorManager.getInnerText", _editorRef);
294-
if (!string.IsNullOrWhiteSpace(msg) || _selectedFiles.Any())
311+
if (!string.IsNullOrWhiteSpace(msg) || _selectedFiles.Any() || _selectedImages.Any())
295312
{
296313
await JS.InvokeVoidAsync("editorManager.clearContent", _editorRef);
297314
await SendAsync(msg?.Trim() ?? string.Empty);
@@ -307,12 +324,25 @@
307324
{
308325
await file.OpenReadStream(20 * 1024 * 1024).CopyToAsync(ms);
309326
ms.Position = 0;
310-
_selectedFiles.Add(new FileInfo
327+
328+
var extension = Path.GetExtension(file.Name).ToLowerInvariant();
329+
var fileInfo = new FileInfo
311330
{
312331
Name = file.Name,
313-
Extension = Path.GetExtension(file.Name),
332+
Extension = extension,
314333
StreamContent = ms
315-
});
334+
};
335+
336+
if (ImageExtensions.Contains(extension))
337+
{
338+
var base64 = Convert.ToBase64String(ms.ToArray());
339+
ms.Position = 0;
340+
_selectedImages.Add((fileInfo, base64));
341+
}
342+
else
343+
{
344+
_selectedFiles.Add(fileInfo);
345+
}
316346
}
317347
catch (Exception ex)
318348
{
@@ -329,17 +359,40 @@
329359
_selectedFiles.Remove(file);
330360
}
331361

362+
private void RemoveImage((FileInfo File, string Base64Preview) image)
363+
{
364+
image.File.StreamContent?.Dispose();
365+
_selectedImages.Remove(image);
366+
}
367+
332368
[JSInvokable]
333-
public async Task OnFilePasted(string fileName, string extension, string base64Data)
369+
public async Task OnFileReceived(string fileName, string extension, string base64Data)
334370
{
335-
var bytes = Convert.FromBase64String(base64Data);
336-
var ms = new MemoryStream(bytes);
337-
_selectedFiles.Add(new FileInfo
371+
try
338372
{
339-
Name = fileName,
340-
Extension = extension,
341-
StreamContent = ms
342-
});
373+
var bytes = Convert.FromBase64String(base64Data);
374+
var ms = new MemoryStream(bytes);
375+
var fileInfo = new FileInfo
376+
{
377+
Name = fileName,
378+
Extension = extension,
379+
StreamContent = ms
380+
};
381+
382+
if (ImageExtensions.Contains(extension.ToLowerInvariant()))
383+
{
384+
_selectedImages.Add((fileInfo, base64Data));
385+
}
386+
else
387+
{
388+
_selectedFiles.Add(fileInfo);
389+
}
390+
}
391+
catch (Exception ex)
392+
{
393+
_errorMessage = $"Failed to load file {fileName}: {ex.Message}";
394+
}
395+
343396
_isDragging = false;
344397
await InvokeAsync(StateHasChanged);
345398
}
@@ -365,7 +418,7 @@
365418

366419
private async Task SendAsync(string msg)
367420
{
368-
if (string.IsNullOrWhiteSpace(msg) && !_selectedFiles.Any())
421+
if (string.IsNullOrWhiteSpace(msg) && !_selectedFiles.Any() && !_selectedImages.Any())
369422
{
370423
return;
371424
}
@@ -377,10 +430,12 @@
377430
_isLoading = true;
378431
StateHasChanged();
379432

380-
var attachments = new List<FileInfo>(_selectedFiles);
433+
var attachedFiles = new List<FileInfo>(_selectedFiles);
434+
var attachedImages = new List<(FileInfo File, string Base64Preview)>(_selectedImages);
381435
try
382436
{
383437
_selectedFiles.Clear();
438+
_selectedImages.Clear();
384439
_inputKey++;
385440
StateHasChanged();
386441

@@ -392,11 +447,11 @@
392447
};
393448
Chat.Messages.Add(newMsg);
394449

395-
var attachedFileNames = attachments.Select(f => f.Name).ToList();
396450
Messages.Add(new MessageExt
397451
{
398452
Message = newMsg,
399-
AttachedFiles = attachedFileNames
453+
AttachedFiles = attachedFiles.Select(f => f.Name).ToList(),
454+
AttachedImages = attachedImages.Select(i => (i.File.Name, i.Base64Preview)).ToList()
400455
});
401456

402457
Chat.ModelId = Utils.Model!;
@@ -410,9 +465,14 @@
410465
await JS.InvokeVoidAsync("scrollManager.scrollToBottomSmooth", "messages-container");
411466

412467
var request = ctx!.WithMessage(msg);
413-
if (attachments.Count != 0)
468+
469+
// Combine files and images - images go as files, ExtractImageFromFiles will handle them
470+
var allFiles = new List<FileInfo>(attachedFiles);
471+
allFiles.AddRange(attachedImages.Select(i => i.File));
472+
473+
if (allFiles.Count != 0)
414474
{
415-
request.WithFiles(attachments);
475+
request.WithFiles(allFiles);
416476
}
417477

418478
cancellationToken.ThrowIfCancellationRequested();
@@ -502,9 +562,13 @@
502562
}
503563
finally
504564
{
505-
foreach (var attachment in attachments)
565+
foreach (var attachment in attachedFiles)
506566
attachment.StreamContent?.Dispose();
507-
attachments.Clear();
567+
attachedFiles.Clear();
568+
569+
foreach (var image in attachedImages)
570+
image.File.StreamContent?.Dispose();
571+
attachedImages.Clear();
508572

509573
_isLoading = false;
510574
_isThinking = false;
@@ -548,11 +612,19 @@
548612
.Where(m => m.AttachedFiles.Any())
549613
.ToDictionary(m => m.Message, m => m.AttachedFiles);
550614

615+
var existingImagesMap = Messages
616+
.Where(m => m.AttachedImages.Any())
617+
.ToDictionary(m => m.Message, m => m.AttachedImages);
618+
619+
var existingReasonMap = Messages
620+
.ToDictionary(m => m.Message, m => m.ShowReason);
621+
551622
Messages = Chat.Messages.Select(x => new MessageExt
552623
{
553624
Message = x,
554625
AttachedFiles = existingFilesMap.TryGetValue(x, out var files) ? files : new List<string>(),
555-
ShowReason = false
626+
AttachedImages = existingImagesMap.TryGetValue(x, out var images) ? images : new List<(string, string)>(),
627+
ShowReason = existingReasonMap.TryGetValue(x, out var show) && show
556628
}).ToList();
557629
}
558630

src/MaIN.InferPage/Utils.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,5 @@ public class MessageExt
2020
public required Message Message { get; set; }
2121
public bool ShowReason { get; set; }
2222
public List<string> AttachedFiles { get; set; } = new();
23+
public List<(string Name, string Base64)> AttachedImages { get; set; } = new();
2324
}

src/MaIN.InferPage/wwwroot/editor.js

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -81,19 +81,19 @@ window.editorManager = {
8181
for (const file of files) {
8282
await editorManager._processFile(file, dotNetHelper);
8383
}
84+
85+
// Ensure overlay is always dismissed after drop
86+
try { await dotNetHelper.invokeMethodAsync('OnDragLeave'); } catch {}
8487
});
8588
},
8689
_processFile: async (file, dotNetHelper) => {
8790
try {
88-
const arrayBuffer = await file.arrayBuffer();
89-
const uint8Array = new Uint8Array(arrayBuffer);
90-
91-
// Convert to base64 - much smaller than int array
92-
let binary = '';
93-
for (let i = 0; i < uint8Array.length; i++) {
94-
binary += String.fromCharCode(uint8Array[i]);
95-
}
96-
const base64 = btoa(binary);
91+
const base64 = await new Promise((resolve, reject) => {
92+
const reader = new FileReader();
93+
reader.onload = () => resolve(reader.result.split(',')[1]);
94+
reader.onerror = () => reject(reader.error);
95+
reader.readAsDataURL(file);
96+
});
9797

9898
let extension = '';
9999
const lastDot = file.name.lastIndexOf('.');
@@ -105,9 +105,9 @@ window.editorManager = {
105105

106106
const fileName = file.name || `file-${Date.now()}${extension}`;
107107

108-
await dotNetHelper.invokeMethodAsync('OnFilePasted', fileName, extension, base64);
109-
} catch {
110-
// Silent fail
108+
await dotNetHelper.invokeMethodAsync('OnFileReceived', fileName, extension, base64);
109+
} catch (err) {
110+
try { await dotNetHelper.invokeMethodAsync('OnDragLeave'); } catch {}
111111
}
112112
}
113113
};

src/MaIN.InferPage/wwwroot/home.css

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,13 +120,49 @@ body {
120120
flex-direction: column;
121121
}
122122

123-
.selected-files-container {
123+
.selected-attachments-container {
124124
display: flex;
125125
flex-wrap: wrap;
126126
gap: 8px;
127127
padding: 4px 12px;
128128
margin-bottom: 2px;
129129
width: 100%;
130+
align-items: flex-end;
131+
}
132+
133+
/* Image preview thumbnail */
134+
.image-preview {
135+
position: relative;
136+
width: 48px;
137+
height: 48px;
138+
border-radius: 6px;
139+
overflow: hidden;
140+
border: 1px solid var(--neutral-stroke-rest);
141+
background-color: var(--neutral-layer-1);
142+
}
143+
144+
.image-preview img {
145+
width: 100%;
146+
height: 100%;
147+
object-fit: cover;
148+
}
149+
150+
.image-dismiss-button {
151+
position: absolute;
152+
top: 2px;
153+
right: 2px;
154+
width: 16px;
155+
height: 16px;
156+
border-radius: 50%;
157+
display: flex;
158+
align-items: center;
159+
justify-content: center;
160+
cursor: pointer;
161+
opacity: 0.6;
162+
}
163+
164+
.image-dismiss-button:hover {
165+
opacity: 1;
130166
}
131167

132168
.file-badge {
@@ -290,6 +326,24 @@ body {
290326
margin-bottom: 10px;
291327
padding-bottom: 8px;
292328
border-bottom: 1px solid var(--neutral-stroke-rest);
329+
align-items: center;
330+
}
331+
332+
/* History image preview (in sent messages) */
333+
.history-image-preview {
334+
width: 40px;
335+
height: 40px;
336+
border-radius: 4px;
337+
overflow: hidden;
338+
border: 1px solid var(--neutral-stroke-rest);
339+
background-color: var(--neutral-layer-1);
340+
flex-shrink: 0;
341+
}
342+
343+
.history-image-preview img {
344+
width: 100%;
345+
height: 100%;
346+
object-fit: cover;
293347
}
294348

295349
.attached-file-tag {

0 commit comments

Comments
 (0)