Skip to content

Commit dc986b5

Browse files
authored
[C#] feat: AssistantsPlanner file upload and download support (#1945)
## Linked issues fixes #1919 ## Details * `AssistantsPlanner` now supports passing in attached files and images within the user message. Files will be uploaded through the OpenAI/Azure OpenAI `Files` API and the `file_id` is attached to the message. * `AssistantsPlanner` now supports downloading files and images generated in a single run. Only image files are attached to the outgoing activity as a list of `Attachments` in the `PredictedSayCommand` default action. #### Change details * Created an `AssistantsMessage` class that extends `ChatMessage`. It stores a single `MessageContent` and files generated with in it in the `AttachedFiles` property. * Added a `FileClient` field to the `AssistantsPlanner`. It wraps around the `Files` api. * Added `FileName` field in `InputFile.cs` class. A filename is required to upload a file to `Files` api. **Samples Updates** * `OrderBot` is now configured with the `file_search` tool. A vector store is created, and the `menu.pdf` file is uploaded to it and the store is attached to the assistant on creation. Users can ask for the menu items or prices and the assistant will be using the `file_search` tool under the hood to get that information. * `MathBot` has no updates - but it can be used to get the assistant to generate a png image of a graph. ## Attestation Checklist - [x] My code follows the style guidelines of this project - I have checked for/fixed spelling, linting, and other errors - I have commented my code for clarity - I have made corresponding changes to the documentation (updating the doc strings in the code is sufficient) - My changes generate no new warnings - I have added tests that validates my changes, and provides sufficient test coverage. I have tested with: - Local testing - E2E testing in Teams - New and existing unit tests pass locally with my changes ### Additional information > Feel free to add other relevant information below
1 parent 7fc1b05 commit dc986b5

File tree

16 files changed

+463
-60
lines changed

16 files changed

+463
-60
lines changed
Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1-
using Microsoft.Teams.AI.AI.Models;
1+
using System.ClientModel;
2+
using System.ClientModel.Primitives;
3+
using Microsoft.Teams.AI.AI.Models;
24
using Microsoft.Teams.AI.Tests.TestUtils;
5+
using Moq;
36
using OpenAI.Assistants;
7+
using OpenAI.Files;
48

59
namespace Microsoft.Teams.AI.Tests.AITests
610
{
@@ -11,19 +15,27 @@ public void Test_Constructor()
1115
{
1216
// Arrange
1317
MessageContent content = OpenAIModelFactory.CreateMessageContent("message", "fileId");
18+
Mock<FileClient> fileClientMock = new Mock<FileClient>();
19+
fileClientMock.Setup(fileClient => fileClient.DownloadFileAsync("fileId", It.IsAny<CancellationToken>())).Returns(() =>
20+
{
21+
return Task.FromResult(ClientResult.FromValue(BinaryData.FromString("test"), new Mock<PipelineResponse>().Object));
22+
});
23+
fileClientMock.Setup(fileClient => fileClient.GetFileAsync("fileId", It.IsAny<CancellationToken>())).Returns(() =>
24+
{
25+
return Task.FromResult(ClientResult.FromValue(OpenAIModelFactory.CreateOpenAIFileInfo("fileId"), new Mock<PipelineResponse>().Object));
26+
});
1427

1528
// Act
16-
AssistantsMessage assistantMessage = new AssistantsMessage(content);
29+
AssistantsMessage assistantMessage = new AssistantsMessage(content, fileClientMock.Object);
1730

1831
// Assert
19-
Assert.Equal(assistantMessage.MessageContent, content);
32+
Assert.Equal(content, assistantMessage.MessageContent);
33+
Assert.Equal("message", assistantMessage.Content);
34+
Assert.Equal(1, assistantMessage.AttachedFiles!.Count);
35+
Assert.Equal("fileId", assistantMessage.AttachedFiles[0].FileInfo.Id);
2036

2137
ChatMessage chatMessage = assistantMessage;
2238
Assert.NotNull(chatMessage);
23-
Assert.Equal(chatMessage.Content, "message");
24-
Assert.Equal(chatMessage.Context!.Citations[0].Url, "fileId");
25-
Assert.Equal(chatMessage.Context.Citations[0].Title, "");
26-
Assert.Equal(chatMessage.Context.Citations[0].Content, "");
2739
}
2840
}
2941
}

