Skip to content

Commit 07034d1

Browse files
committed
Add image-generation support and UI
Introduce image-generation capability across the app: add IImageGenerationModel and HasImageGeneration on AIModel; mark cloud models (DALL·E3, new gpt-image-1 and grok-2-image) as image generators. Update UI to render generated images with download and copy-to-clipboard actions (Home.razor changes, CopyImageToClipboard interop + editor.js). Improve visual/model detection in Utils to use ModelRegistry with a fallback set of known image-generation IDs. Increase SignalR hub max message size to 10MB (Program.cs) to allow larger image transfers and add CSS for generated image layout and controls.
1 parent 6c0af42 commit 07034d1

File tree

9 files changed

+178
-57
lines changed

9 files changed

+178
-57
lines changed

src/MaIN.Domain/Models/Abstract/AIModel.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ public abstract record AIModel(
3434

3535
/// <summary> Checks if model supports vision/image input. </summary>
3636
public bool HasVision => this is IVisionModel;
37+
38+
/// <summary> Checks if model generates images from text prompts. </summary>
39+
public bool HasImageGeneration => this is IImageGenerationModel;
3740
}
3841

3942
/// <summary> Base class for local models. </summary>

src/MaIN.Domain/Models/Abstract/IModelCapabilities.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,8 @@ public interface IEmbeddingModel
4545
/// Interface for models that support text-to-speech.
4646
/// </summary>
4747
public interface ITTSModel;
48+
49+
/// <summary>
50+
/// Interface for models that generate images from text prompts.
51+
/// </summary>
52+
public interface IImageGenerationModel;

src/MaIN.Domain/Models/Concrete/CloudModels.cs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,14 @@ public sealed record DallE3() : CloudModel(
3131
BackendType.OpenAi,
3232
"DALL-E 3",
3333
4000,
34-
"Advanced image generation model from OpenAI");
34+
"Advanced image generation model from OpenAI"), IImageGenerationModel;
35+
36+
public sealed record GptImage1() : CloudModel(
37+
"gpt-image-1",
38+
BackendType.OpenAi,
39+
"GPT Image 1",
40+
4000,
41+
"OpenAI's latest image generation model"), IImageGenerationModel;
3542

3643
// ===== Anthropic Models =====
3744

@@ -74,6 +81,13 @@ public sealed record Grok3Beta() : CloudModel(
7481
ModelDefaults.DefaultMaxContextWindow,
7582
"xAI latest Grok model in beta testing phase");
7683

84+
public sealed record GrokImage() : CloudModel(
85+
"grok-2-image",
86+
BackendType.Xai,
87+
"Grok 2 Image",
88+
4000,
89+
"xAI image generation model"), IImageGenerationModel;
90+
7791
// ===== GroqCloud Models =====
7892

7993
public sealed record Llama3_1_8bInstant() : CloudModel(

src/MaIN.InferPage/Components/Layout/NavBar.razor

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,15 @@
2929
Color="#000"
3030
Style="margin-left: 10px">Reasoning ✨</FluentBadge>
3131
}
32+
@if (Utils.Visual)
33+
{
34+
<FluentBadge
35+
Appearance="Appearance.Neutral"
36+
Fill="highlight"
37+
BackgroundColor="#7c3aed"
38+
Color="#fff"
39+
Style="margin-left: 10px">Visual 🎨</FluentBadge>
40+
}
3241
<div style="margin-left: auto; align-self: flex-end;">
3342
<FluentButton Style="padding: 10px; background-color: transparent;"
3443
BackgroundColor="rgba(0, 0, 0, 0)"

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

