Skip to content

Commit 8a97699

Browse files
committed
Add Image Generation
1 parent 005bd4a commit 8a97699

File tree

13 files changed

+737
-15
lines changed

13 files changed

+737
-15
lines changed

imageGeneratorSample.ServiceDefaults/Extensions.cs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,22 @@ public static TBuilder AddServiceDefaults<TBuilder>(this TBuilder builder) where
3030
#pragma warning restore EXTEXP0001
3131

3232
// Turn on resilience by default
33-
http.AddStandardResilienceHandler();
33+
http.AddStandardResilienceHandler(httpResilienceOptions =>
34+
{
35+
// Set a longer timeouts. Our image models can take a while to generate images.
36+
httpResilienceOptions.TotalRequestTimeout = new()
37+
{
38+
Timeout = TimeSpan.FromMinutes(10)
39+
};
40+
41+
httpResilienceOptions.AttemptTimeout = new()
42+
{
43+
Timeout = TimeSpan.FromMinutes(2)
44+
};
45+
46+
httpResilienceOptions.CircuitBreaker.SamplingDuration = TimeSpan.FromMinutes(5);
47+
48+
});
3449

3550
// Turn on service discovery by default
3651
http.AddServiceDiscovery();

imageGeneratorSample.Web/Components/Pages/Chat/Chat.razor

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
@page "/"
22
@using System.ComponentModel
3+
@using imageGeneratorSample.Web.Services.Images
34
@inject IChatClient ChatClient
5+
@inject IImageGenerator ImageGenerator
6+
@inject IHttpClientFactory HttpClientFactory
47
@inject NavigationManager Nav
58
@inject SemanticSearch Search
9+
@inject IImageCacheService ImageCache
610
@implements IDisposable
711

812
<PageTitle>Chat</PageTitle>
@@ -28,6 +32,7 @@
2832
You are an assistant who answers questions about information you retrieve.
2933
Do not answer questions about anything else.
3034
Use only simple markdown to format your responses.
35+
When sharing images use image links in the following format: ![image description](image-url).
3136
3237
Use the search tool to find relevant information. When you do this, end your
3338
reply with citations in the special XML format:
@@ -46,11 +51,17 @@
4651
private ChatMessage? currentResponseMessage;
4752
private ChatInput? chatInput;
4853
private ChatSuggestions? chatSuggestions;
54+
private HttpClient? httpClient;
4955

5056
protected override void OnInitialized()
5157
{
5258
messages.Add(new(ChatRole.System, SystemPrompt));
53-
chatOptions.Tools = [AIFunctionFactory.Create(SearchAsync)];
59+
chatOptions.Tools = [
60+
AIFunctionFactory.Create(SearchAsync),
61+
AIFunctionFactory.Create(GenerateImagesAsync),
62+
AIFunctionFactory.Create(EditImageAsync)
63+
];
64+
httpClient = HttpClientFactory.CreateClient();
5465
}
5566

5667
private async Task AddUserMessageAsync(ChatMessage userMessage)
@@ -111,6 +122,88 @@
111122
$"<result filename=\"{result.DocumentId}\" page_number=\"{result.PageNumber}\">{result.Text}</result>");
112123
}
113124