dotnet/packages/Microsoft.TeamsAI/Microsoft.TeamsAI.Tests/AITests/Models/ChatCompletionToolCallTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
namespace Microsoft.Teams.AI.Tests.AITests.Models
66
{
7-
internal class ChatCompletionToolCallTests
7+
internal sealed class ChatCompletionToolCallTests
88
{
99
[Fact]
1010
public void Test_ChatCompletionsToolCall_ToFunctionToolCall()

dotnet/packages/Microsoft.TeamsAI/Microsoft.TeamsAI.Tests/TestUtils/OpenAIModelFactory.cs

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
using OpenAI.Assistants;
1+
using Microsoft.VisualStudio.TestPlatform.CommunicationUtilities;
2+
using OpenAI.Assistants;
3+
using OpenAI.Files;
24
using System.ClientModel;
35
using System.ClientModel.Primitives;
46

@@ -69,6 +71,12 @@ public static MessageContent CreateMessageContent(string message, string fileId)
6971
""file_citation"": {{
7072
""file_id"": ""{fileId}""
7173
}}
74+
}},
75+
{{
76+
""type"": ""file_path"",
77+
""file_path"": {{
78+
""file_id"": ""{fileId}""
79+
}}
7280
}}
7381
]
7482
}}
@@ -82,6 +90,22 @@ public static MessageContent CreateMessageContent(string message, string fileId)
8290
return threadMessage.Content[0];
8391
}
8492

