Add OpenAI chat completions to your WinUI 3 / Windows App SDK desktop app

In this how-to, you'll learn how to integrate OpenAI's API into your WinUI 3 / Windows App SDK desktop app. We'll build a chat-like interface that lets you generate responses to messages using OpenAI's chat completions API:

A screenshot of a less minimal WinUI chat app.

Prerequisites

Create a project

  1. Open Visual Studio and create a new project via File > New > Project.
  2. Search for WinUI and select the Blank App, Packaged (WinUI 3 in Desktop) C# project template.
  3. Specify a project name, solution name, and directory. In this example, our ChatGPT_WinUI3 project belongs to a ChatGPT_WinUI3 solution, which will be created in C:\Projects\.

After creating your project, you should see the following default file structure in your Solution Explorer:

A screenshot of the default directory structure in Solution Explorer.

Set your environment variable

In order to use the OpenAI SDK, you'll need to set an environment variable with your API key. In this example, we'll use the OPENAI_API_KEY environment variable. Once you have your API key from the OpenAI developer dashboard, you can set the environment variable from the command line as follows:

setx OPENAI_API_KEY <your-api-key>

Note that this method works well for development, but you'll want to use a more secure method for production apps (for example: you could store your API key in a secure key vault that a remote service can access on behalf of your app). See Best practices for OpenAI key safety.

Install the OpenAI library

From Visual Studio's View menu, select Terminal. You should see an instance of Developer Powershell appear. Run the following command from your project's root directory to install the OpenAI .NET package:

dotnet add package OpenAI

Initialize the library

In MainWindow.xaml.cs, initialize the OpenAI library with your API key:

//...
using OpenAI;
using OpenAI.Chat;

namespace ChatGPT_WinUI3
{
    public sealed partial class MainWindow : Window
    {
        private OpenAIClient openAiService;

        public MainWindow()
        {
            this.InitializeComponent();
           
            var openAiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY");

            openAiService = new(openAiKey);
        }
    }
}

Build the chat UI

We'll use a StackPanel to display a list of messages, and a TextBox to let users enter new messages. Update MainWindow.xaml as follows:

<Window
    x:Class="ChatGPT_WinUI3.MainWindow"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="using:ChatGPT_WinUI3"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    mc:Ignorable="d">
    <Grid>
        <StackPanel Orientation="Vertical" HorizontalAlignment="Stretch">
            <ListView x:Name="ConversationList" />
            <StackPanel Orientation="Horizontal">
                <TextBox x:Name="InputTextBox" HorizontalAlignment="Stretch"/>
                <Button x:Name="SendButton" Content="Send" Click="SendButton_Click"/>
            </StackPanel>
        </StackPanel>
    </Grid>
</Window>

Implement message sending, receiving, and displaying

Add a SendButton_Click event handler to handle the sending, receiving, and display of messages:

public sealed partial class MainWindow : Window
{
    // ...

    private async void SendButton_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            string userInput = InputTextBox.Text;

            if (!string.IsNullOrEmpty(userInput))
            {
                AddMessageToConversation($"User: {userInput}");
                InputTextBox.Text = string.Empty;
                var chatClient = openAiService.GetChatClient("gpt-4o"); // or another model
                var chatOptions = new ChatCompletionOptions
                {
                    MaxOutputTokenCount = 300
                };

                // Assemble the chat prompt with a system message and the user's input
                var completionResult = await chatClient.CompleteChatAsync(
                    [
                        ChatMessage.CreateSystemMessage("You are a helpful assistant."),
                        ChatMessage.CreateUserMessage(userInput)
                    ],
                    chatOptions);

                if (completionResult != null && completionResult.Value.Content.Count > 0)
                {
                    AddMessageToConversation($"GPT: {completionResult.Value.Content.First().Text}");
                }
                else
                {
                    AddMessageToConversation($"GPT: Sorry, something bad happened: {completionResult?.Value.Refusal ?? "Unknown error."}");
                }
            }
        }
        catch (Exception ex)
        {
            AddMessageToConversation($"GPT: Sorry, something bad happened: {ex.Message}");
        }
    }

    private void AddMessageToConversation(string message)
    {
        ConversationList.Items.Add(message);
        ConversationList.ScrollIntoView(ConversationList.Items[ConversationList.Items.Last()]);
    }
}