125+
126+
[Description("Generates images based on a text description")]
127+
private async IAsyncEnumerable<AIContent> GenerateImagesAsync(
128+
[Description("A detailed description of the image to generate")] string prompt,
129+
[Description("The number of images to generate. Some models only support generating one image at a time.")] int count = 1)
130+
{
131+
await InvokeAsync(StateHasChanged);
132+
var options = new ImageGenerationOptions()
133+
{
134+
Count = count,
135+
};
136+
137+
var response = await ImageGenerator.GenerateImagesAsync(prompt, options);
138+
139+
foreach (var content in response.Contents)
140+
{
141+
var cachedContent = await CacheContent(content);
142+
yield return cachedContent;
143+
}
144+
}
145+
146+
[Description("Edits an existing image based on a text description")]
147+
private async IAsyncEnumerable<AIContent> EditImageAsync(
148+
[Description("The URL of the image to edit")] string imageUrl,
149+
[Description("A detailed description of the image to edit")] string prompt,
150+
[Description("The number of images to generate. Some models only support generating one image at a time.")] int count = 1)
151+
{
152+
await InvokeAsync(StateHasChanged);
153+
var options = new ImageGenerationOptions()
154+
{
155+
Count = count,
156+
};
157+
158+
DataContent dataContent;
159+
var cachedData = await ImageCache.GetCachedImageAsync(imageUrl);
160+
if (cachedData is not null)
161+
{
162+
// the image is cached, so we can use it directly
163+
dataContent = new DataContent(cachedData.Value.imageBytes, cachedData.Value.contentType);
164+
}
165+
else
166+
{
167+
// download the image from the URL
168+
var ImageGenerationResponse = await httpClient!.GetAsync(imageUrl);
169+
var imageBytes = await ImageGenerationResponse.Content.ReadAsByteArrayAsync();
170+
171+
dataContent = new DataContent(imageBytes, ImageGenerationResponse.Content.Headers.ContentType?.MediaType ?? "image/png");
172+
}
173+
174+
var response = await ImageGenerator.EditImagesAsync([dataContent], prompt, options);
175+
176+
foreach (var content in response.Contents)
177+
{
178+
var cachedContent = await CacheContent(content);
179+
yield return cachedContent;
180+
}
181+
}
182+
183+
private async Task<AIContent> CacheContent(AIContent content)
184+
{
185+
if (content is DataContent dataContent)
186+
{
187+
var cacheUri = await ImageCache.CacheImageAsync(dataContent.Data, dataContent.MediaType);
188+
189+
return new UriContent(Nav.ToAbsoluteUri(cacheUri), dataContent.MediaType);
190+
}
191+
else if (content is UriContent uriContent)
192+
{
193+
// the URI returned from the client contains a secret, download and cache the file.
194+
var stream = await httpClient!.GetStreamAsync(uriContent.Uri);
195+
196+
var cacheUri = await ImageCache.CacheImageAsync(stream, uriContent.MediaType);
197+
198+
return new UriContent(Nav.ToAbsoluteUri(cacheUri), uriContent.MediaType);
199+
}
200+
else
201+
{
202+
return content;
203+
}
204+
}
205+
206+
114207
public void Dispose()
115208
=> currentResponseCancellation?.Cancel();
116209
}

imageGeneratorSample.Web/Components/Pages/Chat/ChatInput.razor

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
@inject IJSRuntime JS
22

33
<EditForm Model="@this" OnValidSubmit="@SendMessageAsync">
4-
<label class="input-box page-width">
4+
<label class="input-box page-width" @ref="@inputContainer">
55
<textarea @ref="@textArea" @bind="@messageText" placeholder="Type your message..." rows="1"></textarea>
66

77
<div class="tools">
@@ -16,7 +16,9 @@
1616