Lines changed: 60 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -36,37 +36,10 @@
3636
{
3737
@if (conversation.Message.Role != "System")
3838
{
39-
@if (Chat.Visual)
40-
{
41-
<FluentBadge Class="@(conversation.Message.Role == "User" ? "message-role-user" : "message-role-bot")"
42-
Appearance="Appearance.Accent">
43-
@(conversation.Message.Role == "User" ? "User" : Utils.Model)
44-
</FluentBadge>
39+
<FluentCard class="@(conversation.Message.Role == "User" ? "message-card user-message" : "message-card bot-message")">
4540
@if (conversation.Message.Role == "User")
4641
{
47-
<FluentCard class="message-card user-message">
48-
@conversation.Message.Content
49-
</FluentCard>
50-
}
51-
else
52-
{
53-
<FluentCard class="message-card-img bot-message"
54-
Style="height: 30rem !important; width: 30rem !important; ">
55-
<div>
56-
<a href="data:image/png;base64,@Convert.ToBase64String(conversation.Message.Image!)"
57-
style="cursor: -webkit-zoom-in; cursor: zoom-in;" target="_blank">
58-
<img src="data:image/png;base64,@Convert.ToBase64String(conversation.Message.Image!)"
59-
style="object-fit: fill; width:100%; height:100%;"
60-
alt="imageResponse"/>
61-
</a>
62-
</div>
63-
</FluentCard>
64-
}
65-
}
66-
else
67-
{
68-
<FluentCard class="@(conversation.Message.Role == "User" ? "message-card user-message" : "message-card bot-message")">
69-
@if (conversation.Message.Role == "User" && (conversation.AttachedFiles.Any() || conversation.AttachedImages.Any()))
42+
@if (conversation.AttachedFiles.Any() || conversation.AttachedImages.Any())
7043
{
7144
<div class="attached-files-display">
7245
@foreach (var image in conversation.AttachedImages)
@@ -84,10 +57,36 @@
8457
}
8558
</div>
8659
}
87-
@if (conversation.Message.Role == "User")
60+
<div>
61+
@((MarkupString)Markdown.ToHtml(conversation.Message.Content ?? string.Empty, _markdownPipeline))
62+
</div>
63+
}
64+
else
65+
{
66+
@if (conversation.Message.Images?.Any() == true)
8867
{
89-
<div>
90-
@((MarkupString)Markdown.ToHtml(conversation.Message.Content ?? string.Empty, _markdownPipeline))
68+
<div class="generated-images">
69+
@foreach (var imageBytes in conversation.Message.Images)
70+
{
71+
var b64 = Convert.ToBase64String(imageBytes);
72+
<div class="image-wrapper">
73+
<a href="data:image/png;base64,@b64" target="_blank">
74+
<img src="data:image/png;base64,@b64" class="generated-image" alt="generated image" />
75+
</a>
76+
<div class="image-actions">
77+
<a href="data:image/png;base64,@b64" download="generated-image.png"
78+
class="image-action-btn" title="Download">
79+
<FluentIcon Value="@(new Icons.Filled.Size24.ArrowDownload())"
80+
Style="fill: var(--accent-base-color);" />
81+
</a>
82+
<span class="image-action-btn" title="Copy to clipboard"
83+
@onclick="@(async () => await CopyImageToClipboard(b64))">
84+
<FluentIcon Value="@(new Icons.Filled.Size24.Copy())"
85+
Style="fill: var(--accent-base-color);" />
86+
</span>
87+
</div>
88+
</div>
89+
}
9190
</div>
9291
}
9392
else
@@ -114,33 +113,30 @@
114113
@((MarkupString)Markdown.ToHtml(GetMessageContent(conversation.Message), _markdownPipeline))
115114
</div>
116115
}
117-
</FluentCard>
118-
}
116+
}
117+
</FluentCard>
119118
}
120119
}
121120
@if (_isLoading)
122121
{
123-
@if (Chat.Visual)
122+
@if (Utils.Visual)
124123
{
125124
<span class="message-role-bot" style="font-style: italic; font-size: small">This might take a while...</span>
126125
}
127-
else
126+
else if (_incomingMessage != null || _incomingReasoning != null)
128127
{
129-
@if (_incomingMessage != null || _incomingReasoning != null)
130-
{
131-
<FluentCard class="message-card bot-message">
132-
@if (_isThinking)
133-
{
134-
<span class="thinker">
135-
@((MarkupString)Markdown.ToHtml(_incomingReasoning ?? string.Empty, _markdownPipeline))
136-
</span>
137-
}
138-
else
139-
{
140-
@((MarkupString)Markdown.ToHtml(_incomingMessage ?? string.Empty, _markdownPipeline))
141-
}
142-
</FluentCard>
143-
}
128+
<FluentCard class="message-card bot-message">
129+
@if (_isThinking)
130+
{
131+
<span class="thinker">
132+
@((MarkupString)Markdown.ToHtml(_incomingReasoning ?? string.Empty, _markdownPipeline))
133+
</span>
134+
}
135+
else
136+
{
137+
@((MarkupString)Markdown.ToHtml(_incomingMessage ?? string.Empty, _markdownPipeline))
138+
}
139+
</FluentCard>
144140
}
145141
}
146142
<div id="bottom" @ref="_bottomElement"></div>
@@ -277,7 +273,7 @@
277273
}
278274

279275
ctx = Utils.Visual
280-
? AIHub.Chat().EnableVisual()
276+
? AIHub.Chat().WithModel(model).EnableVisual()
281277
: AIHub.Chat().WithModel(model);
282278
}
283279
catch (MaINCustomException ex)
@@ -628,6 +624,18 @@
628624
}).ToList();
629625
}
630626