Run the app

Run the app and try chatting! You should see something like this:

A screenshot of a minimal WinUI chat app.

Improve the chat interface

Let's make the following improvements to the chat interface:

  • Add a ScrollViewer to the StackPanel to enable scrolling.
  • Add a TextBlock to display the GPT response in a way that's more visually distinct from the user's input.
  • Add a ProgressBar to indicate when the app is waiting for a response from the GPT API.
  • Center the StackPanel in the window, similar to ChatGPT's web interface.
  • Ensure that messages wrap to the next line when they reach the edge of the window.
  • Make the TextBox larger and responsive to the Enter key.

Starting from the top:

Add ScrollViewer

Wrap the ListView in a ScrollViewer to enable vertical scrolling on long conversations:

        <StackPanel Orientation="Vertical" HorizontalAlignment="Stretch">
            <ScrollViewer x:Name="ConversationScrollViewer" VerticalScrollBarVisibility="Auto" MaxHeight="500">
                <ListView x:Name="ConversationList" />
            </ScrollViewer>
            <!-- ... -->
        </StackPanel>

Use TextBlock

Modify the AddMessageToConversation method to style the user's input and the GPT response differently:

    // ...
    private void AddMessageToConversation(string message)
    {
        var messageBlock = new TextBlock
        {
            Text = message,
            Margin = new Thickness(5)
        };
        if (message.StartsWith("User:"))
        {
            messageBlock.Foreground = new SolidColorBrush(Colors.LightBlue);
        }
        else
        {
            messageBlock.Foreground = new SolidColorBrush(Colors.LightGreen);
        }
        ConversationList.Items.Add(messageBlock);
        ConversationList.ScrollIntoView(ConversationList.Items.Last()); 
    }

Add ProgressBar

To indicate when the app is waiting for a response, add a ProgressBar to the StackPanel:

        <StackPanel Orientation="Vertical" HorizontalAlignment="Stretch">
            <ScrollViewer x:Name="ConversationScrollViewer" VerticalScrollBarVisibility="Auto" MaxHeight="500">
                <ListView x:Name="ConversationList" />
            </ScrollViewer>
            <ProgressBar x:Name="ResponseProgressBar" Height="5" IsIndeterminate="True" Visibility="Collapsed"/> <!-- new! -->
        </StackPanel>

Then, update the SendButton_Click event handler to show the ProgressBar while waiting for a response:

    private async void SendButton_Click(object sender, RoutedEventArgs e)
    {
        ResponseProgressBar.Visibility = Visibility.Visible; // new!
        string userInput = InputTextBox.Text;

        try
        {
            if (!string.IsNullOrEmpty(userInput))
            {
                AddMessageToConversation($"User: {userInput}");
                InputTextBox.Text = string.Empty;
                var chatClient = openAiService.GetChatClient("gpt-4o"); // or another model
                var chatOptions = new ChatCompletionOptions
                {
                    MaxOutputTokenCount = 300
                };

                // Assemble the chat prompt with a system message and the user's input
                var completionResult = await chatClient.CompleteChatAsync(
                    [
                        ChatMessage.CreateSystemMessage("You are a helpful assistant."),
                        ChatMessage.CreateUserMessage(userInput)
                    ],
                    chatOptions);

                if (completionResult != null && completionResult.Value.Content.Count > 0)
                {
                    AddMessageToConversation($"GPT: {completionResult.Value.Content.First().Text}");
                }
                else
                {
                    AddMessageToConversation($"GPT: Sorry, something bad happened: {completionResult?.Value.Refusal ?? "Unknown error."}");
                }
            }
        }
        catch (Exception ex)
        {
            AddMessageToConversation($"GPT: Sorry, something bad happened: {ex.Message}");
        }
        finally // new!
        {
            ResponseProgressBar.Visibility = Visibility.Collapsed; // new!
        }
    }

Center the StackPanel