93+
public static OpenAIFileInfo CreateOpenAIFileInfo(string fileId)
94+
{
95+
var json = @$"{{
96+
""id"": ""{fileId}"",
97+
""object"": ""file"",
98+
""bytes"": 120000,
99+
""created_at"": 16761602,
100+
""filename"": ""salesOverview.pdf"",
101+
""purpose"": ""assistants""
102+
}}";
103+
104+
var fileInfo = ModelReaderWriter.Read<OpenAIFileInfo>(BinaryData.FromString(json))!;
105+
106+
return fileInfo;
107+
}
108+
85109
public static ThreadRun CreateThreadRun(string threadId, string runStatus, string? runId = null, IList<RequiredAction> requiredActions = null!)
86110
{
87111
var raJson = "{}";

dotnet/packages/Microsoft.TeamsAI/Microsoft.TeamsAI/AI/Action/DefaultActions.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,12 +141,19 @@ public async Task<string> SayCommandAsync([ActionTurnContext] ITurnContext turnC
141141
entity.Citation = referencedCitations;
142142
}
143143

144+
List<Attachment>? attachments = new();
145+
if (command.Response.Attachments != null)
146+
{
147+
attachments = command.Response.Attachments;
148+
}
149+
144150
await turnContext.SendActivityAsync(new Activity()
145151
{
146152
Type = ActivityTypes.Message,
147153
Text = contentText,
148154
ChannelData = channelData,
149-
Entities = new List<Entity>() { entity }
155+
Entities = new List<Entity>() { entity },
156+
Attachments = attachments
150157
}, cancellationToken);
151158

152159
return string.Empty;

dotnet/packages/Microsoft.TeamsAI/Microsoft.TeamsAI/AI/Models/AssistantsMessage.cs

Lines changed: 174 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
using OpenAI.Assistants;
1+
using System.ClientModel;
2+
using Microsoft.Bot.Schema;
3+
using OpenAI.Assistants;
4+
using OpenAI.Files;
5+
26

37
namespace Microsoft.Teams.AI.AI.Models
48
{
@@ -12,46 +16,189 @@ public class AssistantsMessage : ChatMessage
1216
/// </summary>
1317
public MessageContent MessageContent;
1418

19+
/// <summary>
20+
/// Files attached to the assistants api message.
21+
/// </summary>
22+
public List<OpenAIFile>? AttachedFiles { get; }
23+
1524
/// <summary>
1625
/// Creates an AssistantMessage.
1726
/// </summary>
1827
/// <param name="content">The Assistants API thread message.</param>
19-
public AssistantsMessage(MessageContent content) : base(ChatRole.Assistant)
28+
/// <param name="fileClient">The OpenAI File client.</param>
29+
public AssistantsMessage(MessageContent content, FileClient? fileClient = null) : base(ChatRole.Assistant)
2030
{
2131
this.MessageContent = content;
2232

23-
if (content != null)
33+
if (content == null)
34+
{
35+
throw new ArgumentNullException(nameof(content));
36+
}
37+
38+
string textContent = content.Text ?? "";
39+
MessageContext context = new();
40+
41+
List<Task<ClientResult<BinaryData>>> fileContentDownloadTasks = new();
42+
List<Task<ClientResult<OpenAIFileInfo>>> fileInfoDownloadTasks = new();
43+
44+
for (int i = 0; i < content.TextAnnotations.Count; i++)
45+
{
46+
TextAnnotation annotation = content.TextAnnotations[i];
47+
if (annotation?.TextToReplace != null)
48+
{
49+
textContent = textContent.Replace(annotation.TextToReplace, $"[{i + 1}]");
50+
}
51+
52+
if (annotation?.InputFileId != null)
53+
{
54+
// Retrieve file info object
55+
// Neither `content` or `title` is provided in the annotations.
56+
context.Citations.Add(new("Content not available", $"File {i + 1}", annotation.InputFileId));
57+
}
58+
59+
if (annotation?.OutputFileId != null && fileClient != null)
60+
{
61+
// Files generated by code interpretor tool.
62+
fileContentDownloadTasks.Add(fileClient.DownloadFileAsync(annotation.OutputFileId));
63+
fileInfoDownloadTasks.Add(fileClient.GetFileAsync(annotation.OutputFileId));
64+
}
65+
}
66+
67+
List<OpenAIFile> attachedFiles = new();
68+
if (fileContentDownloadTasks.Count > 0)
69+
{
70+
Task.WaitAll(fileContentDownloadTasks.ToArray());
71+
Task.WaitAll(fileInfoDownloadTasks.ToArray());
72+
73+
// Create attachments out of these downloaded files
74+
// Wait for tasks to complete
75+
ClientResult<BinaryData>[] downloadedFileContent = fileContentDownloadTasks.Select((task) => task.Result).ToArray();
76+
ClientResult<OpenAIFileInfo>[] downloadedFileInfo = fileInfoDownloadTasks.Select((task) => task.Result).ToArray();
77+
78+
for (int i = 0; i < downloadedFileContent.Length; i++)
79+
{
80+
attachedFiles.Add(new OpenAIFile(downloadedFileInfo[i], downloadedFileContent[i]));
81+
}
82+
}
83+
84+
this.AttachedFiles = attachedFiles;
85+
this.Attachments = _ConvertAttachedImagesToActivityAttachments(attachedFiles);
86+
87+
this.Content = textContent;
88+
this.Context = context;
89+
}
90+
91+
private List<Attachment> _ConvertAttachedImagesToActivityAttachments(List<OpenAIFile> attachedFiles)
92+
{
93+
List<Attachment> attachments = new();
94+
95+
foreach (OpenAIFile file in attachedFiles)
2496
{
25-
string? textContent = content.Text;
26-
if (content.Text != null && content.Text != string.Empty)
97+
string? mimetype = file.GetMimeType();
98+
string[] imageMimeTypes = new string[] { "image/png", "image/jpg", "image/jpeg", "image/gif" };
99+
if (mimetype == null)
27100
{
28-
this.Content = content.Text;
101+
continue;
29102
}
30103

31-
MessageContext context = new();
32-
for (int i = 0; i < content.TextAnnotations.Count; i++)
104+
if (!imageMimeTypes.Contains(mimetype))
33105
{
34-
TextAnnotation annotation = content.TextAnnotations[i];
35-
if (annotation?.TextToReplace != null)
36-
{
37-
textContent.Replace(annotation.TextToReplace, $"[{i}]");
38-
}
39-
40-
if (annotation?.InputFileId != null)
41-
{
42-
// Retrieve file info object
43-
// Neither `content` or `title` is provided in the annotations
44-
context.Citations.Add(new("", "", annotation.InputFileId));
45-
}
46-
47-
if (annotation?.OutputFileId != null)
48-
{
49-
// TODO: Download files or provide link to end user.
50-
// Files were generated by code interpretor tool.
51-
}
106+
// Skip non image file types
107+
continue;
52108
}
53109

54-
Context = context;
110+
string imageBase64String = Convert.ToBase64String(file.FileContent.ToArray());
111+
attachments.Add(new Attachment
112+
{
113+
Name = file.FileInfo.Filename,
114+
ContentType = mimetype,
115+
ContentUrl = $"data:image/png;base64,{imageBase64String}",
116+
});
117+
}
118+
119+
return attachments;
120+
}
121+
}
122+
123+
/// <summary>
124+
/// Represents an OpenAI File.
125+
/// </summary>
126+
public class OpenAIFile
127+
{
128+
/// <summary>
129+
/// Represents an OpenAI File information
130+
/// </summary>
131+
public OpenAIFileInfo FileInfo;
132+
133+
/// <summary>
134+
/// Represents the contents of an OpenAI File
135+
/// </summary>
136+
public BinaryData FileContent;
137+
138+
private static readonly Dictionary<string, string> MimeTypes = new(StringComparer.OrdinalIgnoreCase)
139+
{
140+
{ "c", "text/x-c" },
141+
{ "cs", "text/x-csharp" },
142+
{ "cpp", "text/x-c++" },
143+
{ "doc", "application/msword" },
144+
{ "docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document" },
145+
{ "html", "text/html" },
146+
{ "java", "text/x-java" },
147+
{ "json", "application/json" },
148+
{ "md", "text/markdown" },
149+
{ "pdf", "application/pdf" },
150+
{ "php", "text/x-php" },
151+
{ "pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation" },
152+
{ "py", "text/x-python" },
153+
{ "rb", "text/x-ruby" },
154+
{ "tex", "text/x-tex" },
155+
{ "txt", "text/plain" },
156+
{ "css", "text/css" },
157+
{ "js", "text/javascript" },
158+
{ "sh", "application/x-sh" },
159+
{ "ts", "application/typescript" },
160+
{ "csv", "application/csv" },
161+
{ "jpeg", "image/jpeg" },
162+
{ "jpg", "image/jpeg" },
163+
{ "gif", "image/gif" },
164+
{ "png", "image/png" },
165+
{ "tar", "application/x-tar" },
166+
{ "xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" },
167+
{ "xml", "application/xml" }, // or "text/xml"
168+
{ "zip", "application/zip" }
169+
};
170+
171+
/// <summary>
172+
/// Initializes an instance of OpenAIFile
173+
/// </summary>
174+
/// <param name="fileInfo">The OpenAI File</param>
175+
/// <param name="fileContent">The OpenAI File contents</param>
176+
public OpenAIFile(OpenAIFileInfo fileInfo, BinaryData fileContent)
177+
{
178+
FileInfo = fileInfo;
179+
FileContent = fileContent;
180+
}
181+
182+
/// <summary>
183+
/// Gets the file's mime type
184+
/// </summary>
185+
/// <returns>The file's mime type</returns>
186+
public string? GetMimeType()
187+
{
188+
bool hasExtension = FileInfo.Filename.Contains(".");
189+
if (!hasExtension)
190+
{
191+
return null;
192+
}
193+
194+
string fileExtension = FileInfo.Filename.Split(new char[] { '.' }).Last();
195+
if (MimeTypes.TryGetValue(fileExtension, out string mimeType))
196+
{
197+
return mimeType;
198+
}
199+
else
200+
{
201+
return null;
55202
}
56203
}
57204
}

dotnet/packages/Microsoft.TeamsAI/Microsoft.TeamsAI/AI/Models/ChatMessage.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using Azure.AI.OpenAI;
22
using Azure.AI.OpenAI.Chat;
3+
using Microsoft.Bot.Schema;
34
using Microsoft.Teams.AI.Exceptions;
45
using Microsoft.Teams.AI.Utilities;
56
using OpenAI.Chat;
@@ -49,6 +50,10 @@ public class ChatMessage
4950
/// </summary>
5051
public IList<ChatCompletionsToolCall>? ToolCalls { get; set; }
5152

53+
/// <summary>
54+
/// Attachments for the bot to send back.
55+
/// </summary>
56+
public List<Attachment>? Attachments { get; set; }
5257

5358
/// <summary>
5459
/// Gets the content with the given type.

0 commit comments

Comments
 (0)