627+
private async Task CopyImageToClipboard(string base64)
628+
{
629+
try
630+
{
631+
await JS.InvokeVoidAsync("editorManager.copyImageToClipboard", base64);
632+
}
633+
catch
634+
{
635+
// silently ignore — clipboard API may not be available in all browsers
636+
}
637+
}
638+
631639
public void Dispose()
632640
{
633641
_cancellationTokenSource?.Dispose();

src/MaIN.InferPage/Program.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@
88

99
var builder = WebApplication.CreateBuilder(args);
1010
builder.Services.AddRazorComponents()
11-
.AddInteractiveServerComponents();
11+
.AddInteractiveServerComponents()
12+
.AddHubOptions(options =>
13+
{
14+
options.MaximumReceiveMessageSize = 10 * 1024 * 1024; // 10 MB
15+
});
1216
builder.Services.AddFluentUIComponents();
1317

1418
try

src/MaIN.InferPage/Utils.cs

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,36 @@
11
using MaIN.Domain.Configuration;
22
using MaIN.Domain.Entities;
3+
using MaIN.Domain.Models.Abstract;
34

45
namespace MaIN.InferPage;
56

67
public static class Utils
78
{
89
public static BackendType BackendType { get; set; } = BackendType.Self;
910
public static bool HasApiKey { get; set; }
11+
public static string? Path { get; set; }
1012
public static bool IsLocal => BackendType == BackendType.Self || (BackendType == BackendType.Ollama && !HasApiKey);
1113
public static string? Model = "gemma3-4b";
1214
public static bool Reason { get; set; }
13-
public static bool Visual => VisualModels.Contains(Model);
14-
private static readonly string[] VisualModels = ["FLUX.1_Shnell", "FLUX.1", "dall-e-3", "dall-e", "imagen", "imagen-3"]; //user might type different names
15-
public static string? Path { get; set; }
15+
public static bool Visual
16+
{
17+
get
18+
{
19+
if (string.IsNullOrEmpty(Model)) return false;
20+
if (ModelRegistry.TryGetById(Model, out var m))
21+
return m is IImageGenerationModel;
22+
return ImageGenerationModels.Contains(Model); // fallback for unregistered models (e.g. FLUX via separate server)
23+
}
24+
}
25+
26+
private static readonly HashSet<string> ImageGenerationModels =
27+
[
28+
"FLUX.1_Shnell", "FLUX.1",
29+
"dall-e-3", "dall-e",
30+
"gpt-image-1",
31+
"imagen", "imagen-3", "imagen-4", "imagen-4-fast",
32+
"grok-2-image"
33+
];
1634
}
1735

1836
public class MessageExt

src/MaIN.InferPage/wwwroot/editor.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,5 +109,10 @@ window.editorManager = {
109109
} catch (err) {
110110
try { await dotNetHelper.invokeMethodAsync('OnDragLeave'); } catch {}
111111
}
112+
},
113+
copyImageToClipboard: async (base64) => {
114+
const res = await fetch(`data:image/png;base64,${base64}`);
115+
const blob = await res.blob();
116+
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
112117
}
113118
};

src/MaIN.InferPage/wwwroot/home.css

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,3 +406,58 @@ body {
406406
30%, 50%, 70% { transform: translate3d(-4px, 0, 0); }
407407
40%, 60% { transform: translate3d(4px, 0, 0); }
408408
}
409+
410+
/* Generated image display */
411+
.generated-images {
412+
display: flex;
413+
flex-direction: column;
414+
gap: 12px;
415+
width: 100%;
416+
}
417+
418+
.image-wrapper {
419+
position: relative;
420+
width: 100%;
421+
}
422+
423+
/* Fix: <a> is inline by default — make it block so width: 100% on <img> works correctly */
424+
.image-wrapper > a {
425+
display: block;
426+
width: 100%;
427+
}
428+
429+
.generated-image {
430+
width: 100%;
431+
height: auto;
432+
border-radius: 8px;
433+
display: block;
434+
cursor: zoom-in;
435+
}
436+
437+
/* Action buttons overlaid on bottom-left corner of the image */
438+
.image-actions {
439+
display: flex;
440+
gap: 6px;
441+
position: absolute;
442+
bottom: 10px;
443+
left: 10px;
444+
}
445+
446+
.image-action-btn {
447+
display: inline-flex;
448+
align-items: center;
449+
justify-content: center;
450+
padding: 8px;
451+
border-radius: 8px;
452+
opacity: 0.85;
453+
transition: opacity 0.15s, background-color 0.15s;
454+
color: var(--accent-base-color);
455+
text-decoration: none;
456+
cursor: pointer;
457+
background-color: rgba(0, 0, 0, 0.4);
458+
}
459+
460+
.image-action-btn:hover {
461+
opacity: 1;
462+
background-color: rgba(0, 0, 0, 0.6);
463+
}

0 commit comments

Comments
 (0)