To center the StackPanel and pull the messages down towards the TextBox, adjust the Grid settings in MainWindow.xaml:

    <Grid VerticalAlignment="Bottom" HorizontalAlignment="Center">
        <!-- ... -->
    </Grid>

Wrap messages

To ensure that messages wrap to the next line when they reach the edge of the window, update MainWindow.xaml to use an ItemsControl.

Replace this:

    <ScrollViewer x:Name="ConversationScrollViewer" VerticalScrollBarVisibility="Auto" MaxHeight="500">
        <ListView x:Name="ConversationList" />
    </ScrollViewer>

With this:

    <ScrollViewer x:Name="ConversationScrollViewer" VerticalScrollBarVisibility="Auto" MaxHeight="500">
        <ItemsControl x:Name="ConversationList" Width="300">
            <ItemsControl.ItemTemplate>
                <DataTemplate>
                    <TextBlock Text="{Binding Text}" TextWrapping="Wrap" Margin="5" Foreground="{Binding Color}"/>
                </DataTemplate>
            </ItemsControl.ItemTemplate>
        </ItemsControl>
    </ScrollViewer>

We'll then introduce a MessageItem class to facilitate binding and coloring:

    // ...
    public class MessageItem
    {
        public string Text { get; set; }
        public SolidColorBrush Color { get; set; }
    }
    // ...

Finally, update the AddMessageToConversation method to use the new MessageItem class:

    // ...
    private void AddMessageToConversation(string message)
    {
        var messageItem = new MessageItem
        {
            Text = message,
            Color = message.StartsWith("User:") ? new SolidColorBrush(Colors.LightBlue)
                                                : new SolidColorBrush(Colors.LightGreen)
        };
        ConversationList.Items.Add(messageItem);

        // handle scrolling
        ConversationScrollViewer.UpdateLayout();
        ConversationScrollViewer.ChangeView(null, ConversationScrollViewer.ScrollableHeight, null);
    }
    // ...

Improve the TextBox

To make the TextBox larger and responsive to the Enter key, update MainWindow.xaml as follows:

    <!-- ... -->
    <StackPanel Orientation="Vertical" Width="300">
        <TextBox x:Name="InputTextBox" HorizontalAlignment="Stretch" VerticalAlignment="Stretch" KeyDown="InputTextBox_KeyDown" TextWrapping="Wrap" MinHeight="100" MaxWidth="300"/>
        <Button x:Name="SendButton" Content="Send" Click="SendButton_Click" HorizontalAlignment="Right"/>
    </StackPanel>
    <!-- ... -->

Then, add the InputTextBox_KeyDown event handler to handle the Enter key:

    //...
    private void InputTextBox_KeyDown(object sender, KeyRoutedEventArgs e)
    {
        if (e.Key == Windows.System.VirtualKey.Enter && !string.IsNullOrWhiteSpace(InputTextBox.Text))
        {
            SendButton_Click(this, new RoutedEventArgs());
        }
    }
    //...

Run the improved app

Your new-and-improved chat interface should look something like this:

A screenshot of a less minimal WinUI 3 chat app.

Recap

Here's what you accomplished in this how-to:

  1. You added OpenAI's API capabilities to your WinUI 3 / Windows App SDK desktop app by installing the official OpenAI library and initializing it with your API key.
  2. You built a chat-like interface that lets you generate responses to messages using OpenAI's chat completions API.
  3. You improved the chat interface by:
    1. adding a ScrollViewer,
    2. using a TextBlock to display the GPT response,
    3. adding a ProgressBar to indicate when the app is waiting for a response from the GPT API,
    4. centering the StackPanel in the window,
    5. ensuring that messages wrap to the next line when they reach the edge of the window, and
    6. making the TextBox larger, resizable, and responsive to the Enter key.

Full code files

The following code is a full example of the chat app with OpenAI chat completions integrated:

<?xml version="1.0" encoding="utf-8"?>
<Window
    x:Class="ChatGPT_WinUI3.MainWindow"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="using:ChatGPT_WinUI3"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    mc:Ignorable="d">
    <Grid VerticalAlignment="Bottom" HorizontalAlignment="Center">
        <StackPanel Orientation="Vertical" HorizontalAlignment="Center">
            <ScrollViewer x:Name="ConversationScrollViewer" VerticalScrollBarVisibility="Auto" MaxHeight="500">
                <ItemsControl x:Name="ConversationList" Width="300">
                    <ItemsControl.ItemTemplate>
                        <DataTemplate>
                            <TextBlock Text="{Binding Text}" TextWrapping="Wrap" Margin="5" Foreground="{Binding Color}"/>
                        </DataTemplate>
                    </ItemsControl.ItemTemplate>
                </ItemsControl>
            </ScrollViewer>
            <ProgressBar x:Name="ResponseProgressBar" Height="5" IsIndeterminate="True" Visibility="Collapsed"/>
            <StackPanel Orientation="Vertical" Width="300">
                <TextBox x:Name="InputTextBox" HorizontalAlignment="Stretch" VerticalAlignment="Stretch" KeyDown="InputTextBox_KeyDown" TextWrapping="Wrap" MinHeight="100" MaxWidth="300"/>
                <Button x:Name="SendButton" Content="Send" Click="SendButton_Click" HorizontalAlignment="Right"/>
            </StackPanel>
        </StackPanel>
    </Grid>
</Window>

using Microsoft.UI;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Input;
using Microsoft.UI.Xaml.Media;
using System;
using System.Collections.Generic;
using System.Linq;

using OpenAI;
using OpenAI.Chat;

namespace ChatGPT_WinUI3
{
    public class MessageItem
    {
        public string Text { get; set; }
        public SolidColorBrush Color { get; set; }
    }

    public sealed partial class MainWindow : Window
    {
        private OpenAIService openAiService;

        public MainWindow()
        {
            this.InitializeComponent();

            var openAiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY");

            openAiService = new(openAiKey);
        }

        private async void SendButton_Click(object sender, RoutedEventArgs e)
        {
            ResponseProgressBar.Visibility = Visibility.Visible;
            string userInput = InputTextBox.Text;
    
            try
            {
                if (!string.IsNullOrEmpty(userInput))
                {
                    AddMessageToConversation($"User: {userInput}");
                    InputTextBox.Text = string.Empty;
                    var chatClient = openAiService.GetChatClient("gpt-4o"); // or another model
                    var chatOptions = new ChatCompletionOptions
                    {
                        MaxOutputTokenCount = 300
                    };

                    // Assemble the chat prompt with a system message and the user's input
                    var completionResult = await chatClient.CompleteChatAsync(
                        [
                            ChatMessage.CreateSystemMessage("You are a helpful assistant."),
                            ChatMessage.CreateUserMessage(userInput)
                        ],
                        chatOptions);
    
                    if (completionResult != null && completionResult.Value.Content.Count > 0)
                    {
                        AddMessageToConversation($"GPT: {completionResult.Value.Content.First().Text}");
                    }
                    else
                    {
                        AddMessageToConversation($"GPT: Sorry, something bad happened: {completionResult?.Value.Refusal ?? "Unknown error."}");
                    }
                }
            }
            catch (Exception ex)
            {
                AddMessageToConversation($"GPT: Sorry, something bad happened: {ex.Message}");
            }
            finally
            {
                ResponseProgressBar.Visibility = Visibility.Collapsed;
            }
        }

        private void AddMessageToConversation(string message)
        {
            var messageItem = new MessageItem
            {
                Text = message,
                Color = message.StartsWith("User:") ? new SolidColorBrush(Colors.LightBlue)
                                                    : new SolidColorBrush(Colors.LightGreen)
            };
            ConversationList.Items.Add(messageItem);

            // handle scrolling
            ConversationScrollViewer.UpdateLayout();
            ConversationScrollViewer.ChangeView(null, ConversationScrollViewer.ScrollableHeight, null);
        }

        private void InputTextBox_KeyDown(object sender, KeyRoutedEventArgs e)
        {
            if (e.Key == Windows.System.VirtualKey.Enter && !string.IsNullOrWhiteSpace(InputTextBox.Text))
            {
                SendButton_Click(this, new RoutedEventArgs());
            }
        }
    }
}