diff --git a/samples/AIStreaming/AIStreaming.csproj b/samples/AIStreaming/AIStreaming.csproj new file mode 100644 index 00000000..a8cf1ae2 --- /dev/null +++ b/samples/AIStreaming/AIStreaming.csproj @@ -0,0 +1,14 @@ + + + + net8.0 + enable + enable + + + + + + + + diff --git a/samples/AIStreaming/AIStreaming.sln b/samples/AIStreaming/AIStreaming.sln new file mode 100644 index 00000000..a80da20b --- /dev/null +++ b/samples/AIStreaming/AIStreaming.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.10.35201.131 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AIStreaming", "AIStreaming.csproj", "{6DC96F1B-8F65-471C-9676-0DD3E0A8A5BB}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {6DC96F1B-8F65-471C-9676-0DD3E0A8A5BB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6DC96F1B-8F65-471C-9676-0DD3E0A8A5BB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6DC96F1B-8F65-471C-9676-0DD3E0A8A5BB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6DC96F1B-8F65-471C-9676-0DD3E0A8A5BB}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {7D19F711-78E2-46B8-B90B-33CF6F10724A} + EndGlobalSection +EndGlobal diff --git a/samples/AIStreaming/GroupAccessor.cs b/samples/AIStreaming/GroupAccessor.cs new file mode 100644 index 00000000..ad94f9cc --- /dev/null +++ b/samples/AIStreaming/GroupAccessor.cs @@ -0,0 +1,24 @@ +using System.Collections.Concurrent; + +namespace AIStreaming +{ + public class GroupAccessor + { + private readonly ConcurrentDictionary _store = new(); + + public void Join(string connectionId, string groupName) + { + _store.AddOrUpdate(connectionId, groupName, (key, value) => groupName); + } + + public void Leave(string connectionId) + { + _store.TryRemove(connectionId, out _); + } + + public bool TryGetGroup(string connectionId, out string? group) + { + return _store.TryGetValue(connectionId, out group); + } + } +} diff --git a/samples/AIStreaming/GroupHistoryStore.cs b/samples/AIStreaming/GroupHistoryStore.cs new file mode 100644 index 00000000..4123be43 --- /dev/null +++ b/samples/AIStreaming/GroupHistoryStore.cs @@ -0,0 +1,41 @@ +using OpenAI.Chat; +using System.Collections.Concurrent; + +namespace AIStreaming +{ + public class GroupHistoryStore + { + private readonly ConcurrentDictionary> _store = new(); + + public IReadOnlyList GetOrAddGroupHistory(string groupName, string userName, string message) + { + var chatMessages = _store.GetOrAdd(groupName, _ => InitiateChatMessages()); + chatMessages.Add(new UserChatMessage(GenerateUserChatMessage(userName, message))); + return chatMessages.AsReadOnly(); + } + + public void UpdateGroupHistoryForAssistant(string groupName, string message) + { + var chatMessages = _store.GetOrAdd(groupName, _ => InitiateChatMessages()); + chatMessages.Add(new AssistantChatMessage(message)); + } + + private IList InitiateChatMessages() + { + var messages = new List + { + new SystemChatMessage("You are a friendly and knowledgeable assistant participating in a group discussion." + + " Your role is to provide helpful, accurate, and concise information when addressed." + + " Maintain a respectful tone, ensure your responses are clear and relevant to the group's ongoing conversation, and assist in facilitating productive discussions." + + " Messages from users will be in the format 'UserName: chat messages'." + + " Pay attention to the 'UserName' to understand who is speaking and tailor your responses accordingly."), + }; + return messages; + } + + private string GenerateUserChatMessage(string userName, string message) + { + return $"{userName}: {message}"; + } + } +} diff --git a/samples/AIStreaming/Hubs/GroupChatHub.cs b/samples/AIStreaming/Hubs/GroupChatHub.cs new file mode 100644 index 00000000..723fdad0 --- /dev/null +++ b/samples/AIStreaming/Hubs/GroupChatHub.cs @@ -0,0 +1,74 @@ +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Options; +using OpenAI; +using System.Text; + +namespace AIStreaming.Hubs +{ + public class GroupChatHub : Hub + { + private readonly GroupAccessor _groupAccessor; + private readonly GroupHistoryStore _history; + private readonly OpenAIClient _openAI; + private readonly OpenAIOptions _options; + + public GroupChatHub(GroupAccessor groupAccessor, GroupHistoryStore history, OpenAIClient openAI, IOptions options) + { + _groupAccessor = groupAccessor ?? throw new ArgumentNullException(nameof(groupAccessor)); + _history = history ?? throw new ArgumentNullException(nameof(history)); + _openAI = openAI ?? throw new ArgumentNullException(nameof(openAI)); + _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); + } + + public async Task JoinGroup(string groupName) + { + await Groups.AddToGroupAsync(Context.ConnectionId, groupName); + _groupAccessor.Join(Context.ConnectionId, groupName); + } + + public override Task OnDisconnectedAsync(Exception? exception) + { + _groupAccessor.Leave(Context.ConnectionId); + return Task.CompletedTask; + } + + public async Task Chat(string userName, string message) + { + if (!_groupAccessor.TryGetGroup(Context.ConnectionId, out var groupName)) + { + throw new InvalidOperationException("Not in a group."); + } + + if (message.StartsWith("@gpt")) + { + var id = Guid.NewGuid().ToString(); + var actualMessage = message.Substring(4).Trim(); + var messagesIncludeHistory = _history.GetOrAddGroupHistory(groupName, userName, actualMessage); + await Clients.OthersInGroup(groupName).SendAsync("NewMessage", userName, message); + + var chatClient = _openAI.GetChatClient(_options.Model); + var totalCompletion = new StringBuilder(); + var lastSentTokenLength = 0; + await foreach (var completion in chatClient.CompleteChatStreamingAsync(messagesIncludeHistory)) + { + foreach (var content in completion.ContentUpdate) + { + totalCompletion.Append(content); + if (totalCompletion.Length - lastSentTokenLength > 20) + { + await Clients.Group(groupName).SendAsync("newMessageWithId", "ChatGPT", id, totalCompletion.ToString()); + lastSentTokenLength = totalCompletion.Length; + } + } + } + _history.UpdateGroupHistoryForAssistant(groupName, totalCompletion.ToString()); + await Clients.Group(groupName).SendAsync("newMessageWithId", "ChatGPT", id, totalCompletion.ToString()); + } + else + { + _history.GetOrAddGroupHistory(groupName, userName, message); + await Clients.OthersInGroup(groupName).SendAsync("NewMessage", userName, message); + } + } + } +} diff --git a/samples/AIStreaming/OpenAIExtensions.cs b/samples/AIStreaming/OpenAIExtensions.cs new file mode 100644 index 00000000..e232a48c --- /dev/null +++ b/samples/AIStreaming/OpenAIExtensions.cs @@ -0,0 +1,32 @@ +using Azure.AI.OpenAI; +using Microsoft.Extensions.Options; +using OpenAI; +using System.ClientModel; + +namespace AIStreaming +{ + public static class OpenAIExtensions + { + public static IServiceCollection AddAzureOpenAI(this IServiceCollection services, IConfiguration configuration) + { + return services + .Configure(configuration.GetSection("OpenAI")) + .AddSingleton(provider => + { + var options = provider.GetRequiredService>().Value; + return new AzureOpenAIClient(new Uri(options.Endpoint), new ApiKeyCredential(options.Key)); + }); + } + + public static IServiceCollection AddOpenAI(this IServiceCollection services, IConfiguration configuration) + { + return services + .Configure(configuration.GetSection("OpenAI")) + .AddSingleton(provider => + { + var options = provider.GetRequiredService>().Value; + return new OpenAIClient(new ApiKeyCredential(options.Key)); + }); + } + } +} diff --git a/samples/AIStreaming/OpenAIOptions.cs b/samples/AIStreaming/OpenAIOptions.cs new file mode 100644 index 00000000..1fc12f8a --- /dev/null +++ b/samples/AIStreaming/OpenAIOptions.cs @@ -0,0 +1,20 @@ +namespace AIStreaming +{ + public class OpenAIOptions + { + /// + /// The endpoint of Azure OpenAI service. Only available for Azure OpenAI. + /// + public string? Endpoint { get; set; } + + /// + /// The key of OpenAI service. + /// + public string? Key { get; set; } + + /// + /// The model to use. + /// + public string? Model { get; set; } + } +} diff --git a/samples/AIStreaming/Program.cs b/samples/AIStreaming/Program.cs new file mode 100644 index 00000000..8007a14c --- /dev/null +++ b/samples/AIStreaming/Program.cs @@ -0,0 +1,21 @@ +using AIStreaming; +using AIStreaming.Hubs; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +builder.Services.AddSignalR().AddAzureSignalR(); +builder.Services.AddSingleton() + .AddSingleton() + .AddAzureOpenAI(builder.Configuration); + +var app = builder.Build(); + +app.UseHttpsRedirection(); +app.UseDefaultFiles(); +app.UseStaticFiles(); + +app.UseRouting(); + +app.MapHub("/groupChat"); +app.Run(); diff --git a/samples/AIStreaming/Properties/launchSettings.json b/samples/AIStreaming/Properties/launchSettings.json new file mode 100644 index 00000000..a5e4052c --- /dev/null +++ b/samples/AIStreaming/Properties/launchSettings.json @@ -0,0 +1,28 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:54484", + "sslPort": 44368 + } + }, + "profiles": { + "AIStreaming": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "http://localhost:5000/" + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/samples/AIStreaming/README.md b/samples/AIStreaming/README.md new file mode 100644 index 00000000..e53ff753 --- /dev/null +++ b/samples/AIStreaming/README.md @@ -0,0 +1,127 @@ +# AI Streaming with SignalR + +## Introduction + +In the current landscape of digital communication, AI-powered chatbots and streaming technology have become increasingly popular. +This project aims to combine these two trends into a seamless group chat application by leveraging SignalR for real-time communication and integrating ChatGPT. +This project demonstrates SignalR group chats and ChatGPT integration. + +## Features + +- Group Chat Functionality: Users can create or join groups and participate in a shared chat experience. +- AI Integration: Users can interact with an AI chatbot by using the @gpt command, seamlessly integrating AI responses into the group chat. +- Real-Time Streaming: The application supports real-time message streaming, ensuring that all participants receive updates instantly. + +## Build and Run + +### Prerequisites + +- [.NET 8.0](https://dotnet.microsoft.com/download/dotnet/8.0) +- Azure OpenAI or OpenAI + +### Steps + +- Edit `appsettings.json` and update the `Endpoint` and `Key` to be the endpoint and key of your Azure OpenAI instance. Find how to get endpoint and key [here](https://learn.microsoft.com/azure/ai-services/openai/chatgpt-quickstart?tabs=command-line%2Cpython-new&pivots=programming-language-csharp#retrieve-key-and-endpoint). +Update the `model` to your deployment name of Azure OpenAI. + +- Edit `Azure.SignalR.ConnectionString` in `appsettings.json` to the connection string of Azure SignalR Service. For the security concern, we suggest using +identity based connection string. + +``` +Endpoint=xxx;AuthType=azure +``` + +And then you need to grant your user the `SignalR App Server ` role. For more connection string details, please access to [Connection String](https://learn.microsoft.com/en-us/azure/azure-signalr/concept-connection-string), and for more details about permission, please access to [Assign Azure roles for access rights](https://learn.microsoft.com/azure/azure-signalr/signalr-concept-authorize-azure-active-directory#assign-azure-roles-for-access-rights). + + +Run the project with: + +```bash +dotnet run +``` + +Open your browser and navigate to http://localhost:5000 to see the application in action. + +![chat sample](./images/chat.jpg) + +### Use OpenAI instead of Azure OpenAI + +You can also use OpenAI instead of Azure OpenAI with some minor changes. + +1. Update the `appsettings.json`: + +```json +"OpenAI": { + "Endpoint": null, // Leave it null + "key": "", + "Model": "gpt-4o" +} +``` + +2. Update the `Program.cs`: + +```csharp +builder.Services.AddSingleton() + .AddSingleton() +// .AddAzureOpenAI(builder.Configuration); // Comment this line and add the below line + .AddOpenAI(builder.Configuration); +``` + +## How It Works + +### 1. Group Chat + +When a user sends a message in the chat, it is broadcast to all other members of the group using SignalR. If the message does not contain the `@gpt` prefix, it is treated as a regular message, stored in the group’s chat history, and use `Clients.OthersInGroup(groupName).SendAsync()` to send to all connected users. + +### 2. AI Interaction and Streaming + +If a message begins with @gpt, the application interprets it as a request to involve the AI chatbot powered by OpenAI. Below are some key details on how this interaction works. + +#### Roles in chat completion + +The OpenAI Chat Completions API supports three distinct roles for generating responses: assistant, user, and system. + +- The assistant role stores previous AI responses. +- The user role contains requests or comments by users. +- The system role sets the guidelines for how the AI should respond. + +In this project, the system role is pre-configured to instruct the AI to act as a group chat assistant. This configuration, located in the GroupHistoryStore.cs file, ensures the AI responds in a manner that is friendly, knowledgeable, and contextually relevant to the ongoing group conversation. + +```csharp +new SystemChatMessage("You are a friendly and knowledgeable assistant participating in a group discussion." + + " Your role is to provide helpful, accurate, and concise information when addressed." + + " Maintain a respectful tone, ensure your responses are clear and relevant to the group's ongoing conversation, and assist in facilitating productive discussions." + + " Messages from users will be in the format 'UserName: chat messages'." + + " Pay attention to the 'UserName' to understand who is speaking and tailor your responses accordingly."), +``` + +All the messages from the user are in the `user` role, with the format `UserName: chat messages`. + + +#### History content + +To enable the OpenAI model to generate context-aware responses, the chat history of the group is provided to the model. This history is managed by the GroupHistoryStore class, which maintains and updates the chat logs for each group. + +Both the user’s messages and the AI’s responses are stored in the chat history, with user messages labeled under the user role and AI responses under the assistant role. These history entries are then sent to the OpenAI model as a `IList`. + +```csharp +// GroupChatHub.cs +chatClient.CompleteChatStreamingAsync(messagesIncludeHistory) +``` + +#### Workflow + +When a user sends a message starting with `@gpt`, the application sends the message together with the whole history to the OpenAI API for completion. The AI model generates a response based on the user's input and the group's chat history. + +The application uses the streaming capabilities of OpenAI to progressively send the AI's response back to the client as it is generated. The response is buffered and sent in chunks whenever the accumulated content exceeds a specific length, making the AI interaction feel more responsive. + +```mermaid +sequenceDiagram + Client->>+Server: @gpt instructions? + Server->>+OpenAI: instruction + OpenAI->>Server: Partial Completion data token + OpenAI->>Server: Partial Completion data token + Server->>Client:Batch Partial Data + OpenAI->>-Server: Partial Completion data token + Server->>-Client:Batch Partial Data +``` diff --git a/samples/AIStreaming/appsettings.Development.json b/samples/AIStreaming/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/samples/AIStreaming/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/samples/AIStreaming/appsettings.json b/samples/AIStreaming/appsettings.json new file mode 100644 index 00000000..1eaf81a7 --- /dev/null +++ b/samples/AIStreaming/appsettings.json @@ -0,0 +1,19 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Azure": { + "SignalR": { + "ConnectionString": "" + } + }, + "OpenAI": { + "Endpoint": "", + "key": "", + "Model": "gpt-4o" + } +} diff --git a/samples/AIStreaming/images/chat.jpg b/samples/AIStreaming/images/chat.jpg new file mode 100644 index 00000000..8a9d78f7 Binary files /dev/null and b/samples/AIStreaming/images/chat.jpg differ diff --git a/samples/AIStreaming/wwwroot/css/styles.css b/samples/AIStreaming/wwwroot/css/styles.css new file mode 100644 index 00000000..deb0e0bb --- /dev/null +++ b/samples/AIStreaming/wwwroot/css/styles.css @@ -0,0 +1,156 @@ +body { + font-family: Arial, sans-serif; + background-color: #f4f4f4; + margin: 0; + padding: 0; + display: flex; + justify-content: center; + align-items: center; + height: 100vh; +} + +.hidden { + visibility: hidden +} + +.modal { + background-color: rgba(0, 0, 0, 0.5); + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; +} + +.modal-content { + background-color: white; + padding: 20px; + border-radius: 10px; + text-align: center; +} + +.modal-content input { + margin: 10px 0; + padding: 10px; + width: 80%; +} + +.modal-content button { + padding: 10px 20px; + background-color: #007bff; + color: white; + border: none; + border-radius: 5px; + cursor: pointer; +} + +.modal-content button:hover { + background-color: #0056b3; +} + +.user-info { + display: flex; + gap: 10px; + align-items: center; + font-size: 14px; +} + +#userNameDisplay { + font-weight: bold; +} + +#connectionStatus { + padding: 5px 10px; + border-radius: 5px; + font-weight: bold; +} + +.status-connected { + background-color: #28a745; + color: white; +} + +.status-disconnected { + background-color: #dc3545; + color: white; +} + + +.chat-container { + display: flex; + flex-direction: column; + width: 90%; + max-width: 600px; + height: 700px; /* Fixed height for the chat container */ + background-color: white; + border-radius: 10px; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); +} + +header { + background-color: #007bff; + color: white; + padding: 10px; + border-radius: 10px 10px 0 0; + text-align: center; +} + +.message { + margin: 5px 0; + padding: 10px; + border-radius: 10px; + max-width: 60%; + display: inline-block; /* Ensure messages do not expand to full width */ +} + +.message.sent { + background-color: #007bff; + color: white; + align-self: flex-end; /* Align sent messages to the right */ +} + +.message.received { + background-color: #f1f1f1; + color: black; + align-self: flex-start; /* Align received messages to the left */ +} + +.chat-messages { + flex-grow: 1; + display: flex; + flex-direction: column; /* Ensure messages stack vertically */ + padding: 10px; + overflow-y: auto; /* Enable scrollbar when content overflows */ + background-color: #f9f9f9; + border-bottom: 1px solid #ccc; +} + +.chat-input { + display: flex; + padding: 10px; +} + +.chat-input input { + flex-grow: 1; + padding: 10px; + border: 1px solid #ccc; + border-radius: 5px; +} + +.chat-input button { + padding: 10px 20px; + background-color: #007bff; + color: white; + border: none; + border-radius: 5px; + margin-left: 10px; + cursor: pointer; +} + +.chat-input button:hover { + background-color: #0056b3; +} diff --git a/samples/AIStreaming/wwwroot/favicon.ico b/samples/AIStreaming/wwwroot/favicon.ico new file mode 100644 index 00000000..63e859b4 Binary files /dev/null and b/samples/AIStreaming/wwwroot/favicon.ico differ diff --git a/samples/AIStreaming/wwwroot/index.html b/samples/AIStreaming/wwwroot/index.html new file mode 100644 index 00000000..f2aa3865 --- /dev/null +++ b/samples/AIStreaming/wwwroot/index.html @@ -0,0 +1,50 @@ + + + + + + Group Chat + + + + + + + + + + + + + + + + diff --git a/samples/AIStreaming/wwwroot/js/scripts.js b/samples/AIStreaming/wwwroot/js/scripts.js new file mode 100644 index 00000000..0dbc4862 --- /dev/null +++ b/samples/AIStreaming/wwwroot/js/scripts.js @@ -0,0 +1,128 @@ +var connection = null +var realUserName = null +updateConnectionStatus(false) + +document.getElementById('userName').addEventListener('keydown', function (event) { + if (!realUserName && event.key === 'Enter') { + submitName(); + } +}); + +document.getElementById('chatInput').addEventListener('keydown', function (event) { + const textValue = document.getElementById('chatInput').value; + if (textValue && event.key === 'Enter') { + sendMessage(); + } +}); + +function submitName() { + const userName = document.getElementById('userName').value; + if (userName) { + document.getElementById('namePrompt').classList.add('hidden'); + document.getElementById('groupSelection').classList.remove('hidden'); + document.getElementById('userNameDisplay').innerText = userName; + + realUserName = userName; + } else { + alert('Please enter your name'); + } +} + +function createGroup() { + const groupName = Math.random().toString(36).substr(2, 6); + joinGroupWithName(groupName); +} + +function joinGroup() { + const groupName = document.getElementById('groupName').value; + if (groupName) { + joinGroupWithName(groupName); + } else { + alert('Please enter a group name'); + } +} + +function joinGroupWithName(groupName) { + document.getElementById('groupSelection').classList.add('hidden'); + document.getElementById('chatGroupName').innerText = 'Group: ' + groupName; + document.getElementById('chatPage').classList.remove('hidden'); + + connection = new signalR.HubConnectionBuilder().withUrl(`/groupChat`).withAutomaticReconnect().build(); + bindConnectionMessages(connection); + connection.start().then(() => { + updateConnectionStatus(true); + onConnected(connection); + connection.send("JoinGroup", groupName); + }).catch(error => { + updateConnectionStatus(false); + console.error(error); + }) +} + +function bindConnectionMessages(connection) { + connection.on('newMessage', (name, message) => { + appendMessage(false, `${name}: ${message}`); + }); + connection.on('newMessageWithId', (name, id, message) => { + appendMessageWithId(id, `${name}: ${message}`); + }); + connection.onclose(() => { + updateConnectionStatus(false); + }); +} + +function onConnected(connection) { + console.log('connection started'); +} + +function sendMessage() { + const message = document.getElementById('chatInput').value; + if (message) { + appendMessage(true, message); + document.getElementById('chatInput').value = ''; + connection.send("Chat", realUserName, message); + } +} + +function appendMessage(isSender, message) { + const chatMessages = document.getElementById('chatMessages'); + const messageElement = createMessageElement(message, isSender, null) + chatMessages.appendChild(messageElement); + chatMessages.scrollTop = chatMessages.scrollHeight; +} + +function appendMessageWithId(id, message) { + // We update the full message + const chatMessages = document.getElementById('chatMessages'); + if (document.getElementById(id)) { + let messageElement = document.getElementById(id); + messageElement.innerText = message; + } else { + let messageElement = createMessageElement(message, false, id); + chatMessages.appendChild(messageElement); + } + chatMessages.scrollTop = chatMessages.scrollHeight; +} + +function createMessageElement(message, isSender, id) { + const messageElement = document.createElement('div'); + messageElement.classList.add('message', isSender ? 'sent' : 'received'); + messageElement.innerText = message; + if (id) { + messageElement.id = id; + } + return messageElement; +} + +function updateConnectionStatus(isConnected) { + const statusElement = document.getElementById('connectionStatus'); + if (isConnected) { + statusElement.innerText = 'Connected'; + statusElement.classList.remove('status-disconnected'); + statusElement.classList.add('status-connected'); + } else { + statusElement.innerText = 'Disconnected'; + statusElement.classList.remove('status-connected'); + statusElement.classList.add('status-disconnected'); + } +} \ No newline at end of file