1717
@code {
1818
private ElementReference textArea;
19+
private ElementReference inputContainer;
1920
private string? messageText;
21+
private IJSObjectReference? jsModule;
2022

2123
[Parameter]
2224
public EventCallback<ChatMessage> OnSend { get; set; }
@@ -39,9 +41,22 @@
3941
{
4042
try
4143
{
42-
var module = await JS.InvokeAsync<IJSObjectReference>("import", "./Components/Pages/Chat/ChatInput.razor.js");
43-
await module.InvokeVoidAsync("init", textArea);
44-
await module.DisposeAsync();
44+
jsModule = await JS.InvokeAsync<IJSObjectReference>("import", "./Components/Pages/Chat/ChatInput.razor.js");
45+
await jsModule.InvokeVoidAsync("init", textArea, inputContainer);
46+
}
47+
catch (JSDisconnectedException)
48+
{
49+
}
50+
}
51+
}
52+
53+
public async ValueTask DisposeAsync()
54+
{
55+
if (jsModule is not null)
56+
{
57+
try
58+
{
59+
await jsModule.DisposeAsync();
4560
}
4661
catch (JSDisconnectedException)
4762
{

imageGeneratorSample.Web/Components/Pages/Chat/ChatInput.razor.css

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,24 @@
11
.input-box {
2-
display: flex;
3-
flex-direction: column;
2+
display: flex;
3+
flex-direction: column;
44
background: white;
55
border: 1px solid rgb(229, 231, 235);
66
border-radius: 8px;
77
padding: 0.5rem 0.75rem;
8-
margin-top: 0.75rem;
8+
margin-top: 0.75rem;
9+
transition: border-color 0.2s ease, background-color 0.2s ease;
910
}
1011

1112
.input-box:focus-within {
1213
outline: 2px solid #4152d5;
1314
}
1415

16+
.input-box.drag-over {
17+
border-color: #4152d5;
18+
border-style: dashed;
19+
background-color: rgba(65, 82, 213, 0.05);
20+
}
21+
1522
textarea {
1623
resize: none;
1724
border: none;

imageGeneratorSample.Web/Components/Pages/Chat/ChatInput.razor.js

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export function init(elem) {
1+
export function init(elem, container) {
22
elem.focus();
33

44
// Auto-resize whenever the user types or if the value is set programmatically
@@ -13,6 +13,9 @@
1313
elem.closest('form').dispatchEvent(new CustomEvent('submit', { bubbles: true, cancelable: true }));
1414
}
1515
});
16+
17+
// Add drag and drop functionality
18+
setupDragAndDrop(container, elem)
1619
}
1720

1821
function resizeToFit(elem) {
@@ -41,3 +44,85 @@ function getPropertyDescriptor(target, propertyName) {
4144
return Object.getOwnPropertyDescriptor(target, propertyName)
4245
|| getPropertyDescriptor(Object.getPrototypeOf(target), propertyName);
4346
}
47+
48+
function setupDragAndDrop(container, textArea) {
49+
let dragOverCount = 0;
50+
51+
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
52+
container.addEventListener(eventName, e => {
53+
e.preventDefault();
54+
e.stopPropagation();
55+
}, false);
56+
});
57+
58+
['dragenter', 'dragover'].forEach(eventName => {
59+
container.addEventListener(eventName, () => {
60+
dragOverCount++;
61+
container.classList.add('drag-over');
62+
}, false);
63+
});
64+
65+
['dragleave', 'drop'].forEach(eventName => {
66+
container.addEventListener(eventName, () => {
67+
dragOverCount--;
68+
if (dragOverCount === 0) {
69+
container.classList.remove('drag-over');
70+
}
71+
}, false);
72+
});
73+
74+
container.addEventListener('drop', async (e) => {
75+
dragOverCount = 0;
76+
container.classList.remove('drag-over');
77+
78+
const files = e.dataTransfer.files;
79+
for (let i = 0; i < files.length; i++) {
80+
const file = files[i];
81+
if (file.type.startsWith('image/')) {
82+
try {
83+
const reader = new FileReader();
84+
reader.onload = async () => {
85+
const base64 = reader.result.split(',')[1];
86+
await uploadImageAndUpdateTextArea(file.name, base64, file.type, textArea);
87+
};
88+
reader.readAsDataURL(file);
89+
} catch (error) {
90+
console.error('Error processing image:', error);
91+
}
92+
}
93+
}
94+
}, false);
95+
}
96+
97+
async function uploadImageAndUpdateTextArea(fileName, base64Data, contentType, textArea) {
98+
try {
99+
const response = await fetch('/api/images', {
100+
method: 'POST',
101+
headers: {
102+
'Content-Type': 'application/json',
103+
},
104+
body: JSON.stringify({
105+
base64Data: base64Data,
106+
contentType: contentType,
107+
fileName: fileName
108+
})
109+
});
110+
111+
if (response.ok) {
112+
const result = await response.json();
113+
const markdownText = `![${fileName}](${result.imageUri})`;
114+
const currentValue = textArea.value || '';
115+
// can we insert at the cursor position?
116+
const newValue = currentValue.length > 0 ? `${currentValue}${markdownText}` : markdownText;
117+
118+
textArea.value = newValue;
119+
textArea.dispatchEvent(new CustomEvent('input', { bubbles: true }));
120+
textArea.dispatchEvent(new CustomEvent('change', { bubbles: true }));
121+
resizeToFit(textArea);
122+
} else {
123+
console.error('Failed to upload image:', response.statusText);
124+
}
125+
} catch (error) {
126+
console.error('Error uploading image:', error);
127+
}
128+
}

imageGeneratorSample.Web/Components/Pages/Chat/ChatMessageItem.razor

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
@if (Message.Role == ChatRole.User)
66
{
77
<div class="user-message">
8-
@Message.Text
8+
<markdown-message markdown="@Message.Text" />
99
</div>
1010
}
1111
else if (Message.Role == ChatRole.Assistant)
@@ -24,7 +24,7 @@ else if (Message.Role == ChatRole.Assistant)
2424
</div>
2525
<div class="assistant-message-header">Assistant</div>
2626
<div class="assistant-message-text">
27-
<assistant-message markdown="@text"></assistant-message>
27+
<markdown-message markdown="@text"></markdown-message>
2828

2929
@foreach (var citation in citations ?? [])
3030
{

0 commit comments

Comments
 (0)