From da92bf2072bb119e923aa96a5bb9c087e59d2b3e Mon Sep 17 00:00:00 2001 From: Frederic Wickert Date: Tue, 10 Dec 2024 00:48:13 +0100 Subject: [PATCH 01/16] Add file upload support and integrate KernelMemory service Updated AgentController.cs to handle file uploads and remove unnecessary comments. Added AddKernelMemoryService method in SemanticKernelExtensions.cs and updated Program.cs to configure KernelMemory service. Added new KernelMemoryOptions in ServicesExtensions.cs. Introduced new properties in AgentItem.cs and AgentItem.ts. Enhanced AgentDialog.tsx to support file uploads with new FileUpload component. Added new files: FileUpload.tsx, DocIntOCREngine.cs, FileUploadController.cs, TestParseDocument.cs, DocuIntHandler.cs, and KernelMemoryOptions.cs. Updated VirtualTeacherGenAIDemo.Server.csproj with new package references. --- .../AI/DocIntOCREngine.cs | 12 +++ .../Controllers/AgentController.cs | 29 +++--- .../Controllers/FileUploadController.cs | 36 +++++++ .../Controllers/TestParseDocument.cs | 93 +++++++++++++++++++ .../Extensions/SemanticKernelExtensions.cs | 57 +++++++++++- .../Extensions/ServicesExtensions.cs | 5 +- .../Handler/DocuIntHandler.cs | 41 ++++++++ .../Models/Storage/AgentItem.cs | 1 + .../Options/KernelMemoryOptions.cs | 52 +++++++++++ VirtualTeacherGenAIDemo.Server/Program.cs | 12 ++- .../VirtualTeacherGenAIDemo.Server.csproj | 2 + .../src/components/Utilities/FileUpload.tsx | 48 ++++++++++ .../src/components/agent/AgentDialog.tsx | 31 +++++-- .../src/models/AgentItem.ts | 8 +- 14 files changed, 401 insertions(+), 26 deletions(-) create mode 100644 VirtualTeacherGenAIDemo.Server/AI/DocIntOCREngine.cs create mode 100644 VirtualTeacherGenAIDemo.Server/Controllers/FileUploadController.cs create mode 100644 VirtualTeacherGenAIDemo.Server/Controllers/TestParseDocument.cs create mode 100644 VirtualTeacherGenAIDemo.Server/Handler/DocuIntHandler.cs create mode 100644 VirtualTeacherGenAIDemo.Server/Options/KernelMemoryOptions.cs create mode 100644 virtualteachergenaidemo.client/src/components/Utilities/FileUpload.tsx diff --git a/VirtualTeacherGenAIDemo.Server/AI/DocIntOCREngine.cs b/VirtualTeacherGenAIDemo.Server/AI/DocIntOCREngine.cs new file mode 100644 index 0000000..b3504bf --- /dev/null +++ b/VirtualTeacherGenAIDemo.Server/AI/DocIntOCREngine.cs @@ -0,0 +1,12 @@ +using Microsoft.KernelMemory.DataFormats; + +namespace VirtualTeacherGenAIDemo.Server.AI +{ + public class DocIntOCREngine : IOcrEngine + { + public Task ExtractTextFromImageAsync(Stream imageContent, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + } +} diff --git a/VirtualTeacherGenAIDemo.Server/Controllers/AgentController.cs b/VirtualTeacherGenAIDemo.Server/Controllers/AgentController.cs index 07eaf27..d8e97bc 100644 --- a/VirtualTeacherGenAIDemo.Server/Controllers/AgentController.cs +++ b/VirtualTeacherGenAIDemo.Server/Controllers/AgentController.cs @@ -30,17 +30,13 @@ public async Task> GetAgentsByType(string type, bool with return await _agentService.GetByTypeAsync(type); } - //function to return all agents [HttpGet("All")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> GetAllAgents() { - return await _agentService.GetAllAgentsAsync(); } - - //function Get by Id [HttpGet("{id}")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> Get(string id, string type) @@ -56,7 +52,7 @@ public async Task> Get(string id, string type) [HttpPost] [ProducesResponseType(StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] - public async Task> Post([FromBody] AgentItem agent) + public async Task> Post(AgentItem agent) { if (agent == null) { @@ -64,15 +60,15 @@ public async Task> Post([FromBody] AgentItem agent) } agent.Id = Guid.NewGuid().ToString(); + await _agentService.AddAgentAsync(agent); return CreatedAtAction(nameof(Get), new { id = agent.Id, type = agent.Type }, agent); } - //For update agent [HttpPut("{id}")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status400BadRequest)] - public async Task Put(string id, [FromBody] AgentItem agent) + public async Task Put(string id, AgentItem agent) { if (agent == null || agent.Id != id) { @@ -85,23 +81,22 @@ public async Task Put(string id, [FromBody] AgentItem agent) return NotFound(); } + await _agentService.UpdateAgentAsync(agent); return NoContent(); - } - //For delete agent [HttpDelete("{agentid}")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task Delete(string agentid, string type ) + public async Task Delete(string agentid, string type) { if (string.IsNullOrWhiteSpace(agentid) || string.IsNullOrWhiteSpace(type)) { return BadRequest("Agent id or type is missing"); } - var agentToDelete = await _agentService.GetByIdAsync(agentid,type); + var agentToDelete = await _agentService.GetByIdAsync(agentid, type); if (agentToDelete == null) { return NotFound("Agent not found"); @@ -110,5 +105,17 @@ public async Task Delete(string agentid, string type ) await _agentService.DeleteAgentAsync(agentToDelete); return NoContent(); } + + private async Task HandleFileUpload(IFormFile file) + { + if (file != null && file.Length > 0) + { + var filePath = Path.Combine("uploads", file.FileName); + using (var stream = new FileStream(filePath, FileMode.Create)) + { + await file.CopyToAsync(stream); + } + } + } } } diff --git a/VirtualTeacherGenAIDemo.Server/Controllers/FileUploadController.cs b/VirtualTeacherGenAIDemo.Server/Controllers/FileUploadController.cs new file mode 100644 index 0000000..282c395 --- /dev/null +++ b/VirtualTeacherGenAIDemo.Server/Controllers/FileUploadController.cs @@ -0,0 +1,36 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using System.IO; +using System.Threading.Tasks; + +namespace VirtualTeacherGenAIDemo.Server.Controllers +{ + [Route("api/[controller]")] + [ApiController] + public class FileUploadController : ControllerBase + { + [HttpPost] + public async Task UploadFile(IFormFile file) + { + if (file == null || file.Length == 0) + { + return BadRequest("No file uploaded."); + } + + var uploadsFolderPath = Path.Combine(Directory.GetCurrentDirectory(), "uploads"); + if (!Directory.Exists(uploadsFolderPath)) + { + Directory.CreateDirectory(uploadsFolderPath); + } + + var filePath = Path.Combine(uploadsFolderPath, file.FileName); + + using (var stream = new FileStream(filePath, FileMode.Create)) + { + await file.CopyToAsync(stream); + } + + return Ok(new { FilePath = filePath }); + } + } +} diff --git a/VirtualTeacherGenAIDemo.Server/Controllers/TestParseDocument.cs b/VirtualTeacherGenAIDemo.Server/Controllers/TestParseDocument.cs new file mode 100644 index 0000000..223ff45 --- /dev/null +++ b/VirtualTeacherGenAIDemo.Server/Controllers/TestParseDocument.cs @@ -0,0 +1,93 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Azure; +using Azure.AI.DocumentIntelligence; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.KernelMemory; +using StackExchange.Redis; + +namespace VirtualTeacherGenAIDemo.Server.Controllers +{ + [Route("api/[controller]")] + [ApiController] + public class TestParseDocument : ControllerBase + { + private IKernelMemory _kernelMemory; + + public TestParseDocument([FromServices] IKernelMemory kernelMemory) + { + _kernelMemory = kernelMemory; + } + + [HttpGet] + public async Task Get() + { + string endpoint = "https://virtualteachdocint.cognitiveservices.azure.com/"; + string key = "7bANKH6Dm7ZIqfrXl4dd3AwUYaQq8TkTrumeJbL4fXXV5bcy8NQhJQQJ99ALAC5RqLJXJ3w3AAALACOGGNQ4"; + + var client = new DocumentIntelligenceClient(new Uri(endpoint), new AzureKeyCredential(key)); + + string filePath = @"D:\temp\chanel\page 7.pdf"; + if (!System.IO.File.Exists(filePath)) + { + return NotFound("File not found"); + } + + + using (var stream = System.IO.File.OpenRead(filePath)) + { + var binaryData = BinaryData.FromStream(stream); + + var content = new AnalyzeDocumentContent() { Base64Source = binaryData }; + + + int i = 1; + + while (true) + { + try + { + Operation operation = await client.AnalyzeDocumentAsync(WaitUntil.Completed, "prebuilt-layout", content, outputContentFormat: ContentFormat.Markdown, + pages: i.ToString()); + + + AnalyzeResult result = operation.Value; + + Console.WriteLine(result.Content); + + await _kernelMemory.DeleteDocumentAsync(i.ToString(), index: "test"); + + //Add tag with the agent ID => Thanif I search with agent I can filter by his id. + await _kernelMemory.ImportTextAsync(result.Content, i.ToString(), index: "test"); + i++; + } + catch (Exception ex) + { + break; + } + + + } + + + + //DocumentUploadRequest documentUploadRequest = new DocumentUploadRequest(); + //documentUploadRequest.Files.Add(new DocumentUploadRequest.UploadedFile() + //{ + // FileContent = stream, + // FileName = "page 7.pdf" + //}); + //documentUploadRequest.DocumentId = "page 7.pdf"; + //documentUploadRequest.Steps.Add(""); + + + + //var docIntOcrEngine = await _kernelMemory.ImportDocumentAsync(documentUploadRequest); + + + } + + return Ok(); + } + } +} diff --git a/VirtualTeacherGenAIDemo.Server/Extensions/SemanticKernelExtensions.cs b/VirtualTeacherGenAIDemo.Server/Extensions/SemanticKernelExtensions.cs index f8c0fc3..3f21a11 100644 --- a/VirtualTeacherGenAIDemo.Server/Extensions/SemanticKernelExtensions.cs +++ b/VirtualTeacherGenAIDemo.Server/Extensions/SemanticKernelExtensions.cs @@ -1,6 +1,8 @@ using Microsoft.Extensions.Options; +using Microsoft.KernelMemory; using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.Connectors.AzureOpenAI; +using VirtualTeacherGenAIDemo.Server.AI; using VirtualTeacherGenAIDemo.Server.Options; namespace VirtualTeacherGenAIDemo.Server.Extensions @@ -54,7 +56,60 @@ internal static IServiceCollection AddChatCompletionService(this IServiceCollect } - + internal static void AddKernelMemoryService(this WebApplicationBuilder appBuilder) + { + var servicesProvider = appBuilder.Services.BuildServiceProvider(); + var options = servicesProvider.GetRequiredService>().Value; + + + var azureOpenAIEmbeddingConfig = new AzureOpenAIConfig + { + Auth = options.Services.AzureOpenAIEmbedding.Auth, + Endpoint = options.Services.AzureOpenAIEmbedding.Endpoint, + APIKey = options.Services.AzureOpenAIEmbedding.APIKey, + Deployment = options.Services.AzureOpenAIEmbedding.Deployment, + MaxTokenTotal = options.Services.AzureOpenAIEmbedding.MaxTokenTotal, + EmbeddingDimensions = options.Services.AzureOpenAIEmbedding.EmbeddingDimensions, + MaxEmbeddingBatchSize = options.Services.AzureOpenAIEmbedding.MaxEmbeddingBatchSize, + MaxRetries = options.Services.AzureOpenAIEmbedding.MaxRetries + }; + + var azureOpenAITextConfig = new AzureOpenAIConfig + { + Auth = options.Services.AzureOpenAIText.Auth, + Endpoint = options.Services.AzureOpenAIText.Endpoint, + APIKey = options.Services.AzureOpenAIText.APIKey, + Deployment = options.Services.AzureOpenAIText.Deployment, + MaxTokenTotal = options.Services.AzureOpenAIText.MaxTokenTotal, + APIType = options.Services.AzureOpenAIText.APIType, + MaxRetries = options.Services.AzureOpenAIText.MaxRetries + }; + + var azureAISearchConfig = new AzureAISearchConfig + { + Auth = options.Services.AzureAISearch.Auth, + Endpoint = options.Services.AzureAISearch.Endpoint, + APIKey = options.Services.AzureAISearch.APIKey, + UseHybridSearch = options.Services.AzureAISearch.UseHybridSearch, + UseStickySessions = options.Services.AzureAISearch.UseStickySessions + }; + + //azureAISearchConfig.UseHybridSearch = true; + + var builder = new KernelMemoryBuilder(appBuilder.Services) + + .WithAzureOpenAITextEmbeddingGeneration(azureOpenAIEmbeddingConfig) + .WithAzureOpenAITextGeneration(azureOpenAITextConfig) + .WithAzureAISearchMemoryDb(azureAISearchConfig) + .WithSearchClientConfig(new SearchClientConfig { MaxMatchesCount = 5, Temperature = 0, TopP = 0 }) + .WithCustomImageOcr(new DocIntOCREngine()); ; + + + + builder.Services.AddSingleton(builder.Build()); + + + } /// diff --git a/VirtualTeacherGenAIDemo.Server/Extensions/ServicesExtensions.cs b/VirtualTeacherGenAIDemo.Server/Extensions/ServicesExtensions.cs index 78c3bc5..956b6a5 100644 --- a/VirtualTeacherGenAIDemo.Server/Extensions/ServicesExtensions.cs +++ b/VirtualTeacherGenAIDemo.Server/Extensions/ServicesExtensions.cs @@ -17,13 +17,16 @@ public static IServiceCollection AddOptions(this IServiceCollection services, Co AddOptions(CosmosOptions.PropertyName); - // Default AI service configurations for Semantic Kernel + AddOptions(KernelMemoryOptions.SectionName); + AddOptions(AIServiceOptions.PropertyName); AddOptions(AssistantOption.PropertyName); AddOptions(SpeechOptions.PropertyName); + + return services; void AddOptions(string propertyName) diff --git a/VirtualTeacherGenAIDemo.Server/Handler/DocuIntHandler.cs b/VirtualTeacherGenAIDemo.Server/Handler/DocuIntHandler.cs new file mode 100644 index 0000000..de47dec --- /dev/null +++ b/VirtualTeacherGenAIDemo.Server/Handler/DocuIntHandler.cs @@ -0,0 +1,41 @@ + +using Microsoft.KernelMemory.Diagnostics; +using Microsoft.KernelMemory.Pipeline; + +namespace VirtualTeacherGenAIDemo.Server.Handler +{ + public class DocuIntHandler : IPipelineStepHandler + { + public string StepName { get; } + private readonly IPipelineOrchestrator _orchestrator; + private readonly ILogger _log; + + public DocuIntHandler(IPipelineOrchestrator orchestrator, ILoggerFactory? loggerFactory = null) + { + _orchestrator = orchestrator; + this._log = (loggerFactory ?? DefaultLogger.Factory).CreateLogger(); + StepName = "DocuIntHandler"; + + this._log.LogInformation("Instantiating handler {0}...", this.GetType().FullName); + } + + public async Task<(ReturnType returnType, DataPipeline updatedPipeline)> InvokeAsync(DataPipeline pipeline, CancellationToken cancellationToken = default) + { + this._log.LogInformation("Running handler {0}...", this.GetType().FullName); + + Console.WriteLine("** My handler is working ** "); + + Console.WriteLine("Index: " + pipeline.Index); + Console.WriteLine("Document Id: " + pipeline.DocumentId); + Console.WriteLine("Steps: " + string.Join(", ", pipeline.Steps)); + Console.WriteLine("Remaining Steps: " + string.Join(", ", pipeline.RemainingSteps)); + + await Task.Delay(0, cancellationToken).ConfigureAwait(false); + + return (ReturnType.Success, pipeline); + + } + + + } +} diff --git a/VirtualTeacherGenAIDemo.Server/Models/Storage/AgentItem.cs b/VirtualTeacherGenAIDemo.Server/Models/Storage/AgentItem.cs index 0e6007c..6f8fc65 100644 --- a/VirtualTeacherGenAIDemo.Server/Models/Storage/AgentItem.cs +++ b/VirtualTeacherGenAIDemo.Server/Models/Storage/AgentItem.cs @@ -21,6 +21,7 @@ public class AgentItem : IStorageEntity [Required, NotEmptyOrWhitespace] public string Type { get; set; } = string.Empty; + public string FileName { get; set; } = string.Empty; [JsonIgnore] public string Partition => this.Type; diff --git a/VirtualTeacherGenAIDemo.Server/Options/KernelMemoryOptions.cs b/VirtualTeacherGenAIDemo.Server/Options/KernelMemoryOptions.cs new file mode 100644 index 0000000..e11053e --- /dev/null +++ b/VirtualTeacherGenAIDemo.Server/Options/KernelMemoryOptions.cs @@ -0,0 +1,52 @@ +using static Microsoft.KernelMemory.AzureOpenAIConfig; + +namespace VirtualTeacherGenAIDemo.Server.Options +{ + + + public class KernelMemoryOptions + { + public const string SectionName = "KernelMemory"; + public required Services Services { get; set; } + } + + public class Services + { + public required Azureaisearch AzureAISearch { get; set; } + public required Azureopenaiembedding AzureOpenAIEmbedding { get; set; } + public required Azureopenaitext AzureOpenAIText { get; set; } + } + + public class Azureaisearch + { + public Microsoft.KernelMemory.AzureAISearchConfig.AuthTypes Auth { get; set; } + public required string Endpoint { get; set; } + public required string APIKey { get; set; } + public bool UseHybridSearch { get; set; } + public bool UseStickySessions { get; set; } + } + + public class Azureopenaiembedding + { + public AuthTypes Auth { get; set; } + public required string Endpoint { get; set; } + public required string APIKey { get; set; } + public required string Deployment { get; set; } + public int MaxTokenTotal { get; set; } + public int? EmbeddingDimensions { get; set; } + public int MaxEmbeddingBatchSize { get; set; } + public int MaxRetries { get; set; } + } + + public class Azureopenaitext + { + public AuthTypes Auth { get; set; } + public required string Endpoint { get; set; } + public required string APIKey { get; set; } + public required string Deployment { get; set; } + public int MaxTokenTotal { get; set; } + public APITypes APIType { get; set; } + public int MaxRetries { get; set; } + } + +} diff --git a/VirtualTeacherGenAIDemo.Server/Program.cs b/VirtualTeacherGenAIDemo.Server/Program.cs index 04feda9..07853e8 100644 --- a/VirtualTeacherGenAIDemo.Server/Program.cs +++ b/VirtualTeacherGenAIDemo.Server/Program.cs @@ -1,8 +1,14 @@ +using Microsoft.KernelMemory; +using System.Reflection.PortableExecutable; using VirtualTeacherGenAIDemo.Server.Extensions; +using VirtualTeacherGenAIDemo.Server.Handler; using VirtualTeacherGenAIDemo.Server.Hubs; var builder = WebApplication.CreateBuilder(args); + + + // Add services to the container. builder.Services .AddSingleton(sp => sp.GetRequiredService>()) // some services require an un-templated ILogger @@ -11,7 +17,11 @@ .AddAIResponses() .AddServices() .AddSemanticKernelServices() - .AddChatCompletionService(); +.AddChatCompletionService(); + + + +builder.AddKernelMemoryService(); builder.Services.AddSignalR(options => options.MaximumParallelInvocationsPerClient = 10); diff --git a/VirtualTeacherGenAIDemo.Server/VirtualTeacherGenAIDemo.Server.csproj b/VirtualTeacherGenAIDemo.Server/VirtualTeacherGenAIDemo.Server.csproj index 545d1a8..985b495 100644 --- a/VirtualTeacherGenAIDemo.Server/VirtualTeacherGenAIDemo.Server.csproj +++ b/VirtualTeacherGenAIDemo.Server/VirtualTeacherGenAIDemo.Server.csproj @@ -40,11 +40,13 @@ + 8.0.11 + diff --git a/virtualteachergenaidemo.client/src/components/Utilities/FileUpload.tsx b/virtualteachergenaidemo.client/src/components/Utilities/FileUpload.tsx new file mode 100644 index 0000000..23b985a --- /dev/null +++ b/virtualteachergenaidemo.client/src/components/Utilities/FileUpload.tsx @@ -0,0 +1,48 @@ +import { useState, useEffect } from 'react'; +import { Button } from '@fluentui/react-button'; +import { Field } from '@fluentui/react-field'; + +interface FileUploadProps { + onFileUpload: (fileName: string) => void; + fileName?: string; +} + +export const FileUpload = ({ onFileUpload, fileName }: FileUploadProps) => { + const [file, setFile] = useState(null); + const [fileError, setFileError] = useState(''); + const [displayFileName, setDisplayFileName] = useState(fileName || ''); + + useEffect(() => { + if (fileName) { + setDisplayFileName(fileName); + } + }, [fileName]); + + const handleFileChange = (event: React.ChangeEvent) => { + const selectedFile = event.target.files?.[0] || null; + setFile(selectedFile); + setFileError(''); + if (selectedFile) { + setDisplayFileName(selectedFile.name); + onFileUpload(selectedFile.name); + } + }; + + const handleUploadClick = () => { + if (!file) { + setFileError('Please select a file to upload.'); + return; + } + onFileUpload(file.name); + }; + + return ( +
+ + + + + {displayFileName &&

File: {displayFileName}

} +
+ ); +}; diff --git a/virtualteachergenaidemo.client/src/components/agent/AgentDialog.tsx b/virtualteachergenaidemo.client/src/components/agent/AgentDialog.tsx index c5ca663..d32d135 100644 --- a/virtualteachergenaidemo.client/src/components/agent/AgentDialog.tsx +++ b/virtualteachergenaidemo.client/src/components/agent/AgentDialog.tsx @@ -8,8 +8,7 @@ import { useState, useEffect } from 'react'; import { AgentItem } from '../../models/AgentItem'; import { makeStyles } from '@fluentui/react-components'; import { tokens } from '@fluentui/tokens'; - - +import { FileUpload } from '../Utilities/FileUpload'; interface AgentDialogProps { onAddAgent: (agent: AgentItem) => void; @@ -25,7 +24,7 @@ const useStyles = makeStyles({ color: "white", ':hover': { backgroundColor: tokens.colorPaletteRedForeground1, - color:'white', + color: 'white', }, }, }); @@ -40,16 +39,24 @@ export const AgentDialog = ({ onAddAgent, onDeleteAgent, type, onClose, agent }: const [promptError, setPromptError] = useState(''); const [isOpen, setIsOpen] = useState(true); const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false); + + const [fileName, setFileName] = useState(agent?.fileName || ''); useEffect(() => { if (agent) { setName(agent.name); setDescription(agent.description); setPrompt(agent.prompt); + setFileName(agent.fileName || ''); } }, [agent]); - const handleAddAgent = () => { + const handleFileUpload = (uploadedFileName: string) => { + + setFileName(uploadedFileName); + }; + + const handleUpsertAgent = () => { let valid = true; if (name.trim() === '') { setNameError('Name is required.'); @@ -74,7 +81,7 @@ export const AgentDialog = ({ onAddAgent, onDeleteAgent, type, onClose, agent }: if (!valid) return; - const newAgent = { name, description, prompt, type, id: agent?.id || "" }; + const newAgent = { name, description, prompt, type, id: agent?.id || "", fileName }; const apiUrl = agent ? `/api/agent/${agent.id}` : '/api/agent'; const method = agent ? 'PUT' : 'POST'; @@ -84,24 +91,24 @@ export const AgentDialog = ({ onAddAgent, onDeleteAgent, type, onClose, agent }: headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify(newAgent), + body: JSON.stringify(agent), }) .then(response => { if (response.status === 204) { - return null; // No content to parse + return null; } return response.json(); }) .then(data => { console.log('Success:', data); if (!agent) { - newAgent.id = data.id; // Assuming the API returns the new agent's ID + newAgent.id = data.id; } onAddAgent(newAgent) }) .catch(error => console.error('Error:', error)); - setIsOpen(false); // Close the dialog + setIsOpen(false); onClose(); }; @@ -170,12 +177,16 @@ export const AgentDialog = ({ onAddAgent, onDeleteAgent, type, onClose, agent }: /> + +
+ +
{agent && ( )} - + diff --git a/virtualteachergenaidemo.client/src/models/AgentItem.ts b/virtualteachergenaidemo.client/src/models/AgentItem.ts index 103da24..94a921c 100644 --- a/virtualteachergenaidemo.client/src/models/AgentItem.ts +++ b/virtualteachergenaidemo.client/src/models/AgentItem.ts @@ -3,14 +3,18 @@ export class AgentItem { name: string; description: string; prompt: string; - type:string + type: string; + fileName:string - constructor(id: string, name: string, description:string, prompt:string, type:string) { + + constructor(id: string, name: string, description:string, prompt:string, type:string, fileName:string) { this.id = id; this.description = description; this.name = name; this.prompt = prompt; this.type = type; + this.fileName = fileName; + } } From dda36cf69168f9248775c2bae1572d1f99fb74c1 Mon Sep 17 00:00:00 2001 From: Frederic Wickert Date: Tue, 10 Dec 2024 23:52:21 +0100 Subject: [PATCH 02/16] Add connectionId handling, new methods, and refactoring Updated ChatController, FileUploadController, SessionController, ChatResponse, ChatService, LLMResponse, and SessionService to handle connectionId and new methods for deleting messages and sessions. Converted SessionItem and SessionAgent interfaces to classes. Improved chatWindow.tsx to handle new API endpoints. Removed unused import in training.tsx and updated state variables. Added new Request and Response models in both TypeScript and C#. Various formatting and minor refactoring changes for better readability and consistency. --- .../Controllers/ChatController.cs | 8 +- .../Controllers/FileUploadController.cs | 56 +++++- .../Controllers/SessionController.cs | 15 +- .../Models/MessageReponse.cs | 26 --- .../Models/Request/DeleteMessageRequest.cs | 8 + .../Models/Request/DeleteSessionRequest.cs | 8 + .../Models/Response/MessageReponse.cs | 16 ++ .../Services/ChatResponse.cs | 61 +++--- .../Services/ChatService.cs | 15 +- .../Services/LLMResponse.cs | 37 ++-- .../Services/SessionService.cs | 18 ++ .../src/components/chat/chatWindow.tsx | 183 ++++++++++++------ .../models/Request/DeleteMessageRequest.ts | 9 + .../models/Request/DeleteSessionRequest.ts | 9 + .../src/models/SessionItem.ts | 23 ++- .../src/views/training/training.tsx | 10 +- .../virtualteachergenaidemo.client.esproj | 1 + 17 files changed, 331 insertions(+), 172 deletions(-) delete mode 100644 VirtualTeacherGenAIDemo.Server/Models/MessageReponse.cs create mode 100644 VirtualTeacherGenAIDemo.Server/Models/Request/DeleteMessageRequest.cs create mode 100644 VirtualTeacherGenAIDemo.Server/Models/Request/DeleteSessionRequest.cs create mode 100644 VirtualTeacherGenAIDemo.Server/Models/Response/MessageReponse.cs create mode 100644 virtualteachergenaidemo.client/src/models/Request/DeleteMessageRequest.ts create mode 100644 virtualteachergenaidemo.client/src/models/Request/DeleteSessionRequest.ts diff --git a/VirtualTeacherGenAIDemo.Server/Controllers/ChatController.cs b/VirtualTeacherGenAIDemo.Server/Controllers/ChatController.cs index 91d3f1b..82ad116 100644 --- a/VirtualTeacherGenAIDemo.Server/Controllers/ChatController.cs +++ b/VirtualTeacherGenAIDemo.Server/Controllers/ChatController.cs @@ -22,9 +22,9 @@ public ChatController(ChatService chatService) [HttpPost("message",Name = "message")] [ProducesResponseType(StatusCodes.Status200OK)] - public IResult Post([FromBody] ChatHistoryRequest chatHistory, CancellationToken token) + public IResult Post([FromBody] ChatHistoryRequest chatHistory, string connectionId, CancellationToken token) { - return _chatService.GetChat(chatHistory, token); + return _chatService.GetChat(chatHistory, connectionId, token); } @@ -41,9 +41,9 @@ public IResult Post([FromBody] ChatHistoryRequest chatHistory, CancellationToken //Delete chat message [HttpDelete("message/{messageId}")] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task DeleteMessage(string messageId, string chatid, CancellationToken token) + public async Task DeleteMessage([FromBody] DeleteMessageRequest message, CancellationToken token) { - await _chatService.DeleteMessage(messageId, chatid); + await _chatService.DeleteMessage(message.MessageId, message.SessionId); } diff --git a/VirtualTeacherGenAIDemo.Server/Controllers/FileUploadController.cs b/VirtualTeacherGenAIDemo.Server/Controllers/FileUploadController.cs index 282c395..faa56dc 100644 --- a/VirtualTeacherGenAIDemo.Server/Controllers/FileUploadController.cs +++ b/VirtualTeacherGenAIDemo.Server/Controllers/FileUploadController.cs @@ -1,7 +1,10 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Azure; +using Azure.AI.DocumentIntelligence; using System.IO; using System.Threading.Tasks; +using Microsoft.KernelMemory; namespace VirtualTeacherGenAIDemo.Server.Controllers { @@ -9,6 +12,13 @@ namespace VirtualTeacherGenAIDemo.Server.Controllers [ApiController] public class FileUploadController : ControllerBase { + private readonly IKernelMemory _kernelMemory; + + public FileUploadController([FromServices] IKernelMemory kernelMemory) + { + _kernelMemory = kernelMemory; + } + [HttpPost] public async Task UploadFile(IFormFile file) { @@ -17,20 +27,52 @@ public async Task UploadFile(IFormFile file) return BadRequest("No file uploaded."); } - var uploadsFolderPath = Path.Combine(Directory.GetCurrentDirectory(), "uploads"); - if (!Directory.Exists(uploadsFolderPath)) + using (var stream = file.OpenReadStream()) { - Directory.CreateDirectory(uploadsFolderPath); + // Call the method to parse the uploaded file stream + var parseResult = await ParseDocument(stream); + + if (!parseResult) + { + return StatusCode(StatusCodes.Status500InternalServerError, "Error parsing the document."); + } } - var filePath = Path.Combine(uploadsFolderPath, file.FileName); + return Ok(); + } - using (var stream = new FileStream(filePath, FileMode.Create)) + private async Task ParseDocument(Stream fileStream) + { + string endpoint = "https://virtualteachdocint.cognitiveservices.azure.com/"; + string key = "7bANKH6Dm7ZIqfrXl4dd3AwUYaQq8TkTrumeJbL4fXXV5bcy8NQhJQQJ99ALAC5RqLJXJ3w3AAALACOGGNQ4"; + + var client = new DocumentIntelligenceClient(new Uri(endpoint), new AzureKeyCredential(key)); + + var binaryData = BinaryData.FromStream(fileStream); + var content = new AnalyzeDocumentContent() { Base64Source = binaryData }; + + int i = 1; + + while (true) { - await file.CopyToAsync(stream); + try + { + Operation operation = await client.AnalyzeDocumentAsync(WaitUntil.Completed, "prebuilt-layout", content, outputContentFormat: ContentFormat.Markdown, pages: i.ToString()); + AnalyzeResult result = operation.Value; + + Console.WriteLine(result.Content); + + await _kernelMemory.DeleteDocumentAsync(i.ToString(), index: "test"); + await _kernelMemory.ImportTextAsync(result.Content, i.ToString(), index: "test"); + i++; + } + catch (Exception) + { + break; + } } - return Ok(new { FilePath = filePath }); + return true; } } } diff --git a/VirtualTeacherGenAIDemo.Server/Controllers/SessionController.cs b/VirtualTeacherGenAIDemo.Server/Controllers/SessionController.cs index 228395a..4d3cf0d 100644 --- a/VirtualTeacherGenAIDemo.Server/Controllers/SessionController.cs +++ b/VirtualTeacherGenAIDemo.Server/Controllers/SessionController.cs @@ -3,6 +3,7 @@ using VirtualTeacherGenAIDemo.Server.Models.Request; using VirtualTeacherGenAIDemo.Server.Models.Storage; using VirtualTeacherGenAIDemo.Server.Services; +using VirtualTeacherGenAIDemo.Server.Storage; namespace VirtualTeacherGenAIDemo.Server.Controllers { @@ -18,7 +19,7 @@ public SessionController(SessionService sessionService) } - [HttpGet("history/{userId}", Name ="history")] + [HttpGet("history/{userId}", Name = "history")] [ProducesResponseType(StatusCodes.Status200OK)] public IEnumerable Get(string userId, CancellationToken token) { @@ -34,7 +35,7 @@ public async Task CompleteSession([FromBody] CompleteSessionRequest request, Can } //Get all not complete session - [HttpGet("notCompleted/{userId}", Name ="sessions")] + [HttpGet("notCompleted/{userId}", Name = "sessions")] public IEnumerable GetNotCompletedSessions(string userId) { return _sessionService.GetNotCompletedSession(userId); @@ -47,5 +48,15 @@ public async Task GetSession(string id, string userId) { return await _sessionService.GetSession(id, userId); } + + [HttpDelete("delete")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task DeleteSession([FromBody] DeleteSessionRequest request, [FromServices] MessageRepository messageRepository, CancellationToken token) + { + await _sessionService.DeleteSessionAsync(request.SessionId, request.UserId, messageRepository, token); + return Ok(); + } + + } } diff --git a/VirtualTeacherGenAIDemo.Server/Models/MessageReponse.cs b/VirtualTeacherGenAIDemo.Server/Models/MessageReponse.cs deleted file mode 100644 index e15318a..0000000 --- a/VirtualTeacherGenAIDemo.Server/Models/MessageReponse.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Microsoft.SemanticKernel.ChatCompletion; - -namespace VirtualTeacherGenAIDemo.Server.Models -{ - public class MessageResponse - { - public string MessageId { get; set; } = string.Empty; - - public string Content { get; set; } = string.Empty; - - /// - /// State = Start, InProgress, End - /// - public string State { get; set; } = string.Empty; - - /// - /// For target the right input in html client - /// - public string WhatAbout { get; set; } = string.Empty; - - public AuthorRole? Role { get; set; } - - public string SessionId { get; set; } = string.Empty; - - } -} diff --git a/VirtualTeacherGenAIDemo.Server/Models/Request/DeleteMessageRequest.cs b/VirtualTeacherGenAIDemo.Server/Models/Request/DeleteMessageRequest.cs new file mode 100644 index 0000000..a915f32 --- /dev/null +++ b/VirtualTeacherGenAIDemo.Server/Models/Request/DeleteMessageRequest.cs @@ -0,0 +1,8 @@ +namespace VirtualTeacherGenAIDemo.Server.Models.Request +{ + public class DeleteMessageRequest + { + public string MessageId { get; set; } + public string SessionId { get; set; } + } +} diff --git a/VirtualTeacherGenAIDemo.Server/Models/Request/DeleteSessionRequest.cs b/VirtualTeacherGenAIDemo.Server/Models/Request/DeleteSessionRequest.cs new file mode 100644 index 0000000..96719f6 --- /dev/null +++ b/VirtualTeacherGenAIDemo.Server/Models/Request/DeleteSessionRequest.cs @@ -0,0 +1,8 @@ +namespace VirtualTeacherGenAIDemo.Server.Models.Request +{ + public class DeleteSessionRequest + { + public required string SessionId { get; set; } + public required string UserId { get; set; } + } +} diff --git a/VirtualTeacherGenAIDemo.Server/Models/Response/MessageReponse.cs b/VirtualTeacherGenAIDemo.Server/Models/Response/MessageReponse.cs new file mode 100644 index 0000000..235eed1 --- /dev/null +++ b/VirtualTeacherGenAIDemo.Server/Models/Response/MessageReponse.cs @@ -0,0 +1,16 @@ +using Microsoft.SemanticKernel.ChatCompletion; + +namespace VirtualTeacherGenAIDemo.Server.Models.Response +{ + public class MessageResponse + { + public string MessageId { get; set; } = string.Empty; + + public string Content { get; set; } = string.Empty; + + public AuthorRole? Role { get; set; } + + public string SessionId { get; set; } = string.Empty; + + } +} diff --git a/VirtualTeacherGenAIDemo.Server/Services/ChatResponse.cs b/VirtualTeacherGenAIDemo.Server/Services/ChatResponse.cs index 25e2a70..522c8d5 100644 --- a/VirtualTeacherGenAIDemo.Server/Services/ChatResponse.cs +++ b/VirtualTeacherGenAIDemo.Server/Services/ChatResponse.cs @@ -4,7 +4,7 @@ using Microsoft.SemanticKernel.ChatCompletion; using Microsoft.SemanticKernel.Connectors.AzureOpenAI; using VirtualTeacherGenAIDemo.Server.Hubs; -using VirtualTeacherGenAIDemo.Server.Models; +using VirtualTeacherGenAIDemo.Server.Models.Response; using VirtualTeacherGenAIDemo.Server.Models.Storage; using VirtualTeacherGenAIDemo.Server.Storage; @@ -15,21 +15,19 @@ public class ChatResponse private readonly ILogger _logger; private readonly IHubContext _messageRelayHubContext; private readonly AzureOpenAIChatCompletionService _chat; - private readonly Kernel _kernel; private readonly int DELAY = 25; public ChatResponse(ILogger logger, - [FromServices] Kernel kernel, [FromServices] AzureOpenAIChatCompletionService chat, [FromServices] IHubContext messageRelayHubContext) { _logger = logger; - _kernel = kernel; + _chat = chat; _messageRelayHubContext = messageRelayHubContext; } - public async Task StartChat(string persona, + public async Task StartChat(string connectionId, string persona, ChatHistory chatHistory, string userId, SessionItem session, @@ -37,7 +35,7 @@ public async Task StartChat(string persona, SessionRepository sessionRepository, CancellationToken token) { - + ChatMessageContent promptSystem = chatHistory.FirstOrDefault(q => q.Role == AuthorRole.System)!; if (promptSystem != null) @@ -50,7 +48,7 @@ public async Task StartChat(string persona, if (string.IsNullOrEmpty(session.Id)) { - session.Id = Guid.NewGuid().ToString(); + session.Id = Guid.NewGuid().ToString(); SessionItem historyItem = new() { Id = session.Id, @@ -60,36 +58,34 @@ public async Task StartChat(string persona, ScenarioName = session.ScenarioName, ScenarioDescription = session.ScenarioDescription, Agents = session.Agents, - IsCompleted = false, + IsCompleted = false, }; await sessionRepository.UpsertAsync(historyItem); - + MessageResponse messageforUI = new() - { - SessionId = session.Id, - State = "Session", + { + SessionId = session.Id, }; - await this.UpdateMessageOnClient("SessionInsert", messageforUI, token); + await this.UpdateMessageOnClient("SessionInsert", messageforUI, connectionId, token); } MessageResponse response = new MessageResponse - { - State = "Start", - WhatAbout = "chat", + { SessionId = session.Id, - Role = AuthorRole.Assistant, + Role = AuthorRole.Assistant, + Content = string.Empty, + }; - await this.UpdateMessageOnClient("ReceiveMessageUpdate", response, token); + await this.UpdateMessageOnClient("StartMessageUpdate", response, connectionId, token); await foreach (StreamingChatMessageContent chatUpdate in _chat.GetStreamingChatMessageContentsAsync(chatHistory, cancellationToken: token)) { if (!string.IsNullOrEmpty(chatUpdate.Content)) - { - response.State = "InProgress"; + { response.Content += chatUpdate.Content; - await this.UpdateMessageOnClient("ReceiveMessageUpdate", response, token); + await this.UpdateMessageOnClient("InProgressMessageUpdate", response, connectionId, token); Console.Write(chatUpdate.Content); await Task.Delay(DELAY); } @@ -110,14 +106,11 @@ public async Task StartChat(string persona, await messageRepository.UpsertAsync(userMessage); MessageResponse messageforUI = new() - { - Content = lastMessage.Content!, - Role = AuthorRole.User, + { + MessageId = userMessage.Id, SessionId = session.Id, - State = "End", - MessageId = userMessage.Id }; - await this.UpdateMessageOnClient("UserIDUpdate", messageforUI, token); + await this.UpdateMessageOnClient("MessageIdUpdate", messageforUI, connectionId, token); } //ajouter le message dans la BD @@ -138,11 +131,11 @@ public async Task StartChat(string persona, message.SessionId = session.Id; messageRepository.UpsertAsync(message).GetAwaiter().GetResult(); - response.State = "End"; + response.MessageId = message.Id; - response.SessionId= message.SessionId; - chatHistory.AddAssistantMessage(response.Content); - await this.UpdateMessageOnClient("ReceiveMessageUpdate",response, token); + response.SessionId = message.SessionId; + //chatHistory.AddAssistantMessage(response.Content); + await this.UpdateMessageOnClient("EndMessageUpdate", response, connectionId, token); } @@ -150,11 +143,9 @@ public async Task StartChat(string persona, /// Update the response on the client. ///
/// The message - private async Task UpdateMessageOnClient(string hubconnection, MessageResponse message, CancellationToken token) + private async Task UpdateMessageOnClient(string hubconnection, MessageResponse message, string connectionId, CancellationToken token) { - await this._messageRelayHubContext.Clients.All.SendAsync(hubconnection, message, token); + await this._messageRelayHubContext.Clients.Client(connectionId).SendAsync(hubconnection, message, token); } - - } } diff --git a/VirtualTeacherGenAIDemo.Server/Services/ChatService.cs b/VirtualTeacherGenAIDemo.Server/Services/ChatService.cs index 4dd0208..ba89362 100644 --- a/VirtualTeacherGenAIDemo.Server/Services/ChatService.cs +++ b/VirtualTeacherGenAIDemo.Server/Services/ChatService.cs @@ -18,17 +18,17 @@ public class ChatService public ChatService([FromServices] MessageRepository messageRepository, [FromServices] SessionRepository sessionRepository, - [FromServices] ChatResponse chatResponse, + [FromServices] ChatResponse chatResponse, IOptions option) { _messageRepository = messageRepository; _sessionRepository = sessionRepository; _chatResponse = chatResponse; - _assistantOption = option.Value; + _assistantOption = option.Value; } //Creater function to call GetAsync in other thread without asunc/await and with cancellation token in parameters - public IResult GetChat(ChatHistoryRequest chatHistory, CancellationToken token) + public IResult GetChat(ChatHistoryRequest chatHistory, string connectionId, CancellationToken token) { //Transform ChatHistoryRequest to ChatHistory ChatHistory SKHistory = new ChatHistory(); @@ -44,8 +44,11 @@ public IResult GetChat(ChatHistoryRequest chatHistory, CancellationToken token) break; } } + if (chatHistory.Session != null) + { + Task.Run(() => _chatResponse.StartChat(connectionId, _assistantOption.Persona, SKHistory, chatHistory.UserId, chatHistory.Session, _messageRepository, _sessionRepository, token), token); + } - Task.Run(() => _chatResponse.StartChat(_assistantOption.Persona, SKHistory, chatHistory.UserId, chatHistory.Session, _messageRepository, _sessionRepository, token), token); return TypedResults.Ok("Chat requested"); } @@ -59,9 +62,9 @@ public async Task> GetChatMessages(string userId) } //Delete a message by id - public async Task DeleteMessage(string messageId, string userId) + public async Task DeleteMessage(string messageId, string sessionId) { - await _messageRepository.DeleteMessageByIdAsync(messageId, userId); + await _messageRepository.DeleteMessageByIdAsync(messageId, sessionId); } diff --git a/VirtualTeacherGenAIDemo.Server/Services/LLMResponse.cs b/VirtualTeacherGenAIDemo.Server/Services/LLMResponse.cs index cd50283..4ecbfb9 100644 --- a/VirtualTeacherGenAIDemo.Server/Services/LLMResponse.cs +++ b/VirtualTeacherGenAIDemo.Server/Services/LLMResponse.cs @@ -2,7 +2,7 @@ using Microsoft.AspNetCore.SignalR; using Microsoft.SemanticKernel; using VirtualTeacherGenAIDemo.Server.Hubs; -using VirtualTeacherGenAIDemo.Server.Models; +using VirtualTeacherGenAIDemo.Server.Models.Response; using VirtualTeacherGenAIDemo.Server.Models.Storage; using VirtualTeacherGenAIDemo.Server.Storage; @@ -60,54 +60,43 @@ private async Task StreamResponseCoachToClient(string whatAbout MessageResponse messageResponse = new MessageResponse { - State = "Start", - Content = "", - WhatAbout = whatAbout + Content = string.Empty, }; await foreach (StreamingChatMessageContent contentPiece in _kernel.InvokeStreamingAsync(_kernel.Plugins[this.PluginName][this.FunctionName], arguments, token)) { - await this.UpdateMessageOnClient(messageResponse, connectionId, token); - messageResponse.State = "InProgress"; - if (!string.IsNullOrEmpty(contentPiece.Content)) { messageResponse.Content += contentPiece.Content; - await this.UpdateMessageOnClient(messageResponse, connectionId, token); + await this.UpdateMessageOnClient("InProgressMessageUpdate", messageResponse, connectionId, token); Console.Write(contentPiece.Content); await Task.Delay(DELAY); } } - messageResponse.State = "End"; - await this.UpdateMessageOnClient(messageResponse, connectionId, token); + await this.UpdateMessageOnClient("EndMessageUpdate", messageResponse, connectionId, token); return messageResponse; } - private async Task StreamResponseToClient(string sessionId, string id, string whatAbout, KernelArguments arguments, string connectionId, CancellationToken token) + private async Task StreamResponseToClient(string sessionId, string id, string whatAbout, KernelArguments arguments, string connectionId, CancellationToken token) { MessageResponse messageResponse = new MessageResponse - { - State = "Start", - Content = "", - WhatAbout = whatAbout + { + Content = "", }; await foreach (StreamingChatMessageContent contentPiece in _kernel.InvokeStreamingAsync(_kernel.Plugins[this.PluginName][this.FunctionName], arguments, token)) { - await this.UpdateMessageOnClient( messageResponse, connectionId, token); - messageResponse.State = "InProgress"; - if (!string.IsNullOrEmpty(contentPiece.Content)) { messageResponse.Content += contentPiece.Content; - await this.UpdateMessageOnClient( messageResponse, connectionId, token); + await this.UpdateMessageOnClient(whatAbout, messageResponse, connectionId, token); Console.Write(contentPiece.Content); - await Task.Delay(DELAY); + //await Task.Delay(DELAY); } } - messageResponse.State = "End"; + string NewId = Guid.NewGuid().ToString(); //Save content in DB @@ -119,16 +108,16 @@ await this._dashboardRepository.UpsertAsync(new DashboardItem { }); //await this.UpdateMessageOnClient( messageResponse, connectionId, token); - return messageResponse; + } /// /// Update the response on the client. /// /// The message - private async Task UpdateMessageOnClient(MessageResponse message, string connectionId, CancellationToken token) + private async Task UpdateMessageOnClient(string hubconnection, MessageResponse message, string connectionId, CancellationToken token) { - await this._messageRelayHubContext.Clients.Client(connectionId).SendAsync(message.WhatAbout, message.Content, token); + await this._messageRelayHubContext.Clients.Client(connectionId).SendAsync(hubconnection, message, token); } } diff --git a/VirtualTeacherGenAIDemo.Server/Services/SessionService.cs b/VirtualTeacherGenAIDemo.Server/Services/SessionService.cs index bd19972..735ba14 100644 --- a/VirtualTeacherGenAIDemo.Server/Services/SessionService.cs +++ b/VirtualTeacherGenAIDemo.Server/Services/SessionService.cs @@ -44,5 +44,23 @@ public IEnumerable GetNotCompletedSession(string userId) { return _sessionRepository.GetNotCompleteSession(userId); } + + public async Task DeleteSessionAsync(string sessionId, string userId, MessageRepository messageRepository, CancellationToken token) + { + // Delete all messages associated with the session + var messages = await messageRepository.FindByChatIdAsync(sessionId); + foreach (var message in messages) + { + await messageRepository.DeleteAsync(message); + } + + // Delete the session + var session = await _sessionRepository.GetSessionById(sessionId, userId); + if (session != null) + { + await _sessionRepository.DeleteAsync(session); + } + } + } } diff --git a/virtualteachergenaidemo.client/src/components/chat/chatWindow.tsx b/virtualteachergenaidemo.client/src/components/chat/chatWindow.tsx index d7dade0..8b28a24 100644 --- a/virtualteachergenaidemo.client/src/components/chat/chatWindow.tsx +++ b/virtualteachergenaidemo.client/src/components/chat/chatWindow.tsx @@ -6,35 +6,37 @@ import { ChatHistoryRequest, ChatMessage } from '../../models/ChatHistoryRequest import { HubConnectionBuilder, HubConnection } from '@microsoft/signalr'; import { textToSpeechAsync, cancelSpeech } from '../../services/speechService'; import { ScenarioItem } from '../../models/ScenarioItem'; -import { ISessionItem } from '../../models/SessionItem'; +import { SessionItem } from '../../models/SessionItem'; import { Button } from '@fluentui/react-button'; -import { useUsername } from '../../auth/UserContext'; +import { useUsername } from '../../auth/UserContext'; +import { DeleteSessionRequest } from '../../models/Request/DeleteSessionRequest'; +import { DeleteMessageRequest } from '../../models/Request/DeleteMessageRequest'; enum AuthorRole { - User = 0, - Assistant = 1 + User, + Assistant } interface Message { - content: string; - sessionId?: string; + content: string; + sessionId?: string; id: string; authorRole: AuthorRole; } interface ChatWindowProps { - scenario?: ScenarioItem | undefined; - session?: ISessionItem; + scenario: ScenarioItem | null; + session: SessionItem | null; } function ChatWindow({ scenario, session }: ChatWindowProps) { const userName = useUsername(); const [messages, setMessages] = useState([]); - const [connection, setConnection] = useState(null); + const [connection, setConnection] = useState(null); const [sessionId, setSessionId] = useState("") const currentMessageRef = useRef(null); const [isSavingSession, setIsSavingSession] = useState(false); - + useEffect(() => { const hubUrl = process.env.HUB_URL; const newConnection = new HubConnectionBuilder() @@ -49,51 +51,61 @@ function ChatWindow({ scenario, session }: ChatWindowProps) { if (connection) { connection.start() .then(() => { - console.log('Connected to SignalR hub'); - - connection.on('ReceiveMessageUpdate', (message: any) => { - if (message.state === "Start") { - currentMessageRef.current = message.content; - - setMessages(prevMessages => [...prevMessages, {id:message.id, sessionId: message.sessionId, content: message.content, authorRole: AuthorRole.Assistant }]); - } else if (message.state === "InProgress") { - setMessages(prevMessages => { - const updatedMessages = [...prevMessages]; - updatedMessages[updatedMessages.length - 1] = {id:message.id, sessionId: message.sessionId, content: message.content, authorRole: AuthorRole.Assistant } - return updatedMessages; - }); - } else if (message.state === "End") { - //find message by id and update it with the id from the server - setMessages(prevMessages => { - const updatedMessages = [...prevMessages]; - updatedMessages[updatedMessages.length - 1].sessionId = message.sessionId; - return updatedMessages; - }); - textToSpeechAsync(message.content); - } + connection.on('SessionInsert', (message: any) => { + setSessionId(message.sessionId); + }); + + connection.on('StartMessageUpdate', (message: any) => { + currentMessageRef.current = message.content; + + setMessages(prevMessages => [...prevMessages, { + id: message.messageId, + sessionId: message.sessionId, + content: message.content, + authorRole: AuthorRole.Assistant + }]); + + //affiche le dernier message de messages + console.log("messages: ", messages[messages.length - 1]); + }); - connection.on('UserIDUpdate', (message: any) => { + connection.on('InProgressMessageUpdate', (message: any) => { + setMessages(prevMessages => { + const updatedMessages = [...prevMessages]; + updatedMessages[updatedMessages.length - 1] = {id:message.id, sessionId: message.sessionId, content: message.content, authorRole: AuthorRole.Assistant } + return updatedMessages; + }); + + }); + + connection.on('EndMessageUpdate', (message: any) => { setMessages(prevMessages => { const updatedMessages = [...prevMessages]; - const lastUserMessageIndex = updatedMessages.slice().reverse().findIndex(msg => msg.authorRole === AuthorRole.User); - if (lastUserMessageIndex !== -1) { - const index = updatedMessages.length - 1 - lastUserMessageIndex; - updatedMessages[index] = { ...updatedMessages[index], sessionId: message.sessionId}; - } + updatedMessages[updatedMessages.length - 1].sessionId = message.sessionId; return updatedMessages; }); + textToSpeechAsync(message.content); + }); - connection.on('SessionInsert', (message: any) => { - setSessionId(message.sessionId); + connection.on('MessageIdUpdate', (message: any) => { + setMessages(prevMessages => { + const updatedMessages = [...prevMessages]; + const lastUserMessage = updatedMessages.find(msg => msg.authorRole === AuthorRole.User); + if (lastUserMessage !== null && lastUserMessage !== undefined) { + lastUserMessage.id = message.id; + } + return updatedMessages; + }); }); + }) .catch(e => console.log('Connection failed: ', e)); } }, [connection]); - useEffect(() => { + useEffect(() => { if (session) { fetch(`/api/chat/messages/${session.id}`) .then(response => response.json()) @@ -101,22 +113,26 @@ function ChatWindow({ scenario, session }: ChatWindowProps) { const fetchedMessages = data .filter((msg: Message) => msg.content !== "") .map((msg: Message) => ({ - id: msg.sessionId, + id: msg.id, content: msg.content, - sessionID: msg.sessionId, + sessionId: msg.sessionId, authorRole: msg.authorRole - })); - setMessages(fetchedMessages); + }) + ); + setMessages(fetchedMessages); + console.log("fetchedMessages: ", fetchedMessages); }) .catch(error => console.error('Error fetching session messages:', error)); + + console.log("sessionId: ", session.id); } }, [session]); const handleNewMessage = async (message: string) => { - setMessages(prevMessages => [...prevMessages, { id: "", sessionId: sessionId, content: message, authorRole: AuthorRole.User}]); + setMessages(prevMessages => [...prevMessages, { id: "", sessionId: sessionId, content: message, authorRole: AuthorRole.User }]); + - const currentScenario = scenario?.agents || session?.agents; const agent = currentScenario?.find(agent => agent.type === 'system'); const rolePlayAgent = currentScenario?.find(agent => agent.type === 'rolePlay'); @@ -126,7 +142,7 @@ function ChatWindow({ scenario, session }: ChatWindowProps) { const scenarioDescription = scenario?.description || session?.scenarioDescription; //Create ISessionItem object - const sessionItem: ISessionItem = { + const sessionItem: SessionItem = { id: session?.id || sessionId, timestamp: new Date(), userId: userName, @@ -152,7 +168,7 @@ function ChatWindow({ scenario, session }: ChatWindowProps) { const callLLMApi = async (chatHistory: ChatHistoryRequest) => { console.log(chatHistory); try { - const response = await fetch(`/api/chat/message?sessionId=${sessionId}`, { + const response = await fetch(`/api/chat/message?connectionId=${connection?.connectionId}`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -165,19 +181,33 @@ function ChatWindow({ scenario, session }: ChatWindowProps) { } }; - const handleDeleteMessage = async (index: number) => { + const handleDeleteMessage = async (id: string) => { //stop the audio cancelSpeech(); - const messageToDelete = messages[index]; - try { - await fetch(`/api/chat/messages/${messageToDelete.id}?sessionId=${messageToDelete.sessionId}`, { - method: 'DELETE', - }); - setMessages(prevMessages => prevMessages.filter((_, i) => i !== index)); - } catch (error) { - console.error('Error deleting message:', error); + const messageToDelete = messages.find(msg => msg.id === id); + if (messageToDelete !== null && messageToDelete !== undefined) { + const deleteRequest = new DeleteMessageRequest(messageToDelete.id, messageToDelete.sessionId!); + + try { + const response = await fetch(`/api/chat/message/${messageToDelete.id}`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(deleteRequest), + }); + + if (response.ok) { + setMessages(prevMessages => prevMessages.filter(msg => msg.id !== id)); + } else { + console.error('Failed to delete message'); + } + } catch (error) { + console.error('Error deleting message:', error); + } } + }; const handleSaveSession = async (sessionId: string | undefined) => { @@ -201,6 +231,35 @@ function ChatWindow({ scenario, session }: ChatWindowProps) { } }; + + const deleteSession = async () => { + if (!session) return; + + const deleteRequest = new DeleteSessionRequest(session.id, session.userId); + + try { + const response = await fetch(`/api/session/delete`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(deleteRequest), + }); + + if (response.ok) { + alert('Session deleted successfully'); + setMessages([]); + } else { + console.error('Failed to delete session'); + alert('Failed to delete session'); + } + } catch (error) { + console.error('Error deleting session:', error); + alert('Failed to delete session'); + } + }; + + return (
@@ -217,7 +276,7 @@ function ChatWindow({ scenario, session }: ChatWindowProps) { {messages.map((msg, index) => (
{msg.content} - handleDeleteMessage(index)}>× + handleDeleteMessage(msg.id)}>×
))}
@@ -233,7 +292,11 @@ function ChatWindow({ scenario, session }: ChatWindowProps) {
{/**/}
- + + + + +
); diff --git a/virtualteachergenaidemo.client/src/models/Request/DeleteMessageRequest.ts b/virtualteachergenaidemo.client/src/models/Request/DeleteMessageRequest.ts new file mode 100644 index 0000000..3da46e6 --- /dev/null +++ b/virtualteachergenaidemo.client/src/models/Request/DeleteMessageRequest.ts @@ -0,0 +1,9 @@ +export class DeleteMessageRequest { + messageId: string; + sessionId: string; + + constructor(messageId: string, sessionId: string) { + this.messageId = messageId; + this.sessionId = sessionId; + } +} diff --git a/virtualteachergenaidemo.client/src/models/Request/DeleteSessionRequest.ts b/virtualteachergenaidemo.client/src/models/Request/DeleteSessionRequest.ts new file mode 100644 index 0000000..fd696f4 --- /dev/null +++ b/virtualteachergenaidemo.client/src/models/Request/DeleteSessionRequest.ts @@ -0,0 +1,9 @@ +export class DeleteSessionRequest { + sessionId: string; + userId: string; + + constructor(sessionId: string, userId: string) { + this.sessionId = sessionId; + this.userId = userId; + } +} diff --git a/virtualteachergenaidemo.client/src/models/SessionItem.ts b/virtualteachergenaidemo.client/src/models/SessionItem.ts index 4e9b85f..86c0036 100644 --- a/virtualteachergenaidemo.client/src/models/SessionItem.ts +++ b/virtualteachergenaidemo.client/src/models/SessionItem.ts @@ -1,4 +1,4 @@ -export interface ISessionItem { +export class SessionItem { id: string; title?: string; isComplete?: boolean; @@ -6,11 +6,28 @@ export interface ISessionItem { userId: string; scenarioName: string; scenarioDescription: string; - agents: ISessionAgent[]; + agents: SessionAgent[]; + + constructor(id: string, title: string, isComplete: boolean, timestamp: Date, userId: string, scenarioName: string, scenarioDescription: string, agents: ISessionAgent[]) { + this.id = id; + this.title = title; + this.isComplete = isComplete; + this.timestamp = timestamp; + this.userId = userId; + this.scenarioName = scenarioName; + this.scenarioDescription = scenarioDescription; + this.agents = agents; + } } -export interface ISessionAgent { +export class SessionAgent { prompt: string; type: string; + + + constructor(prompt: string, type: string) { + this.prompt = prompt; + this.type = type; + } } \ No newline at end of file diff --git a/virtualteachergenaidemo.client/src/views/training/training.tsx b/virtualteachergenaidemo.client/src/views/training/training.tsx index f6b35db..62c5a32 100644 --- a/virtualteachergenaidemo.client/src/views/training/training.tsx +++ b/virtualteachergenaidemo.client/src/views/training/training.tsx @@ -8,17 +8,17 @@ import { useState, useEffect } from 'react'; import ScenarioList from '../../components/scenario/scenarioList'; import { ScenarioItem } from '../../models/ScenarioItem'; import { ISessionItem } from '../../models/SessionItem'; -import { Session } from 'inspector'; + function Training() { const navigate = useNavigate(); const location = useLocation(); - const [selectedScenario, setSelectedScenario] = useState(location.state?.scenario); - const [session, setSession] = useState(); + const [selectedScenario, setSelectedScenario] = useState(location.state?.scenario); + const [session, setSession] = useState(); const handleBackClick = () => { - setSelectedScenario(undefined); - setSession(undefined); + setSelectedScenario(null); + setSession(null); navigate(-1); // Navigate back to the previous page }; diff --git a/virtualteachergenaidemo.client/virtualteachergenaidemo.client.esproj b/virtualteachergenaidemo.client/virtualteachergenaidemo.client.esproj index 3745a7c..a63c5f8 100644 --- a/virtualteachergenaidemo.client/virtualteachergenaidemo.client.esproj +++ b/virtualteachergenaidemo.client/virtualteachergenaidemo.client.esproj @@ -10,6 +10,7 @@ + \ No newline at end of file From f3f1212704090593beba6815a8f72b4386ee7cc0 Mon Sep 17 00:00:00 2001 From: Frederic Wickert Date: Wed, 11 Dec 2024 00:04:05 +0100 Subject: [PATCH 03/16] Introduce delay in LLMResponse and fix content update in React Uncommented `await Task.Delay(DELAY);` in `LLMResponse.cs` to introduce a delay after updating the message on the client, which could be used to throttle processing or simulate latency. Modified `setContent` in `DashboardFeatureResult.tsx` to set the content to `updatedData.content` instead of `updatedData`, ensuring that only the relevant content property is used. --- VirtualTeacherGenAIDemo.Server/Services/LLMResponse.cs | 2 +- .../src/components/dashboard/DashboardFeatureResult.tsx | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/VirtualTeacherGenAIDemo.Server/Services/LLMResponse.cs b/VirtualTeacherGenAIDemo.Server/Services/LLMResponse.cs index 4ecbfb9..1b9f9a0 100644 --- a/VirtualTeacherGenAIDemo.Server/Services/LLMResponse.cs +++ b/VirtualTeacherGenAIDemo.Server/Services/LLMResponse.cs @@ -93,7 +93,7 @@ private async Task StreamResponseToClient(string sessionId, string id, string wh messageResponse.Content += contentPiece.Content; await this.UpdateMessageOnClient(whatAbout, messageResponse, connectionId, token); Console.Write(contentPiece.Content); - //await Task.Delay(DELAY); + await Task.Delay(DELAY); } } diff --git a/virtualteachergenaidemo.client/src/components/dashboard/DashboardFeatureResult.tsx b/virtualteachergenaidemo.client/src/components/dashboard/DashboardFeatureResult.tsx index 2e34335..b81bd0f 100644 --- a/virtualteachergenaidemo.client/src/components/dashboard/DashboardFeatureResult.tsx +++ b/virtualteachergenaidemo.client/src/components/dashboard/DashboardFeatureResult.tsx @@ -26,7 +26,8 @@ const DashboardFeatureResult: React.FC = ({ session connection.on(infoType, (updatedData: any) => { setIsLoading(false); - setContent(updatedData); + + setContent(updatedData.content); }); return () => { From 6aa204a7c6a3ffc14945dd71930d87b6bca94e7e Mon Sep 17 00:00:00 2001 From: Frederic Wickert Date: Wed, 11 Dec 2024 11:00:30 +0100 Subject: [PATCH 04/16] Refactor file upload with chunking and real-time updates Significantly refactored `FileUploadController.cs`: - Removed dependencies on `Azure` and `Microsoft.KernelMemory`. - Added dependencies on `System.Collections.Concurrent`, `System.IO`, `System.Threading`, and `System.Threading.Tasks`. - Replaced `IKernelMemory` with `FileUploadService`. - Implemented chunked file upload logic using `ConcurrentDictionary`. - Added logic to assemble file chunks and call `FileUploadService` to parse the document. - Moved `ParseDocument` method to `FileUploadService`. Updated `ServicesExtensions.cs`: - Added `DocumentIntelligentOptions` to options configuration. - Registered `FileUploadService` as a scoped service. Updated `Program.cs`: - Removed unused imports. - Configured `FormOptions` to set multipart body length limit to 100 MB. - Added SignalR configuration to limit maximum parallel invocations per client. Updated `FileUpload.tsx`: - Added SignalR client setup for real-time updates. - Implemented chunked file upload logic in `handleUploadClick`. - Added status display for real-time updates from the server. Updated `AgentDialog.tsx`: - Added state management for `fileName`. - Updated `useEffect` to handle `fileName` changes. - Added `handleFileUpload` method to update `fileName` state. Added `DocumentIntelligentOptions.cs`: - Defined `DocumentIntelligentOptions` class with properties for `Endpoint`, `Key`, and `IndexName`. - Added validation attributes for required fields. Added `FileUploadService.cs`: - Implemented `FileUploadService` class for document parsing. - Used `DocumentIntelligenceClient` to analyze the document. - Implemented real-time status updates using SignalR. - Moved document parsing logic from the controller to this service. --- .../Controllers/FileUploadController.cs | 85 +++++++++---------- .../Extensions/ServicesExtensions.cs | 3 + .../Handler/DocuIntHandler.cs | 41 --------- .../Options/DocumentIntelligentOptions.cs | 18 ++++ VirtualTeacherGenAIDemo.Server/Program.cs | 10 ++- .../Services/FileUploadService.cs | 70 +++++++++++++++ .../src/components/Utilities/FileUpload.tsx | 64 +++++++++++++- .../src/components/agent/AgentDialog.tsx | 6 ++ 8 files changed, 207 insertions(+), 90 deletions(-) delete mode 100644 VirtualTeacherGenAIDemo.Server/Handler/DocuIntHandler.cs create mode 100644 VirtualTeacherGenAIDemo.Server/Options/DocumentIntelligentOptions.cs create mode 100644 VirtualTeacherGenAIDemo.Server/Services/FileUploadService.cs diff --git a/VirtualTeacherGenAIDemo.Server/Controllers/FileUploadController.cs b/VirtualTeacherGenAIDemo.Server/Controllers/FileUploadController.cs index faa56dc..ac75469 100644 --- a/VirtualTeacherGenAIDemo.Server/Controllers/FileUploadController.cs +++ b/VirtualTeacherGenAIDemo.Server/Controllers/FileUploadController.cs @@ -1,10 +1,10 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using Azure; -using Azure.AI.DocumentIntelligence; +using System.Collections.Concurrent; using System.IO; +using System.Threading; using System.Threading.Tasks; -using Microsoft.KernelMemory; +using VirtualTeacherGenAIDemo.Server.Services; namespace VirtualTeacherGenAIDemo.Server.Controllers { @@ -12,67 +12,62 @@ namespace VirtualTeacherGenAIDemo.Server.Controllers [ApiController] public class FileUploadController : ControllerBase { - private readonly IKernelMemory _kernelMemory; + private readonly FileUploadService _fileUploadService; + private readonly ConcurrentDictionary> _fileChunks = new(); - public FileUploadController([FromServices] IKernelMemory kernelMemory) + public FileUploadController([FromServices] FileUploadService fileUploadService) { - _kernelMemory = kernelMemory; + _fileUploadService = fileUploadService; } - [HttpPost] - public async Task UploadFile(IFormFile file) + [HttpPost("")] + public async Task UploadFile(string connectionId, IFormFile fileStream, int chunkIndex, int totalChunks, CancellationToken token) { - if (file == null || file.Length == 0) + if (fileStream == null || fileStream.Length == 0) { return BadRequest("No file uploaded."); } - using (var stream = file.OpenReadStream()) + if (!_fileChunks.ContainsKey(connectionId)) { - // Call the method to parse the uploaded file stream - var parseResult = await ParseDocument(stream); - - if (!parseResult) - { - return StatusCode(StatusCodes.Status500InternalServerError, "Error parsing the document."); - } + _fileChunks[connectionId] = new ConcurrentDictionary(); } - return Ok(); - } - - private async Task ParseDocument(Stream fileStream) - { - string endpoint = "https://virtualteachdocint.cognitiveservices.azure.com/"; - string key = "7bANKH6Dm7ZIqfrXl4dd3AwUYaQq8TkTrumeJbL4fXXV5bcy8NQhJQQJ99ALAC5RqLJXJ3w3AAALACOGGNQ4"; - - var client = new DocumentIntelligenceClient(new Uri(endpoint), new AzureKeyCredential(key)); - - var binaryData = BinaryData.FromStream(fileStream); - var content = new AnalyzeDocumentContent() { Base64Source = binaryData }; - - int i = 1; + using (var memoryStream = new MemoryStream()) + { + await fileStream.CopyToAsync(memoryStream, token); + _fileChunks[connectionId][chunkIndex] = memoryStream.ToArray(); + } - while (true) + if (_fileChunks[connectionId].Count == totalChunks) { - try + // All chunks uploaded, assemble the file + using (var finalMemoryStream = new MemoryStream()) { - Operation operation = await client.AnalyzeDocumentAsync(WaitUntil.Completed, "prebuilt-layout", content, outputContentFormat: ContentFormat.Markdown, pages: i.ToString()); - AnalyzeResult result = operation.Value; - - Console.WriteLine(result.Content); + for (int i = 0; i < totalChunks; i++) + { + if (_fileChunks[connectionId].TryGetValue(i, out var chunk)) + { + await finalMemoryStream.WriteAsync(chunk, 0, chunk.Length, token); + } + else + { + return NoContent(); // Missing chunk + } + } - await _kernelMemory.DeleteDocumentAsync(i.ToString(), index: "test"); - await _kernelMemory.ImportTextAsync(result.Content, i.ToString(), index: "test"); - i++; - } - catch (Exception) - { - break; + finalMemoryStream.Position = 0; + var parseResult = await _fileUploadService.ParseDocument(finalMemoryStream, connectionId, token); + if (!parseResult) + { + return StatusCode(StatusCodes.Status500InternalServerError, "Error parsing the document."); + } } + + _fileChunks.TryRemove(connectionId, out _); // Clean up chunks } - return true; + return Ok(); } } } diff --git a/VirtualTeacherGenAIDemo.Server/Extensions/ServicesExtensions.cs b/VirtualTeacherGenAIDemo.Server/Extensions/ServicesExtensions.cs index 956b6a5..32f7f79 100644 --- a/VirtualTeacherGenAIDemo.Server/Extensions/ServicesExtensions.cs +++ b/VirtualTeacherGenAIDemo.Server/Extensions/ServicesExtensions.cs @@ -25,6 +25,8 @@ public static IServiceCollection AddOptions(this IServiceCollection services, Co AddOptions(SpeechOptions.PropertyName); + AddOptions(DocumentIntelligentOptions.PropertyName); + return services; @@ -165,6 +167,7 @@ internal static IServiceCollection AddServices(this IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); return services; } } diff --git a/VirtualTeacherGenAIDemo.Server/Handler/DocuIntHandler.cs b/VirtualTeacherGenAIDemo.Server/Handler/DocuIntHandler.cs deleted file mode 100644 index de47dec..0000000 --- a/VirtualTeacherGenAIDemo.Server/Handler/DocuIntHandler.cs +++ /dev/null @@ -1,41 +0,0 @@ - -using Microsoft.KernelMemory.Diagnostics; -using Microsoft.KernelMemory.Pipeline; - -namespace VirtualTeacherGenAIDemo.Server.Handler -{ - public class DocuIntHandler : IPipelineStepHandler - { - public string StepName { get; } - private readonly IPipelineOrchestrator _orchestrator; - private readonly ILogger _log; - - public DocuIntHandler(IPipelineOrchestrator orchestrator, ILoggerFactory? loggerFactory = null) - { - _orchestrator = orchestrator; - this._log = (loggerFactory ?? DefaultLogger.Factory).CreateLogger(); - StepName = "DocuIntHandler"; - - this._log.LogInformation("Instantiating handler {0}...", this.GetType().FullName); - } - - public async Task<(ReturnType returnType, DataPipeline updatedPipeline)> InvokeAsync(DataPipeline pipeline, CancellationToken cancellationToken = default) - { - this._log.LogInformation("Running handler {0}...", this.GetType().FullName); - - Console.WriteLine("** My handler is working ** "); - - Console.WriteLine("Index: " + pipeline.Index); - Console.WriteLine("Document Id: " + pipeline.DocumentId); - Console.WriteLine("Steps: " + string.Join(", ", pipeline.Steps)); - Console.WriteLine("Remaining Steps: " + string.Join(", ", pipeline.RemainingSteps)); - - await Task.Delay(0, cancellationToken).ConfigureAwait(false); - - return (ReturnType.Success, pipeline); - - } - - - } -} diff --git a/VirtualTeacherGenAIDemo.Server/Options/DocumentIntelligentOptions.cs b/VirtualTeacherGenAIDemo.Server/Options/DocumentIntelligentOptions.cs new file mode 100644 index 0000000..f4b972d --- /dev/null +++ b/VirtualTeacherGenAIDemo.Server/Options/DocumentIntelligentOptions.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; + +namespace VirtualTeacherGenAIDemo.Server.Options +{ + public class DocumentIntelligentOptions + { + public const string PropertyName = "DocumentIntelligent"; + + [Required, NotEmptyOrWhitespace] + public string Endpoint { get; set; } = string.Empty; + + [Required, NotEmptyOrWhitespace] + public string Key { get; set; } = string.Empty; + + [Required, NotEmptyOrWhitespace] + public string IndexName { get; set; } = string.Empty; + } +} diff --git a/VirtualTeacherGenAIDemo.Server/Program.cs b/VirtualTeacherGenAIDemo.Server/Program.cs index 07853e8..4697769 100644 --- a/VirtualTeacherGenAIDemo.Server/Program.cs +++ b/VirtualTeacherGenAIDemo.Server/Program.cs @@ -1,7 +1,5 @@ -using Microsoft.KernelMemory; -using System.Reflection.PortableExecutable; +using Microsoft.AspNetCore.Http.Features; using VirtualTeacherGenAIDemo.Server.Extensions; -using VirtualTeacherGenAIDemo.Server.Handler; using VirtualTeacherGenAIDemo.Server.Hubs; var builder = WebApplication.CreateBuilder(args); @@ -23,6 +21,12 @@ builder.AddKernelMemoryService(); +builder.Services.Configure(options => +{ + options.MultipartBodyLengthLimit = 100 * 1024 * 1024; // 100 MB +}); + + builder.Services.AddSignalR(options => options.MaximumParallelInvocationsPerClient = 10); builder.Services.AddCorsPolicy(); diff --git a/VirtualTeacherGenAIDemo.Server/Services/FileUploadService.cs b/VirtualTeacherGenAIDemo.Server/Services/FileUploadService.cs new file mode 100644 index 0000000..4c0ef47 --- /dev/null +++ b/VirtualTeacherGenAIDemo.Server/Services/FileUploadService.cs @@ -0,0 +1,70 @@ +using Azure; +using Azure.AI.DocumentIntelligence; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Options; +using Microsoft.KernelMemory; +using MongoDB.Driver.Core.Connections; +using System; +using System.IO; +using System.Threading.Tasks; +using VirtualTeacherGenAIDemo.Server.Hubs; +using VirtualTeacherGenAIDemo.Server.Options; + +namespace VirtualTeacherGenAIDemo.Server.Services +{ + public class FileUploadService + { + private readonly IKernelMemory _kernelMemory; + private readonly DocumentIntelligentOptions _options; + private readonly IHubContext _messageRelayHubContext; + + public FileUploadService(IKernelMemory kernelMemory, IOptions options, IHubContext messageRelayHubContext) + { + _kernelMemory = kernelMemory; + _options = options.Value; + _messageRelayHubContext = messageRelayHubContext; + } + + public async Task ParseDocument(Stream fileStream, string connectionId, CancellationToken token) + { + var client = new DocumentIntelligenceClient(new Uri(_options.Endpoint), new AzureKeyCredential(_options.Key)); + + var binaryData = BinaryData.FromStream(fileStream); + var content = new AnalyzeDocumentContent() { Base64Source = binaryData }; + + int i = 1; + await UpdateMessageOnClient("DocumentParsedUpdate", "Process started...", connectionId, token); + while (true) + { + try + { + Operation operation = await client.AnalyzeDocumentAsync(WaitUntil.Completed, "prebuilt-layout", content, outputContentFormat: ContentFormat.Markdown, pages: i.ToString()); + AnalyzeResult result = operation.Value; + + Console.WriteLine(result.Content); + + await _kernelMemory.DeleteDocumentAsync(i.ToString(), index: _options.IndexName); + await _kernelMemory.ImportTextAsync(result.Content, i.ToString(), index: _options.IndexName); + + + string toSend = $"Page {i} parsed successfully."; + await UpdateMessageOnClient("DocumentParsedUpdate", toSend, connectionId, token); + + i++; + } + catch (Exception) + { + break; + } + } + await UpdateMessageOnClient("DocumentParsedUpdate", "Process completed !", connectionId, token); + + return true; + } + + private async Task UpdateMessageOnClient(string hubConnection, object message, string connectionId, CancellationToken token) + { + await _messageRelayHubContext.Clients.Client(connectionId).SendAsync(hubConnection, message, token); + } + } +} diff --git a/virtualteachergenaidemo.client/src/components/Utilities/FileUpload.tsx b/virtualteachergenaidemo.client/src/components/Utilities/FileUpload.tsx index 23b985a..a603072 100644 --- a/virtualteachergenaidemo.client/src/components/Utilities/FileUpload.tsx +++ b/virtualteachergenaidemo.client/src/components/Utilities/FileUpload.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react'; import { Button } from '@fluentui/react-button'; import { Field } from '@fluentui/react-field'; +import { HubConnectionBuilder, HubConnection } from '@microsoft/signalr'; interface FileUploadProps { onFileUpload: (fileName: string) => void; @@ -11,6 +12,8 @@ export const FileUpload = ({ onFileUpload, fileName }: FileUploadProps) => { const [file, setFile] = useState(null); const [fileError, setFileError] = useState(''); const [displayFileName, setDisplayFileName] = useState(fileName || ''); + const [connection, setConnection] = useState(null); + const [status, setStatus] = useState(''); useEffect(() => { if (fileName) { @@ -18,6 +21,28 @@ export const FileUpload = ({ onFileUpload, fileName }: FileUploadProps) => { } }, [fileName]); + useEffect(() => { + const hubUrl = process.env.HUB_URL; + const newConnection = new HubConnectionBuilder() + .withUrl(hubUrl!) + .withAutomaticReconnect() + .build(); + + setConnection(newConnection); + }, []); + + useEffect(() => { + if (connection) { + connection.start() + .then(() => { + connection.on('DocumentParsedUpdate', (message: string) => { + setStatus(message); + }); + }) + .catch(e => console.log('Connection failed: ', e)); + } + }, [connection]); + const handleFileChange = (event: React.ChangeEvent) => { const selectedFile = event.target.files?.[0] || null; setFile(selectedFile); @@ -28,11 +53,47 @@ export const FileUpload = ({ onFileUpload, fileName }: FileUploadProps) => { } }; - const handleUploadClick = () => { + const CHUNK_SIZE = 2 * 1024 * 1024; // 2MB + + const uploadChunk = async (chunk: Blob, chunkIndex: number, totalChunks: number) => { + const formData = new FormData(); + formData.append('fileStream', chunk); + formData.append('chunkIndex', chunkIndex.toString()); + formData.append('totalChunks', totalChunks.toString()); + + + const response = await fetch(`/api/FileUpload?connectionId=${connection?.connectionId || ''}`, { + method: 'POST', + body: formData + }); + + if (!response.ok) { + throw new Error('Chunk upload failed'); + } + }; + + const handleUploadClick = async () => { if (!file) { setFileError('Please select a file to upload.'); return; } + + const totalChunks = Math.ceil(file.size / CHUNK_SIZE); + + + for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) { + const start = chunkIndex * CHUNK_SIZE; + const end = Math.min(start + CHUNK_SIZE, file.size); + const chunk = file.slice(start, end); + + try { + await uploadChunk(chunk, chunkIndex, totalChunks); + } catch (error) { + setFileError('File upload failed. Please try again.'); + return; + } + } + onFileUpload(file.name); }; @@ -43,6 +104,7 @@ export const FileUpload = ({ onFileUpload, fileName }: FileUploadProps) => { {displayFileName &&

File: {displayFileName}

} + {status} ); }; diff --git a/virtualteachergenaidemo.client/src/components/agent/AgentDialog.tsx b/virtualteachergenaidemo.client/src/components/agent/AgentDialog.tsx index d32d135..bc4fa80 100644 --- a/virtualteachergenaidemo.client/src/components/agent/AgentDialog.tsx +++ b/virtualteachergenaidemo.client/src/components/agent/AgentDialog.tsx @@ -10,6 +10,7 @@ import { makeStyles } from '@fluentui/react-components'; import { tokens } from '@fluentui/tokens'; import { FileUpload } from '../Utilities/FileUpload'; + interface AgentDialogProps { onAddAgent: (agent: AgentItem) => void; onDeleteAgent: (agentId: string) => void; @@ -18,6 +19,8 @@ interface AgentDialogProps { agent?: AgentItem; } + + const useStyles = makeStyles({ deleteButton: { backgroundColor: tokens.colorPaletteRedBackground3, @@ -40,6 +43,7 @@ export const AgentDialog = ({ onAddAgent, onDeleteAgent, type, onClose, agent }: const [isOpen, setIsOpen] = useState(true); const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false); + const [fileName, setFileName] = useState(agent?.fileName || ''); useEffect(() => { @@ -51,6 +55,8 @@ export const AgentDialog = ({ onAddAgent, onDeleteAgent, type, onClose, agent }: } }, [agent]); + + const handleFileUpload = (uploadedFileName: string) => { setFileName(uploadedFileName); From 3d3ce3e7421865fa104373b1e20f89019ae26953 Mon Sep 17 00:00:00 2001 From: Frederic Wickert Date: Wed, 11 Dec 2024 15:22:33 +0100 Subject: [PATCH 05/16] Refactor file upload to support multiple files Simplified file upload process in `FileUploadController.cs` by using `MemoryStream` for handling file chunks. Updated `UploadFile` method to accept additional parameters and process file chunks accordingly. Enhanced error handling in `FileUploadService.cs` to log exceptions to the client. Refactored `FileUpload.tsx` to support multiple file uploads, including updates to state management, file selection handling, and chunk uploading. Improved UI to display names of all selected files and show validation messages for multiple files. --- .../Controllers/FileUploadController.cs | 51 +++---------- .../Services/FileUploadService.cs | 3 +- .../src/components/Utilities/FileUpload.tsx | 74 ++++++++++--------- 3 files changed, 53 insertions(+), 75 deletions(-) diff --git a/VirtualTeacherGenAIDemo.Server/Controllers/FileUploadController.cs b/VirtualTeacherGenAIDemo.Server/Controllers/FileUploadController.cs index ac75469..caf634d 100644 --- a/VirtualTeacherGenAIDemo.Server/Controllers/FileUploadController.cs +++ b/VirtualTeacherGenAIDemo.Server/Controllers/FileUploadController.cs @@ -13,58 +13,29 @@ namespace VirtualTeacherGenAIDemo.Server.Controllers public class FileUploadController : ControllerBase { private readonly FileUploadService _fileUploadService; - private readonly ConcurrentDictionary> _fileChunks = new(); + private static readonly ConcurrentDictionary _fileStreams = new(); public FileUploadController([FromServices] FileUploadService fileUploadService) { _fileUploadService = fileUploadService; } - [HttpPost("")] - public async Task UploadFile(string connectionId, IFormFile fileStream, int chunkIndex, int totalChunks, CancellationToken token) + [HttpPost()] + public async Task UploadFile(string connectionId, IFormFile file, [FromForm] string fileId, [FromForm] int chunkIndex, [FromForm] int totalChunks, CancellationToken token) { - if (fileStream == null || fileStream.Length == 0) + if (!_fileStreams.ContainsKey(fileId)) { - return BadRequest("No file uploaded."); + _fileStreams[fileId] = new MemoryStream(); } - if (!_fileChunks.ContainsKey(connectionId)) - { - _fileChunks[connectionId] = new ConcurrentDictionary(); - } + var fileStream = _fileStreams[fileId]; + await file.CopyToAsync(fileStream, token); - using (var memoryStream = new MemoryStream()) + if (chunkIndex == totalChunks - 1) { - await fileStream.CopyToAsync(memoryStream, token); - _fileChunks[connectionId][chunkIndex] = memoryStream.ToArray(); - } - - if (_fileChunks[connectionId].Count == totalChunks) - { - // All chunks uploaded, assemble the file - using (var finalMemoryStream = new MemoryStream()) - { - for (int i = 0; i < totalChunks; i++) - { - if (_fileChunks[connectionId].TryGetValue(i, out var chunk)) - { - await finalMemoryStream.WriteAsync(chunk, 0, chunk.Length, token); - } - else - { - return NoContent(); // Missing chunk - } - } - - finalMemoryStream.Position = 0; - var parseResult = await _fileUploadService.ParseDocument(finalMemoryStream, connectionId, token); - if (!parseResult) - { - return StatusCode(StatusCodes.Status500InternalServerError, "Error parsing the document."); - } - } - - _fileChunks.TryRemove(connectionId, out _); // Clean up chunks + fileStream.Position = 0; + _ = Task.Run(() => _fileUploadService.ParseDocument(fileStream, connectionId, token)); + _fileStreams.TryRemove(fileId, out _); } return Ok(); diff --git a/VirtualTeacherGenAIDemo.Server/Services/FileUploadService.cs b/VirtualTeacherGenAIDemo.Server/Services/FileUploadService.cs index 4c0ef47..825b624 100644 --- a/VirtualTeacherGenAIDemo.Server/Services/FileUploadService.cs +++ b/VirtualTeacherGenAIDemo.Server/Services/FileUploadService.cs @@ -52,8 +52,9 @@ public async Task ParseDocument(Stream fileStream, string connectionId, Ca i++; } - catch (Exception) + catch (Exception ex) { + await UpdateMessageOnClient("DocumentParsedUpdate", $"Error : {ex.Message}", connectionId, token); break; } } diff --git a/virtualteachergenaidemo.client/src/components/Utilities/FileUpload.tsx b/virtualteachergenaidemo.client/src/components/Utilities/FileUpload.tsx index a603072..fdca55f 100644 --- a/virtualteachergenaidemo.client/src/components/Utilities/FileUpload.tsx +++ b/virtualteachergenaidemo.client/src/components/Utilities/FileUpload.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'; import { Button } from '@fluentui/react-button'; import { Field } from '@fluentui/react-field'; import { HubConnectionBuilder, HubConnection } from '@microsoft/signalr'; +import { v4 as uuidv4 } from 'uuid'; interface FileUploadProps { onFileUpload: (fileName: string) => void; @@ -9,15 +10,16 @@ interface FileUploadProps { } export const FileUpload = ({ onFileUpload, fileName }: FileUploadProps) => { - const [file, setFile] = useState(null); - const [fileError, setFileError] = useState(''); - const [displayFileName, setDisplayFileName] = useState(fileName || ''); + const [files, setFiles] = useState([]); + const [fileErrors, setFileErrors] = useState([]); + const [displayFileNames, setDisplayFileNames] = useState(fileName ? [fileName] : []); const [connection, setConnection] = useState(null); const [status, setStatus] = useState(''); + const [fileIds, setFileIds] = useState([]); useEffect(() => { if (fileName) { - setDisplayFileName(fileName); + setDisplayFileNames([fileName]); } }, [fileName]); @@ -44,24 +46,23 @@ export const FileUpload = ({ onFileUpload, fileName }: FileUploadProps) => { }, [connection]); const handleFileChange = (event: React.ChangeEvent) => { - const selectedFile = event.target.files?.[0] || null; - setFile(selectedFile); - setFileError(''); - if (selectedFile) { - setDisplayFileName(selectedFile.name); - onFileUpload(selectedFile.name); - } + const selectedFiles = Array.from(event.target.files || []); + setFiles(selectedFiles); + setFileErrors([]); + setDisplayFileNames(selectedFiles.map(file => file.name)); + setFileIds(selectedFiles.map(() => uuidv4())); // Generate unique IDs for each file + selectedFiles.forEach(file => onFileUpload(file.name)); }; const CHUNK_SIZE = 2 * 1024 * 1024; // 2MB - const uploadChunk = async (chunk: Blob, chunkIndex: number, totalChunks: number) => { + const uploadChunk = async (chunk: Blob, chunkIndex: number, totalChunks: number, fileId: string) => { const formData = new FormData(); - formData.append('fileStream', chunk); + formData.append('file', chunk); formData.append('chunkIndex', chunkIndex.toString()); formData.append('totalChunks', totalChunks.toString()); + formData.append('fileId', fileId); // Include the unique file ID - const response = await fetch(`/api/FileUpload?connectionId=${connection?.connectionId || ''}`, { method: 'POST', body: formData @@ -73,37 +74,42 @@ export const FileUpload = ({ onFileUpload, fileName }: FileUploadProps) => { }; const handleUploadClick = async () => { - if (!file) { - setFileError('Please select a file to upload.'); + if (files.length === 0) { + setFileErrors(['Please select files to upload.']); return; } - const totalChunks = Math.ceil(file.size / CHUNK_SIZE); - - - for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) { - const start = chunkIndex * CHUNK_SIZE; - const end = Math.min(start + CHUNK_SIZE, file.size); - const chunk = file.slice(start, end); - - try { - await uploadChunk(chunk, chunkIndex, totalChunks); - } catch (error) { - setFileError('File upload failed. Please try again.'); - return; + for (let i = 0; i < files.length; i++) { + const file = files[i]; + const fileId = fileIds[i]; + const totalChunks = Math.ceil(file.size / CHUNK_SIZE); + + for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) { + const start = chunkIndex * CHUNK_SIZE; + const end = Math.min(start + CHUNK_SIZE, file.size); + const chunk = file.slice(start, end); + + try { + await uploadChunk(chunk, chunkIndex, totalChunks, fileId); + } catch (error) { + setFileErrors(prevErrors => [...prevErrors, `File upload failed for ${file.name}. Please try again.`]); + return; + } } - } - onFileUpload(file.name); + onFileUpload(file.name); + } }; return (
- - + + - {displayFileName &&

File: {displayFileName}

} + {displayFileNames.map((name, index) => ( +

File: {name}

+ ))} {status}
); From 9a2894c422c9c1165f11c930b44e02bc34a4990e Mon Sep 17 00:00:00 2001 From: Frederic Wickert Date: Wed, 11 Dec 2024 21:46:13 +0100 Subject: [PATCH 06/16] Add agentId parameter to file upload process Introduce an `agentId` parameter to the file upload process to ensure documents are tagged and identified uniquely based on the agent. This change affects the client-side file upload request (`FileUpload.tsx`), the controller (`FileUploadController.cs`), and the service layer (`FileUploadService.cs`). The `agentId` is used for tagging documents and constructing unique document IDs, enhancing document management and traceability. --- .../Controllers/FileUploadController.cs | 4 ++-- .../Services/FileUploadService.cs | 10 ++++++---- .../src/components/Utilities/FileUpload.tsx | 3 ++- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/VirtualTeacherGenAIDemo.Server/Controllers/FileUploadController.cs b/VirtualTeacherGenAIDemo.Server/Controllers/FileUploadController.cs index caf634d..56f7eaa 100644 --- a/VirtualTeacherGenAIDemo.Server/Controllers/FileUploadController.cs +++ b/VirtualTeacherGenAIDemo.Server/Controllers/FileUploadController.cs @@ -21,7 +21,7 @@ public FileUploadController([FromServices] FileUploadService fileUploadService) } [HttpPost()] - public async Task UploadFile(string connectionId, IFormFile file, [FromForm] string fileId, [FromForm] int chunkIndex, [FromForm] int totalChunks, CancellationToken token) + public async Task UploadFile(string connectionId, string agentId, IFormFile file, [FromForm] string fileId, [FromForm] int chunkIndex, [FromForm] int totalChunks, CancellationToken token) { if (!_fileStreams.ContainsKey(fileId)) { @@ -34,7 +34,7 @@ public async Task UploadFile(string connectionId, IFormFile file, if (chunkIndex == totalChunks - 1) { fileStream.Position = 0; - _ = Task.Run(() => _fileUploadService.ParseDocument(fileStream, connectionId, token)); + _ = Task.Run(() => _fileUploadService.ParseDocument(fileStream, agentId, connectionId, token)); _fileStreams.TryRemove(fileId, out _); } diff --git a/VirtualTeacherGenAIDemo.Server/Services/FileUploadService.cs b/VirtualTeacherGenAIDemo.Server/Services/FileUploadService.cs index 825b624..2a975f2 100644 --- a/VirtualTeacherGenAIDemo.Server/Services/FileUploadService.cs +++ b/VirtualTeacherGenAIDemo.Server/Services/FileUploadService.cs @@ -25,7 +25,7 @@ public FileUploadService(IKernelMemory kernelMemory, IOptions ParseDocument(Stream fileStream, string connectionId, CancellationToken token) + public async Task ParseDocument(Stream fileStream, string agentId, string connectionId, CancellationToken token) { var client = new DocumentIntelligenceClient(new Uri(_options.Endpoint), new AzureKeyCredential(_options.Key)); @@ -41,10 +41,12 @@ public async Task ParseDocument(Stream fileStream, string connectionId, Ca Operation operation = await client.AnalyzeDocumentAsync(WaitUntil.Completed, "prebuilt-layout", content, outputContentFormat: ContentFormat.Markdown, pages: i.ToString()); AnalyzeResult result = operation.Value; - Console.WriteLine(result.Content); + TagCollection tags = new TagCollection(); + tags.Add("agentId", agentId); - await _kernelMemory.DeleteDocumentAsync(i.ToString(), index: _options.IndexName); - await _kernelMemory.ImportTextAsync(result.Content, i.ToString(), index: _options.IndexName); + string docuId = $"{agentId}:{i.ToString()}"; + await _kernelMemory.DeleteDocumentAsync(docuId, index: _options.IndexName); // Need to test if need to delete + await _kernelMemory.ImportTextAsync(result.Content, docuId, index: _options.IndexName, tags:tags); string toSend = $"Page {i} parsed successfully."; diff --git a/virtualteachergenaidemo.client/src/components/Utilities/FileUpload.tsx b/virtualteachergenaidemo.client/src/components/Utilities/FileUpload.tsx index fdca55f..6403566 100644 --- a/virtualteachergenaidemo.client/src/components/Utilities/FileUpload.tsx +++ b/virtualteachergenaidemo.client/src/components/Utilities/FileUpload.tsx @@ -63,7 +63,8 @@ export const FileUpload = ({ onFileUpload, fileName }: FileUploadProps) => { formData.append('totalChunks', totalChunks.toString()); formData.append('fileId', fileId); // Include the unique file ID - const response = await fetch(`/api/FileUpload?connectionId=${connection?.connectionId || ''}`, { + const agentId = 'your-agent-id'; // Replace with actual agentId + const response = await fetch(`/api/FileUpload?connectionId=${connection?.connectionId || ''}&agentId=${agentId}`, { method: 'POST', body: formData }); From 0a660eefb4e24bfe2716212973888aaf6912fac6 Mon Sep 17 00:00:00 2001 From: Frederic Wickert Date: Fri, 13 Dec 2024 09:40:44 +0100 Subject: [PATCH 07/16] Add agentId and fileName params; integrate SearchService Updated ChatController and FileUploadController to include agentId and fileName parameters, respectively. Enhanced ChatService and FileUploadService to handle these new parameters. Integrated SearchService and SearchTool into the project, updating SemanticKernelExtensions and ServicesExtensions accordingly. Refactored ChatResponse to use IChatCompletionService. Updated various frontend components to pass agentId and fileName parameters. Bumped versions of key dependencies in VirtualTeacherGenAIDemo.Server.csproj. --- .../Controllers/ChatController.cs | 4 +- .../Controllers/FileUploadController.cs | 4 +- .../Extensions/SemanticKernelExtensions.cs | 9 +++- .../Extensions/ServicesExtensions.cs | 5 +- .../Options/AssistantOption.cs | 12 ----- .../Services/ChatResponse.cs | 39 ++++++++------- .../Services/ChatService.cs | 18 +++---- .../Services/FileUploadService.cs | 17 ++++--- .../Services/SearchService.cs | 49 +++++++++++++++++++ .../Tools/SearchTool.cs | 26 ++++++++++ .../VirtualTeacherGenAIDemo.Server.csproj | 6 +-- .../src/components/Utilities/FileUpload.tsx | 11 +++-- .../src/components/agent/AgentDialog.tsx | 2 +- .../src/components/chat/chatWindow.tsx | 10 ++-- .../src/components/scenario/scenarioList.tsx | 34 ++++++++----- .../src/models/SessionItem.ts | 6 ++- 16 files changed, 170 insertions(+), 82 deletions(-) delete mode 100644 VirtualTeacherGenAIDemo.Server/Options/AssistantOption.cs create mode 100644 VirtualTeacherGenAIDemo.Server/Services/SearchService.cs create mode 100644 VirtualTeacherGenAIDemo.Server/Tools/SearchTool.cs diff --git a/VirtualTeacherGenAIDemo.Server/Controllers/ChatController.cs b/VirtualTeacherGenAIDemo.Server/Controllers/ChatController.cs index 82ad116..eee4395 100644 --- a/VirtualTeacherGenAIDemo.Server/Controllers/ChatController.cs +++ b/VirtualTeacherGenAIDemo.Server/Controllers/ChatController.cs @@ -22,9 +22,9 @@ public ChatController(ChatService chatService) [HttpPost("message",Name = "message")] [ProducesResponseType(StatusCodes.Status200OK)] - public IResult Post([FromBody] ChatHistoryRequest chatHistory, string connectionId, CancellationToken token) + public IResult Post([FromBody] ChatHistoryRequest chatHistory,string agentId, string connectionId, CancellationToken token) { - return _chatService.GetChat(chatHistory, connectionId, token); + return _chatService.GetChat(chatHistory,agentId,connectionId, token); } diff --git a/VirtualTeacherGenAIDemo.Server/Controllers/FileUploadController.cs b/VirtualTeacherGenAIDemo.Server/Controllers/FileUploadController.cs index 56f7eaa..5fafa2f 100644 --- a/VirtualTeacherGenAIDemo.Server/Controllers/FileUploadController.cs +++ b/VirtualTeacherGenAIDemo.Server/Controllers/FileUploadController.cs @@ -21,7 +21,7 @@ public FileUploadController([FromServices] FileUploadService fileUploadService) } [HttpPost()] - public async Task UploadFile(string connectionId, string agentId, IFormFile file, [FromForm] string fileId, [FromForm] int chunkIndex, [FromForm] int totalChunks, CancellationToken token) + public async Task UploadFile(string connectionId, string agentId, IFormFile file, [FromForm] string fileName, [FromForm] string fileId, [FromForm] int chunkIndex, [FromForm] int totalChunks, CancellationToken token) { if (!_fileStreams.ContainsKey(fileId)) { @@ -34,7 +34,7 @@ public async Task UploadFile(string connectionId, string agentId, if (chunkIndex == totalChunks - 1) { fileStream.Position = 0; - _ = Task.Run(() => _fileUploadService.ParseDocument(fileStream, agentId, connectionId, token)); + _ = Task.Run(() => _fileUploadService.ParseDocument(fileStream, fileName , agentId, connectionId, token)); _fileStreams.TryRemove(fileId, out _); } diff --git a/VirtualTeacherGenAIDemo.Server/Extensions/SemanticKernelExtensions.cs b/VirtualTeacherGenAIDemo.Server/Extensions/SemanticKernelExtensions.cs index 3f21a11..eef8730 100644 --- a/VirtualTeacherGenAIDemo.Server/Extensions/SemanticKernelExtensions.cs +++ b/VirtualTeacherGenAIDemo.Server/Extensions/SemanticKernelExtensions.cs @@ -4,6 +4,8 @@ using Microsoft.SemanticKernel.Connectors.AzureOpenAI; using VirtualTeacherGenAIDemo.Server.AI; using VirtualTeacherGenAIDemo.Server.Options; +using VirtualTeacherGenAIDemo.Server.Services; +using VirtualTeacherGenAIDemo.Server.Tools; namespace VirtualTeacherGenAIDemo.Server.Extensions { @@ -27,8 +29,13 @@ internal static IServiceCollection AddSemanticKernelServices(this IServiceCollec builder.Services.AddLogging(c => c.AddConsole().SetMinimumLevel(LogLevel.Information)); builder.WithCompletionBackend(sp.GetRequiredService>().Value, serviceID); builder.Services.AddHttpClient(); - Kernel kernel = builder.Build(); + + var searchTool = new SearchTool(sp.GetRequiredService()); + builder.Plugins.AddFromObject(searchTool); + + + Kernel kernel = builder.Build(); sp.GetRequiredService()(sp, kernel); return kernel; }); diff --git a/VirtualTeacherGenAIDemo.Server/Extensions/ServicesExtensions.cs b/VirtualTeacherGenAIDemo.Server/Extensions/ServicesExtensions.cs index 32f7f79..c984a30 100644 --- a/VirtualTeacherGenAIDemo.Server/Extensions/ServicesExtensions.cs +++ b/VirtualTeacherGenAIDemo.Server/Extensions/ServicesExtensions.cs @@ -19,9 +19,7 @@ public static IServiceCollection AddOptions(this IServiceCollection services, Co AddOptions(KernelMemoryOptions.SectionName); - AddOptions(AIServiceOptions.PropertyName); - - AddOptions(AssistantOption.PropertyName); + AddOptions(AIServiceOptions.PropertyName); AddOptions(SpeechOptions.PropertyName); @@ -168,6 +166,7 @@ internal static IServiceCollection AddServices(this IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); return services; } } diff --git a/VirtualTeacherGenAIDemo.Server/Options/AssistantOption.cs b/VirtualTeacherGenAIDemo.Server/Options/AssistantOption.cs deleted file mode 100644 index cc3d2aa..0000000 --- a/VirtualTeacherGenAIDemo.Server/Options/AssistantOption.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace VirtualTeacherGenAIDemo.Server.Options -{ - public class AssistantOption - { - public const string PropertyName = "Assistant"; - - /// - /// The persona to use for the chat. - /// - public string Persona { get; set; } = string.Empty; - } -} diff --git a/VirtualTeacherGenAIDemo.Server/Services/ChatResponse.cs b/VirtualTeacherGenAIDemo.Server/Services/ChatResponse.cs index 522c8d5..d534750 100644 --- a/VirtualTeacherGenAIDemo.Server/Services/ChatResponse.cs +++ b/VirtualTeacherGenAIDemo.Server/Services/ChatResponse.cs @@ -3,6 +3,7 @@ using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.ChatCompletion; using Microsoft.SemanticKernel.Connectors.AzureOpenAI; +using Microsoft.SemanticKernel.Connectors.OpenAI; using VirtualTeacherGenAIDemo.Server.Hubs; using VirtualTeacherGenAIDemo.Server.Models.Response; using VirtualTeacherGenAIDemo.Server.Models.Storage; @@ -14,20 +15,22 @@ public class ChatResponse { private readonly ILogger _logger; private readonly IHubContext _messageRelayHubContext; - private readonly AzureOpenAIChatCompletionService _chat; + private readonly IChatCompletionService _chat; + private readonly Kernel _kernel; private readonly int DELAY = 25; - public ChatResponse(ILogger logger, - [FromServices] AzureOpenAIChatCompletionService chat, - [FromServices] IHubContext messageRelayHubContext) + public ChatResponse(ILogger logger, + [FromServices] IHubContext messageRelayHubContext, + [FromServices] Kernel kernel) { _logger = logger; - - _chat = chat; + _kernel = kernel; + _chat = _kernel.GetRequiredService(); _messageRelayHubContext = messageRelayHubContext; + } - public async Task StartChat(string connectionId, string persona, + public async Task StartChat(string connectionId, ChatHistory chatHistory, string userId, SessionItem session, @@ -37,15 +40,6 @@ public async Task StartChat(string connectionId, string persona, { - ChatMessageContent promptSystem = chatHistory.FirstOrDefault(q => q.Role == AuthorRole.System)!; - if (promptSystem != null) - { - if (string.IsNullOrEmpty(promptSystem.Content)) - { - promptSystem.Content = persona; - } - } - if (string.IsNullOrEmpty(session.Id)) { session.Id = Guid.NewGuid().ToString(); @@ -80,7 +74,16 @@ public async Task StartChat(string connectionId, string persona, }; await this.UpdateMessageOnClient("StartMessageUpdate", response, connectionId, token); - await foreach (StreamingChatMessageContent chatUpdate in _chat.GetStreamingChatMessageContentsAsync(chatHistory, cancellationToken: token)) + OpenAIPromptExecutionSettings openAIPromptExecutionSettings = new() + { + ToolCallBehavior = ToolCallBehavior.AutoInvokeKernelFunctions, + }; + + + await foreach (StreamingChatMessageContent chatUpdate in _chat.GetStreamingChatMessageContentsAsync(chatHistory, + executionSettings: openAIPromptExecutionSettings, + kernel: _kernel, + cancellationToken: token)) { if (!string.IsNullOrEmpty(chatUpdate.Content)) { @@ -139,6 +142,8 @@ public async Task StartChat(string connectionId, string persona, } + + /// /// Update the response on the client. /// diff --git a/VirtualTeacherGenAIDemo.Server/Services/ChatService.cs b/VirtualTeacherGenAIDemo.Server/Services/ChatService.cs index ba89362..73b0884 100644 --- a/VirtualTeacherGenAIDemo.Server/Services/ChatService.cs +++ b/VirtualTeacherGenAIDemo.Server/Services/ChatService.cs @@ -10,25 +10,23 @@ namespace VirtualTeacherGenAIDemo.Server.Services { public class ChatService { - private readonly ChatResponse _chatResponse; - private readonly AssistantOption _assistantOption; + private readonly ChatResponse _chatResponse; private readonly MessageRepository _messageRepository; private readonly SessionRepository _sessionRepository; public ChatService([FromServices] MessageRepository messageRepository, [FromServices] SessionRepository sessionRepository, - [FromServices] ChatResponse chatResponse, - IOptions option) + [FromServices] ChatResponse chatResponse) { _messageRepository = messageRepository; _sessionRepository = sessionRepository; _chatResponse = chatResponse; - _assistantOption = option.Value; + } //Creater function to call GetAsync in other thread without asunc/await and with cancellation token in parameters - public IResult GetChat(ChatHistoryRequest chatHistory, string connectionId, CancellationToken token) + public IResult GetChat(ChatHistoryRequest chatHistory, string agentId, string connectionId, CancellationToken token) { //Transform ChatHistoryRequest to ChatHistory ChatHistory SKHistory = new ChatHistory(); @@ -37,22 +35,24 @@ public IResult GetChat(ChatHistoryRequest chatHistory, string connectionId, Canc switch (message.Role) { case "User": - SKHistory.AddUserMessage(message.Content); + SKHistory.AddUserMessage(message.Content + "\r\nUse the seach tool to find information."); break; case "System": - SKHistory.AddSystemMessage(message.Content); + SKHistory.AddSystemMessage($"agentId:{agentId}\n" + message.Content); break; } } if (chatHistory.Session != null) { - Task.Run(() => _chatResponse.StartChat(connectionId, _assistantOption.Persona, SKHistory, chatHistory.UserId, chatHistory.Session, _messageRepository, _sessionRepository, token), token); + Task.Run(() => _chatResponse.StartChat(connectionId, SKHistory, chatHistory.UserId, chatHistory.Session, _messageRepository, _sessionRepository, token), token); } return TypedResults.Ok("Chat requested"); } + + //return all message from a chatid public async Task> GetChatMessages(string userId) { diff --git a/VirtualTeacherGenAIDemo.Server/Services/FileUploadService.cs b/VirtualTeacherGenAIDemo.Server/Services/FileUploadService.cs index 2a975f2..37004c4 100644 --- a/VirtualTeacherGenAIDemo.Server/Services/FileUploadService.cs +++ b/VirtualTeacherGenAIDemo.Server/Services/FileUploadService.cs @@ -25,7 +25,7 @@ public FileUploadService(IKernelMemory kernelMemory, IOptions ParseDocument(Stream fileStream, string agentId, string connectionId, CancellationToken token) + public async Task ParseDocument(Stream fileStream, string fileName, string agentId, string connectionId, CancellationToken token) { var client = new DocumentIntelligenceClient(new Uri(_options.Endpoint), new AzureKeyCredential(_options.Key)); @@ -44,14 +44,17 @@ public async Task ParseDocument(Stream fileStream, string agentId, string TagCollection tags = new TagCollection(); tags.Add("agentId", agentId); - string docuId = $"{agentId}:{i.ToString()}"; - await _kernelMemory.DeleteDocumentAsync(docuId, index: _options.IndexName); // Need to test if need to delete - await _kernelMemory.ImportTextAsync(result.Content, docuId, index: _options.IndexName, tags:tags); + string docuId = $"{agentId}_file_{fileName.Replace(" ", "-")}_id_{i.ToString()}"; - - string toSend = $"Page {i} parsed successfully."; - await UpdateMessageOnClient("DocumentParsedUpdate", toSend, connectionId, token); + if (!string.IsNullOrWhiteSpace(result.Content)) + { + //await _kernelMemory.DeleteDocumentAsync(docuId, index: _options.IndexName); // Need to test if need to delete + await _kernelMemory.ImportTextAsync(result.Content, docuId, index: _options.IndexName, tags: tags); + string toSend = $"Page {i} parsed successfully."; + await UpdateMessageOnClient("DocumentParsedUpdate", toSend, connectionId, token); + } + i++; } catch (Exception ex) diff --git a/VirtualTeacherGenAIDemo.Server/Services/SearchService.cs b/VirtualTeacherGenAIDemo.Server/Services/SearchService.cs new file mode 100644 index 0000000..e50d2f1 --- /dev/null +++ b/VirtualTeacherGenAIDemo.Server/Services/SearchService.cs @@ -0,0 +1,49 @@ +using Microsoft.Extensions.Options; +using Microsoft.KernelMemory; +using System.Text; +using VirtualTeacherGenAIDemo.Server.Options; + +namespace VirtualTeacherGenAIDemo.Server.Services +{ + public class SearchService + { + + private IKernelMemory _memoryServerless; + private readonly DocumentIntelligentOptions _documentIntelligentOptions; + + public SearchService(IKernelMemory memoryServerless, IOptions options) + { + _memoryServerless = memoryServerless; + _documentIntelligentOptions = options.Value; + } + + + public async Task SearchByAgent(string query, string agentId, string type) + { + MemoryFilter filter = new MemoryFilter(); + + filter.Add("agentId", agentId); + + StringBuilder results = new(); + + results.AppendLine(); + results.AppendLine($"[{type.ToUpper()}]"); + results.AppendLine(); + + SearchResult answer = await _memoryServerless.SearchAsync(query: query, index: _documentIntelligentOptions.IndexName, filter: filter); + + foreach (Citation result in answer.Results) + { + foreach(var part in result.Partitions.OrderBy(o=>o.Relevance)) + { + results.AppendLine(part.Text); + results.AppendLine($"Relevance: {part.Relevance}"); + } + + } + + return results.ToString(); + } + + } +} diff --git a/VirtualTeacherGenAIDemo.Server/Tools/SearchTool.cs b/VirtualTeacherGenAIDemo.Server/Tools/SearchTool.cs new file mode 100644 index 0000000..5ec4398 --- /dev/null +++ b/VirtualTeacherGenAIDemo.Server/Tools/SearchTool.cs @@ -0,0 +1,26 @@ +using Microsoft.SemanticKernel; +using System.ComponentModel; +using VirtualTeacherGenAIDemo.Server.Services; + +namespace VirtualTeacherGenAIDemo.Server.Tools +{ + public class SearchTool + { + + private readonly SearchService _searchService; + + public SearchTool(SearchService searchService) + { + _searchService = searchService; + } + + + [KernelFunction, + Description("Use this Tool to search the knowledge and it to help AI to answer accuratly. It could be material of a new collection, material for the teacher")] + public async Task SearchByAgent([Description("The prompt of the user")] string query, + [Description("the agent id that is on the system prompt")] string agentId) + { + return await _searchService.SearchByAgent(query, agentId, "Knowledge"); + } + } +} diff --git a/VirtualTeacherGenAIDemo.Server/VirtualTeacherGenAIDemo.Server.csproj b/VirtualTeacherGenAIDemo.Server/VirtualTeacherGenAIDemo.Server.csproj index 985b495..d87e399 100644 --- a/VirtualTeacherGenAIDemo.Server/VirtualTeacherGenAIDemo.Server.csproj +++ b/VirtualTeacherGenAIDemo.Server/VirtualTeacherGenAIDemo.Server.csproj @@ -45,11 +45,11 @@ 8.0.11 - + - + - + diff --git a/virtualteachergenaidemo.client/src/components/Utilities/FileUpload.tsx b/virtualteachergenaidemo.client/src/components/Utilities/FileUpload.tsx index 6403566..ec19238 100644 --- a/virtualteachergenaidemo.client/src/components/Utilities/FileUpload.tsx +++ b/virtualteachergenaidemo.client/src/components/Utilities/FileUpload.tsx @@ -7,9 +7,10 @@ import { v4 as uuidv4 } from 'uuid'; interface FileUploadProps { onFileUpload: (fileName: string) => void; fileName?: string; + agentId: string | undefined; } -export const FileUpload = ({ onFileUpload, fileName }: FileUploadProps) => { +export const FileUpload = ({ onFileUpload, fileName, agentId }: FileUploadProps) => { const [files, setFiles] = useState([]); const [fileErrors, setFileErrors] = useState([]); const [displayFileNames, setDisplayFileNames] = useState(fileName ? [fileName] : []); @@ -56,14 +57,14 @@ export const FileUpload = ({ onFileUpload, fileName }: FileUploadProps) => { const CHUNK_SIZE = 2 * 1024 * 1024; // 2MB - const uploadChunk = async (chunk: Blob, chunkIndex: number, totalChunks: number, fileId: string) => { + const uploadChunk = async (chunk: Blob, chunkIndex: number, totalChunks: number, fileId: string, fileName:string) => { const formData = new FormData(); formData.append('file', chunk); formData.append('chunkIndex', chunkIndex.toString()); formData.append('totalChunks', totalChunks.toString()); - formData.append('fileId', fileId); // Include the unique file ID + formData.append('fileId', fileId); + formData.append('fileName', fileName); - const agentId = 'your-agent-id'; // Replace with actual agentId const response = await fetch(`/api/FileUpload?connectionId=${connection?.connectionId || ''}&agentId=${agentId}`, { method: 'POST', body: formData @@ -91,7 +92,7 @@ export const FileUpload = ({ onFileUpload, fileName }: FileUploadProps) => { const chunk = file.slice(start, end); try { - await uploadChunk(chunk, chunkIndex, totalChunks, fileId); + await uploadChunk(chunk, chunkIndex, totalChunks, fileId, files[i].name); } catch (error) { setFileErrors(prevErrors => [...prevErrors, `File upload failed for ${file.name}. Please try again.`]); return; diff --git a/virtualteachergenaidemo.client/src/components/agent/AgentDialog.tsx b/virtualteachergenaidemo.client/src/components/agent/AgentDialog.tsx index bc4fa80..5fb8176 100644 --- a/virtualteachergenaidemo.client/src/components/agent/AgentDialog.tsx +++ b/virtualteachergenaidemo.client/src/components/agent/AgentDialog.tsx @@ -185,7 +185,7 @@ export const AgentDialog = ({ onAddAgent, onDeleteAgent, type, onClose, agent }:
- +
diff --git a/virtualteachergenaidemo.client/src/components/chat/chatWindow.tsx b/virtualteachergenaidemo.client/src/components/chat/chatWindow.tsx index 8b28a24..86b415a 100644 --- a/virtualteachergenaidemo.client/src/components/chat/chatWindow.tsx +++ b/virtualteachergenaidemo.client/src/components/chat/chatWindow.tsx @@ -150,7 +150,8 @@ function ChatWindow({ scenario, session }: ChatWindowProps) { scenarioDescription: scenarioDescription || '', agents: currentScenario?.map(agent => ({ prompt: agent.prompt, - type: agent.type + type: agent.type, + id: agent.id })) || [] }; @@ -162,13 +163,13 @@ function ChatWindow({ scenario, session }: ChatWindowProps) { ]); console.log(chatHistory); - await callLLMApi(chatHistory); + await callLLMApi(chatHistory, rolePlayAgent?.id); }; - const callLLMApi = async (chatHistory: ChatHistoryRequest) => { + const callLLMApi = async (chatHistory: ChatHistoryRequest, agentId: string | undefined) => { console.log(chatHistory); try { - const response = await fetch(`/api/chat/message?connectionId=${connection?.connectionId}`, { + const response = await fetch(`/api/chat/message?agentId=${agentId}&connectionId=${connection?.connectionId}`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -180,7 +181,6 @@ function ChatWindow({ scenario, session }: ChatWindowProps) { console.error('Error calling Chat:', error); } }; - const handleDeleteMessage = async (id: string) => { //stop the audio cancelSpeech(); diff --git a/virtualteachergenaidemo.client/src/components/scenario/scenarioList.tsx b/virtualteachergenaidemo.client/src/components/scenario/scenarioList.tsx index b306984..e20ba0d 100644 --- a/virtualteachergenaidemo.client/src/components/scenario/scenarioList.tsx +++ b/virtualteachergenaidemo.client/src/components/scenario/scenarioList.tsx @@ -26,8 +26,8 @@ const useStyles = makeStyles({ customCard: { minWidth: '400px', maxWidth: '300px', - minHeight: '280px', - maxHeight: '280px', + minHeight: '260px', + maxHeight: '260px', }, customCardSmall: { minWidth: '400px', @@ -44,13 +44,13 @@ const useStyles = makeStyles({ display: 'grid', justifyItems: 'center', alignItems: 'center', - marginTop: '10px', + marginTop: '5px', }, icon: { - fontSize: '35px', + fontSize: '30px', }, iconText: { - marginTop: '5px', + marginTop: '0px', textAlign: 'center', }, grayColor: { @@ -82,6 +82,17 @@ const useStyles = makeStyles({ editButton: { marginLeft: 'auto', }, + headerText: { + fontSize: '20px', + }, + description: { + display: '-webkit-box', + WebkitBoxOrient: 'vertical', + WebkitLineClamp: 2, + overflow: 'hidden', + textOverflow: 'ellipsis', + height: '3em', // Adjust based on line height to ensure consistent height + }, }); const getAgentColorClass = (agentType: string, classes: any) => { @@ -183,13 +194,11 @@ const ScenarioList: React.FC = ({ onScenarioStart }) => { {scenarios.map(scenario => ( {scenario.name}} + header={{scenario.name}} />
-
{scenario.description}
- - +
{scenario.description}
{role === UserRoleEnum.Admin && (
{[0, 1, 2].map(index => { @@ -197,11 +206,9 @@ const ScenarioList: React.FC = ({ onScenarioStart }) => { const colorClass = agent ? getAgentColorClass(agent.type, classes) : classes.grayColor; return (
- + - {agent ? agent.name : 'Not set'} + {agent ? agent.name.split(' ').slice(0, 3).join(' ') + (agent.name.split(' ').length > 3 ? '...' : '') : 'Not set'}
); @@ -210,6 +217,7 @@ const ScenarioList: React.FC = ({ onScenarioStart }) => { )}
+ {role === UserRoleEnum.Admin && ( diff --git a/virtualteachergenaidemo.client/src/models/SessionItem.ts b/virtualteachergenaidemo.client/src/models/SessionItem.ts index 86c0036..3445ffc 100644 --- a/virtualteachergenaidemo.client/src/models/SessionItem.ts +++ b/virtualteachergenaidemo.client/src/models/SessionItem.ts @@ -9,7 +9,7 @@ export class SessionItem { agents: SessionAgent[]; - constructor(id: string, title: string, isComplete: boolean, timestamp: Date, userId: string, scenarioName: string, scenarioDescription: string, agents: ISessionAgent[]) { + constructor(id: string, title: string, isComplete: boolean, timestamp: Date, userId: string, scenarioName: string, scenarioDescription: string, agents: SessionAgent[]) { this.id = id; this.title = title; this.isComplete = isComplete; @@ -22,12 +22,14 @@ export class SessionItem { } export class SessionAgent { + id:string prompt: string; type: string; - constructor(prompt: string, type: string) { + constructor(id:string, prompt: string, type: string) { this.prompt = prompt; this.type = type; + this.id = id; } } \ No newline at end of file From 13770567de2ececb373dda6b6bd8ced99e4596c7 Mon Sep 17 00:00:00 2001 From: Frederic Wickert Date: Fri, 13 Dec 2024 15:42:00 +0100 Subject: [PATCH 08/16] Refactor services and update file handling Refactored `AddOptions` and `AddStorageContext` methods in `ServicesExtensions.cs` to streamline option additions and use a single `CosmosOptions` instance with partition keys. Updated `AgentItem` class to replace `FileName` with `FileNames` for handling multiple file names. Expanded `CosmosOptions` to include partition key properties and an `AuthMethod` property. Refactored `CosmosDbContext` to support different authentication methods and ensure database and container creation. Updated `FileUpload` and `AgentDialog` components to handle multiple file names and state management. Made minor formatting and comment adjustments for readability and consistency. --- .../Extensions/ServicesExtensions.cs | 43 +++++-------- .../Models/Storage/AgentItem.cs | 4 +- .../Options/CosmosOptions.cs | 62 ++++++++++++++++--- .../Storage/CosmosDbContext.cs | 40 +++++++++--- .../src/components/Utilities/FileUpload.tsx | 17 ++--- .../src/components/agent/AgentDialog.tsx | 31 +++++++--- .../src/models/AgentItem.ts | 6 +- 7 files changed, 137 insertions(+), 66 deletions(-) diff --git a/VirtualTeacherGenAIDemo.Server/Extensions/ServicesExtensions.cs b/VirtualTeacherGenAIDemo.Server/Extensions/ServicesExtensions.cs index c984a30..ec150f5 100644 --- a/VirtualTeacherGenAIDemo.Server/Extensions/ServicesExtensions.cs +++ b/VirtualTeacherGenAIDemo.Server/Extensions/ServicesExtensions.cs @@ -11,22 +11,14 @@ public static class ServicesExtensions { public static IServiceCollection AddOptions(this IServiceCollection services, ConfigurationManager configuration) { - // General configuration AddOptions(ServiceOptions.PropertyName); - AddOptions(CosmosOptions.PropertyName); - AddOptions(KernelMemoryOptions.SectionName); - - AddOptions(AIServiceOptions.PropertyName); - + AddOptions(AIServiceOptions.PropertyName); AddOptions(SpeechOptions.PropertyName); - AddOptions(DocumentIntelligentOptions.PropertyName); - - return services; void AddOptions(string propertyName) @@ -63,7 +55,7 @@ internal static IServiceCollection AddCorsPolicy(this IServiceCollection service policy.WithOrigins(allowedOrigins) .WithMethods("GET", "POST", "DELETE") .AllowAnyHeader() - .AllowCredentials(); + .AllowCredentials(); }); }); } @@ -73,35 +65,29 @@ internal static IServiceCollection AddCorsPolicy(this IServiceCollection service internal static IServiceCollection AddStorageContext(this IServiceCollection services) { - IStorageContext messageStorageContext; - CosmosOptions cosmosConfig = services.BuildServiceProvider().GetRequiredService>().Value; - messageStorageContext = new CosmosDbContext(cosmosConfig.EndPoint, cosmosConfig.Database, cosmosConfig.MessageContainer); + var serviceProvider = services.BuildServiceProvider(); + var cosmosOptions = serviceProvider.GetRequiredService>().Value; + + IStorageContext messageStorageContext = new CosmosDbContext(cosmosOptions, cosmosOptions.MessageContainer, cosmosOptions.MessagePartitionKey); services.AddSingleton(new MessageRepository(messageStorageContext)); - IStorageContext sessionStorageContext; - sessionStorageContext = new CosmosDbContext(cosmosConfig.EndPoint, cosmosConfig.Database, cosmosConfig.SessionContainer); - services.AddSingleton(new SessionRepository(sessionStorageContext)); - - IStorageContext dashboardStorageContext; - dashboardStorageContext = new CosmosDbContext(cosmosConfig.EndPoint, cosmosConfig.Database, cosmosConfig.DashboardContainer); + IStorageContext sessionStorageContext = new CosmosDbContext(cosmosOptions, cosmosOptions.SessionContainer, cosmosOptions.SessionPartitionKey); + services.AddSingleton(new SessionRepository(sessionStorageContext)); + + IStorageContext dashboardStorageContext = new CosmosDbContext(cosmosOptions, cosmosOptions.DashboardContainer, cosmosOptions.DashboardPartitionKey); services.AddSingleton(new DashboardRepository(dashboardStorageContext)); - IStorageContext agentStorageContext; - agentStorageContext = new CosmosDbContext(cosmosConfig.EndPoint, cosmosConfig.Database, cosmosConfig.AgentContainer); + IStorageContext agentStorageContext = new CosmosDbContext(cosmosOptions, cosmosOptions.AgentContainer, cosmosOptions.AgentPartitionKey); services.AddSingleton(new AgentRepository(agentStorageContext)); - IStorageContext scenarioStorageContext; - scenarioStorageContext = new CosmosDbContext(cosmosConfig.EndPoint, cosmosConfig.Database, cosmosConfig.ScenarioContainer); + IStorageContext scenarioStorageContext = new CosmosDbContext(cosmosOptions, cosmosOptions.ScenarioContainer, cosmosOptions.ScenarioPartitionKey); services.AddSingleton(new ScenarioRepository(scenarioStorageContext)); - IStorageContext userStorageContext; - userStorageContext = new CosmosDbContext(cosmosConfig.EndPoint, cosmosConfig.Database, cosmosConfig.UserContainer); + IStorageContext userStorageContext = new CosmosDbContext(cosmosOptions, cosmosOptions.UserContainer, cosmosOptions.UserPartitionKey); services.AddSingleton(new UserRepository(userStorageContext)); - return services; - - } + } internal static IServiceCollection AddAIResponses(this IServiceCollection services) { @@ -155,7 +141,6 @@ private static void TrimStringProperties(T options) where T : class } } - internal static IServiceCollection AddServices(this IServiceCollection services) { services.AddScoped(); diff --git a/VirtualTeacherGenAIDemo.Server/Models/Storage/AgentItem.cs b/VirtualTeacherGenAIDemo.Server/Models/Storage/AgentItem.cs index 6f8fc65..10f124c 100644 --- a/VirtualTeacherGenAIDemo.Server/Models/Storage/AgentItem.cs +++ b/VirtualTeacherGenAIDemo.Server/Models/Storage/AgentItem.cs @@ -21,7 +21,9 @@ public class AgentItem : IStorageEntity [Required, NotEmptyOrWhitespace] public string Type { get; set; } = string.Empty; - public string FileName { get; set; } = string.Empty; + + [JsonPropertyName("fileNames")] + public string FileNames { get; set; } = string.Empty; [JsonIgnore] public string Partition => this.Type; diff --git a/VirtualTeacherGenAIDemo.Server/Options/CosmosOptions.cs b/VirtualTeacherGenAIDemo.Server/Options/CosmosOptions.cs index ccb8b8f..2549cf9 100644 --- a/VirtualTeacherGenAIDemo.Server/Options/CosmosOptions.cs +++ b/VirtualTeacherGenAIDemo.Server/Options/CosmosOptions.cs @@ -9,6 +9,12 @@ public class CosmosOptions { public const string PropertyName = "Cosmos"; + /// + /// Gets or sets the authentication method for CosmosDB. + /// + [Required, NotEmptyOrWhitespace] + public string AuthMethod { get; set; } = string.Empty; + /// /// Gets or sets the Cosmos database name. /// @@ -27,35 +33,75 @@ public class CosmosOptions [Required, NotEmptyOrWhitespace] public string MessageContainer { get; set; } = string.Empty; + /// + /// Gets or sets the partition key for the message container. + /// + [Required, NotEmptyOrWhitespace] + public string MessagePartitionKey { get; set; } = string.Empty; + /// /// Gets or sets the Cosmos container for dashboard. /// - [Required , NotEmptyOrWhitespace] + [Required, NotEmptyOrWhitespace] public string DashboardContainer { get; set; } = string.Empty; - //same for AgentContainer /// - /// Gets or sets the Cosmos container for agents. + /// Gets or sets the partition key for the dashboard container. + /// + [Required, NotEmptyOrWhitespace] + public string DashboardPartitionKey { get; set; } = string.Empty; + + /// + /// Gets or sets the Cosmos container for agents. /// [Required, NotEmptyOrWhitespace] public string AgentContainer { get; set; } = string.Empty; - //Add new contrainer for Scenario /// - /// gets or sets the Cosmos container for scenarios + /// Gets or sets the partition key for the agent container. + /// + [Required, NotEmptyOrWhitespace] + public string AgentPartitionKey { get; set; } = string.Empty; + + /// + /// Gets or sets the Cosmos container for scenarios. /// [Required, NotEmptyOrWhitespace] public string ScenarioContainer { get; set; } = string.Empty; + /// + /// Gets or sets the partition key for the scenario container. + /// + [Required, NotEmptyOrWhitespace] + public string ScenarioPartitionKey { get; set; } = string.Empty; + + /// + /// Gets or sets the Cosmos container for sessions. + /// [Required, NotEmptyOrWhitespace] public string SessionContainer { get; set; } = string.Empty; + /// + /// Gets or sets the partition key for the session container. + /// [Required, NotEmptyOrWhitespace] - public string UserContainer { get; set; } = string.Empty; + public string SessionPartitionKey { get; set; } = string.Empty; + /// + /// Gets or sets the Cosmos container for users. + /// [Required, NotEmptyOrWhitespace] - public string EndPoint { get; set; } = string.Empty; + public string UserContainer { get; set; } = string.Empty; - + /// + /// Gets or sets the partition key for the user container. + /// + [Required, NotEmptyOrWhitespace] + public string UserPartitionKey { get; set; } = string.Empty; + /// + /// Gets or sets the Cosmos endpoint. + /// + [Required, NotEmptyOrWhitespace] + public string EndPoint { get; set; } = string.Empty; } diff --git a/VirtualTeacherGenAIDemo.Server/Storage/CosmosDbContext.cs b/VirtualTeacherGenAIDemo.Server/Storage/CosmosDbContext.cs index 1af7ee9..6aa2892 100644 --- a/VirtualTeacherGenAIDemo.Server/Storage/CosmosDbContext.cs +++ b/VirtualTeacherGenAIDemo.Server/Storage/CosmosDbContext.cs @@ -1,12 +1,12 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Net; using System.Threading.Tasks; using Azure.Identity; using Microsoft.Azure.Cosmos; +using Microsoft.Extensions.Options; +using VirtualTeacherGenAIDemo.Server.Options; namespace VirtualTeacherGenAIDemo.Server.Storage; @@ -28,21 +28,41 @@ public class CosmosDbContext : IStorageContext, IDisposable where T : ISto /// /// Initializes a new instance of the CosmosDbContext class. /// - /// The CosmosDB connection string. - /// The CosmosDB database name. - /// The CosmosDB container name. - public CosmosDbContext(string connectionString, string database, string container) + /// The CosmosDB options. + /// The CosmosDB container name. + public CosmosDbContext(CosmosOptions cosmosOptions, string containerName, string partitionKey) { + + // Configure JsonSerializerOptions - var options = new CosmosClientOptions + var clientOptions = new CosmosClientOptions { SerializerOptions = new CosmosSerializationOptions { PropertyNamingPolicy = CosmosPropertyNamingPolicy.CamelCase }, }; - this._client = new CosmosClient(connectionString, new DefaultAzureCredential() , options); - this._container = this._client.GetContainer(database, container); + + if (cosmosOptions.AuthMethod == "Key") + { + this._client = new CosmosClient(cosmosOptions.ConnectionString, clientOptions); + } + else if (cosmosOptions.AuthMethod == "ManagedIdentity") + { + this._client = new CosmosClient(cosmosOptions.EndPoint, new DefaultAzureCredential(), clientOptions); + } + else + { + throw new InvalidOperationException("Invalid CosmosDB authentication method specified."); + } + + // Create database if it does not exist + var databaseResponse = this._client.CreateDatabaseIfNotExistsAsync(cosmosOptions.Database).GetAwaiter().GetResult(); + var databaseInstance = databaseResponse.Database; + + // Create container if it does not exist + var containerResponse = databaseInstance.CreateContainerIfNotExistsAsync(containerName, $"/{partitionKey}").GetAwaiter().GetResult(); + this._container = containerResponse.Container; } /// diff --git a/virtualteachergenaidemo.client/src/components/Utilities/FileUpload.tsx b/virtualteachergenaidemo.client/src/components/Utilities/FileUpload.tsx index ec19238..b1f5785 100644 --- a/virtualteachergenaidemo.client/src/components/Utilities/FileUpload.tsx +++ b/virtualteachergenaidemo.client/src/components/Utilities/FileUpload.tsx @@ -5,24 +5,24 @@ import { HubConnectionBuilder, HubConnection } from '@microsoft/signalr'; import { v4 as uuidv4 } from 'uuid'; interface FileUploadProps { - onFileUpload: (fileName: string) => void; - fileName?: string; + onFileUpload: (fileName: string) => void; agentId: string | undefined; + onChange?: (fileNames: string[]) => void; } -export const FileUpload = ({ onFileUpload, fileName, agentId }: FileUploadProps) => { +export const FileUpload = ({ onFileUpload, fileNames, agentId, onChange }: FileUploadProps) => { const [files, setFiles] = useState([]); const [fileErrors, setFileErrors] = useState([]); - const [displayFileNames, setDisplayFileNames] = useState(fileName ? [fileName] : []); + const [displayFileNames, setDisplayFileNames] = useState(fileNames ? [fileNames] : []); const [connection, setConnection] = useState(null); const [status, setStatus] = useState(''); const [fileIds, setFileIds] = useState([]); useEffect(() => { - if (fileName) { - setDisplayFileNames([fileName]); + if (fileNames) { + setDisplayFileNames([fileNames]); } - }, [fileName]); + }, [fileNames]); useEffect(() => { const hubUrl = process.env.HUB_URL; @@ -52,6 +52,9 @@ export const FileUpload = ({ onFileUpload, fileName, agentId }: FileUploadProps) setFileErrors([]); setDisplayFileNames(selectedFiles.map(file => file.name)); setFileIds(selectedFiles.map(() => uuidv4())); // Generate unique IDs for each file + if (onChange) { + onChange(selectedFiles.map(file => file.name)); + } selectedFiles.forEach(file => onFileUpload(file.name)); }; diff --git a/virtualteachergenaidemo.client/src/components/agent/AgentDialog.tsx b/virtualteachergenaidemo.client/src/components/agent/AgentDialog.tsx index 5fb8176..6a2a5ef 100644 --- a/virtualteachergenaidemo.client/src/components/agent/AgentDialog.tsx +++ b/virtualteachergenaidemo.client/src/components/agent/AgentDialog.tsx @@ -42,16 +42,15 @@ export const AgentDialog = ({ onAddAgent, onDeleteAgent, type, onClose, agent }: const [promptError, setPromptError] = useState(''); const [isOpen, setIsOpen] = useState(true); const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false); - - - const [fileName, setFileName] = useState(agent?.fileName || ''); + const [fileNames, setFileNames] = useState(); + const [fileName, setFileName] = useState(); useEffect(() => { if (agent) { setName(agent.name); setDescription(agent.description); setPrompt(agent.prompt); - setFileName(agent.fileName || ''); + setFileNames(agent.fileNames.split(",")) } }, [agent]); @@ -87,7 +86,16 @@ export const AgentDialog = ({ onAddAgent, onDeleteAgent, type, onClose, agent }: if (!valid) return; - const newAgent = { name, description, prompt, type, id: agent?.id || "", fileName }; + + + const newAgent: AgentItem = { + name, + description, + prompt, + type, + id: agent?.id || "", + fileNames: fileNames ? fileNames.join(",") : '' + }; const apiUrl = agent ? `/api/agent/${agent.id}` : '/api/agent'; const method = agent ? 'PUT' : 'POST'; @@ -97,7 +105,7 @@ export const AgentDialog = ({ onAddAgent, onDeleteAgent, type, onClose, agent }: headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify(agent), + body: JSON.stringify(newAgent), }) .then(response => { if (response.status === 204) { @@ -110,7 +118,7 @@ export const AgentDialog = ({ onAddAgent, onDeleteAgent, type, onClose, agent }: if (!agent) { newAgent.id = data.id; } - onAddAgent(newAgent) + onAddAgent(newAgent); }) .catch(error => console.error('Error:', error)); @@ -118,6 +126,7 @@ export const AgentDialog = ({ onAddAgent, onDeleteAgent, type, onClose, agent }: onClose(); }; + const handleDeleteAgent = () => { if (!agent) return; @@ -149,6 +158,12 @@ export const AgentDialog = ({ onAddAgent, onDeleteAgent, type, onClose, agent }: } }; + const handleFilesChange = (files: string[]) => { + if (files.length > 0) { + setFileNames(files); + } + }; + return ( <> @@ -185,7 +200,7 @@ export const AgentDialog = ({ onAddAgent, onDeleteAgent, type, onClose, agent }:
- +
diff --git a/virtualteachergenaidemo.client/src/models/AgentItem.ts b/virtualteachergenaidemo.client/src/models/AgentItem.ts index 94a921c..b809029 100644 --- a/virtualteachergenaidemo.client/src/models/AgentItem.ts +++ b/virtualteachergenaidemo.client/src/models/AgentItem.ts @@ -4,16 +4,16 @@ export class AgentItem { description: string; prompt: string; type: string; - fileName:string + fileNames: string; - constructor(id: string, name: string, description:string, prompt:string, type:string, fileName:string) { + constructor(id: string, name: string, description:string, prompt:string, type:string, fileNames:string) { this.id = id; this.description = description; this.name = name; this.prompt = prompt; this.type = type; - this.fileName = fileName; + this.fileNames = fileNames; } } From 12c0cfb01930ae781e24b1976b7eb57c09c58c22 Mon Sep 17 00:00:00 2001 From: Lucile Jeanneret Date: Mon, 16 Dec 2024 20:31:50 +0100 Subject: [PATCH 09/16] configure app setting --- .../appsettings.json | 82 ++++++++++++++++++- 1 file changed, 81 insertions(+), 1 deletion(-) diff --git a/VirtualTeacherGenAIDemo.Server/appsettings.json b/VirtualTeacherGenAIDemo.Server/appsettings.json index 10f68b8..e6588b1 100644 --- a/VirtualTeacherGenAIDemo.Server/appsettings.json +++ b/VirtualTeacherGenAIDemo.Server/appsettings.json @@ -5,5 +5,85 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "AllowedOrigins": [ + "https://localhost:5173/" + ], + "AzureAd": { + "Instance": "https://login.microsoftonline.com/", + "redirectUri": "" + }, + + "AIservice": { + "Types": "AzureOpenAI", + "Endpoint": "", + "Key": "", + "Models": { + "ChatDeploymentName": "gpt-4o-mini", + "Embedding": "text-embedding-3-large", + "Planner": "gpt-4o-mini" + } + }, + "Cosmos": { + "Database": "VirtualTeacher", + "MessageContainer": "message", + "MessagePartitionKey": "sessionId", + "SessionContainer": "session", + "SessionPartitionKey": "sessionId", + "DashboardContainer": "dashboard", + "DashboardPartitionKey": "dashboardId", + "AgentContainer": "agent", + "AgentPartitionKey": "agentId", + "ScenarioContainer": "scenario", + "ScenarioPartitionKey": "scenarioId", + "UserContainer": "user", + "UserPartitionKey": "userId" + }, + "DocumentIntelligent": { + "Endpoint": "", + "Key": "", + "IndexName": "virtualteacherTest" + }, + "KernelMemory": { + "Services": { + "AzureAISearch": { + "Auth": "ApiKey", + "Endpoint": "", + "APIKey": "", + "UseHybridSearch": true, // Set to true or false as needed + "UseStickySessions": true // Set to true or false as needed + }, + "AzureOpenAIEmbedding": { + "Auth": "ApiKey", + "Endpoint": "", + "APIKey": "", + "Deployment": "text-embedding-3-large", + "MaxTokenTotal": 8191, + "EmbeddingDimensions": 3072, + "MaxEmbeddingBatchSize": 10, + "MaxRetries": 10 + }, + "AzureOpenAIText": { + "Auth": "ApiKey", // Replace with appropriate AuthType value + "Endpoint": "", + "APIKey": "", + "Deployment": "gpt-4o", + "MaxTokenTotal": 128000, + "APIType": "ChatCompletion", + "MaxRetries": 10 + } + } + }, + "Service": { + "SemanticPluginsDirectory": "Plugins" + }, + "Speech": { + "SubscriptionKey": "", + "Region": "", + "DefaultLanguage": "fr-FR", + "VoiceName": "en-US-AvaMultilingualNeural" + }, + "SignalR": { + "Endpoint": "" + } } From 086971091f23eac1e31b8dfccc6797c38328ae67 Mon Sep 17 00:00:00 2001 From: Lucile Jeanneret Date: Mon, 16 Dec 2024 20:32:32 +0100 Subject: [PATCH 10/16] doc(server): add comment to msal configuration --- VirtualTeacherGenAIDemo.Server/Program.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/VirtualTeacherGenAIDemo.Server/Program.cs b/VirtualTeacherGenAIDemo.Server/Program.cs index 4697769..d89fd23 100644 --- a/VirtualTeacherGenAIDemo.Server/Program.cs +++ b/VirtualTeacherGenAIDemo.Server/Program.cs @@ -53,6 +53,7 @@ app.UseHttpsRedirection(); app.UseCors(); +// Comment to disable Azure Add Auth app.UseAuthorization(); app.MapFallbackToFile("/index.html"); @@ -61,6 +62,7 @@ name: "default", pattern: "{controller=Home}/{action=Index}/{id?}"); +// Comment to disable Azure AD authentication app.MapGet("/api/auth-config", (IConfiguration config) => new { TenantId = config["AzureAd:TenantId"], From 0d49b702ce8d0af1619c2a170a7034a7a073627a Mon Sep 17 00:00:00 2001 From: Lucile Jeanneret Date: Mon, 16 Dec 2024 21:21:53 +0100 Subject: [PATCH 11/16] fix(appsetting):cosmos settings --- VirtualTeacherGenAIDemo.Server/appsettings.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/VirtualTeacherGenAIDemo.Server/appsettings.json b/VirtualTeacherGenAIDemo.Server/appsettings.json index e6588b1..d077962 100644 --- a/VirtualTeacherGenAIDemo.Server/appsettings.json +++ b/VirtualTeacherGenAIDemo.Server/appsettings.json @@ -25,19 +25,19 @@ } }, "Cosmos": { - "Database": "VirtualTeacher", + "Database": "Conversations", "MessageContainer": "message", "MessagePartitionKey": "sessionId", - "SessionContainer": "session", - "SessionPartitionKey": "sessionId", "DashboardContainer": "dashboard", - "DashboardPartitionKey": "dashboardId", + "DashboardPartitionKey": "sessionId", "AgentContainer": "agent", - "AgentPartitionKey": "agentId", + "AgentPartitionKey": "type", "ScenarioContainer": "scenario", - "ScenarioPartitionKey": "scenarioId", + "ScenarioPartitionKey": "id", + "SessionContainer": "session", + "SessionPartitionKey": "userId", "UserContainer": "user", - "UserPartitionKey": "userId" + "UserPartitionKey": "id" }, "DocumentIntelligent": { "Endpoint": "", From 82eb9ee8706bf90ab3c3d037d269c95020458651 Mon Sep 17 00:00:00 2001 From: Lucile Jeanneret Date: Mon, 16 Dec 2024 21:46:46 +0100 Subject: [PATCH 12/16] feat(iac): init --- deploy/main.tf | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 deploy/main.tf diff --git a/deploy/main.tf b/deploy/main.tf new file mode 100644 index 0000000..9c952ae --- /dev/null +++ b/deploy/main.tf @@ -0,0 +1,32 @@ +# Define the provider +provider "azurerm" { + features {} +} + +# Reference to an existing resource group +data "azurerm_resource_group" "rg" { + name = "rg-virtual-teacher-demo-wus" +} + +# Define the App Service Plan +resource "azurerm_app_service_plan" "appserviceplan" { + name = "plan-virtual-teacher-demo-wus" + location = azurerm_resource_group.rg.location + resource_group_name = azurerm_resource_group.rg.name + sku { + tier = "Standard" + size = "S1" + } +} + +# Define the App Service +resource "azurerm_app_service" "appservice" { + name = "webapp-virtual-teacher-demo-wus" + location = azurerm_resource_group.rg.location + resource_group_name = azurerm_resource_group.rg.name + app_service_plan_id = azurerm_app_service_plan.appserviceplan.id + + site_config { + dotnet_framework_version = "v4.0" + } +} \ No newline at end of file From 9791ce7b5f210e8a103bfcf1dc77bf356319e900 Mon Sep 17 00:00:00 2001 From: Lucile Jeanneret Date: Mon, 16 Dec 2024 21:52:02 +0100 Subject: [PATCH 13/16] feat(iac): init worflows --- .github/workflows/terraform.yml | 39 +++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 .github/workflows/terraform.yml diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml new file mode 100644 index 0000000..75aa196 --- /dev/null +++ b/.github/workflows/terraform.yml @@ -0,0 +1,39 @@ +name: Terraform + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + terraform: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + - name: Set up Terraform + uses: hashicorp/setup-terraform@v2 + with: + terraform_version: 1.0.11 + + - name: Initialize Terraform + run: terraform init + working-directory: ./deploy + + - name: Validate Terraform + run: terraform validate + working-directory: ./deploy + + - name: Plan Terraform + run: terraform plan + working-directory: ./deploy + + - name: Apply Terraform + if: github.ref == 'refs/heads/main' + run: terraform apply -auto-approve + working-directory: ./deploy \ No newline at end of file From 2e27fe4017b312d23dea3332f3907151b90d3b5c Mon Sep 17 00:00:00 2001 From: Lucile Jeanneret Date: Mon, 16 Dec 2024 22:05:30 +0100 Subject: [PATCH 14/16] fix(iac) --- deploy/main.tf | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/deploy/main.tf b/deploy/main.tf index 9c952ae..f00fd3f 100644 --- a/deploy/main.tf +++ b/deploy/main.tf @@ -8,22 +8,20 @@ data "azurerm_resource_group" "rg" { name = "rg-virtual-teacher-demo-wus" } -# Define the App Service Plan resource "azurerm_app_service_plan" "appserviceplan" { name = "plan-virtual-teacher-demo-wus" - location = azurerm_resource_group.rg.location - resource_group_name = azurerm_resource_group.rg.name + location = data.azurerm_resource_group.rg.location + resource_group_name = data.azurerm_resource_group.rg.name sku { tier = "Standard" size = "S1" } } -# Define the App Service resource "azurerm_app_service" "appservice" { name = "webapp-virtual-teacher-demo-wus" - location = azurerm_resource_group.rg.location - resource_group_name = azurerm_resource_group.rg.name + location = data.azurerm_resource_group.rg.location + resource_group_name = data.azurerm_resource_group.rg.name app_service_plan_id = azurerm_app_service_plan.appserviceplan.id site_config { From 5fe77c792c00bb1c7b2dcb590f749315d9593803 Mon Sep 17 00:00:00 2001 From: Lucile Jeanneret Date: Mon, 16 Dec 2024 22:51:35 +0100 Subject: [PATCH 15/16] ci(workflows): use sp from secrets --- .github/workflows/terraform.yml | 6 ++++++ deploy/main.tf | 5 +---- deploy/variables.tf | 4 ++++ 3 files changed, 11 insertions(+), 4 deletions(-) create mode 100644 deploy/variables.tf diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml index 75aa196..5fdfd7f 100644 --- a/.github/workflows/terraform.yml +++ b/.github/workflows/terraform.yml @@ -7,6 +7,7 @@ on: pull_request: branches: - main + workflow_dispatch: # This allows manual triggering of the workflow jobs: terraform: @@ -21,6 +22,11 @@ jobs: with: terraform_version: 1.0.11 + - name: Set up Azure credentials + uses: azure/login@v1 + with: + creds: ${{ secrets.AZURE_CREDENTIALS }} + - name: Initialize Terraform run: terraform init working-directory: ./deploy diff --git a/deploy/main.tf b/deploy/main.tf index f00fd3f..2f860de 100644 --- a/deploy/main.tf +++ b/deploy/main.tf @@ -1,6 +1,7 @@ # Define the provider provider "azurerm" { features {} + subscription_id = var.subscription_id } # Reference to an existing resource group @@ -23,8 +24,4 @@ resource "azurerm_app_service" "appservice" { location = data.azurerm_resource_group.rg.location resource_group_name = data.azurerm_resource_group.rg.name app_service_plan_id = azurerm_app_service_plan.appserviceplan.id - - site_config { - dotnet_framework_version = "v4.0" - } } \ No newline at end of file diff --git a/deploy/variables.tf b/deploy/variables.tf new file mode 100644 index 0000000..37339b4 --- /dev/null +++ b/deploy/variables.tf @@ -0,0 +1,4 @@ +variable "subscription_id" { + description = "The Azure subscription ID" + type = string +} \ No newline at end of file From 9ae2be11a9517aeffe6f03f7ce1b2964f7cb6b64 Mon Sep 17 00:00:00 2001 From: Lucile Jeanneret Date: Mon, 16 Dec 2024 22:58:23 +0100 Subject: [PATCH 16/16] remove --- .github/workflows/terraform.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml index 5fdfd7f..7516e88 100644 --- a/.github/workflows/terraform.yml +++ b/.github/workflows/terraform.yml @@ -7,7 +7,6 @@ on: pull_request: branches: - main - workflow_dispatch: # This allows manual triggering of the workflow jobs: terraform: