ChatGPT clone with Flutter and Azure OpenAI

Alexandre Lipp
8 min readNov 2, 2023

Artificial intelligence and natural language processing are transforming how we interact with technology. Building intelligent, conversational chatbots has become an exciting endeavor for developers and AI enthusiasts. In this article, we’ll explore the development of a ChatGPT partial clone, powered by Azure OpenAI (with gpt-35-turbo model), and the Flutter framework. This article is a comprehensive tutorial on how to implement an interactive and responsive chat application.

Here is a screenshot of the chat that will be built:

Android phone screenshot of a chat conversation with a large language model. The conversation is about sorting a list in python.

Packages used

Only 2 packages(both official packages) will be required

  1. http (1.1.0): Used to make HTTP requests to communicate with the Azure OpenAI API.
  2. flutter_markdown (6.6.18): GPT 3.5 output is formatted with the markdown format. This package is used to render and display it correctly.

Application Widget

  • The root widget is a simple StatelessWidget with a dark theme.
class MyApp extends StatelessWidget {
const MyApp({super.key});

@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
colorScheme: const ColorScheme.dark(
primaryContainer: Color(0xFF005e49), // green
secondaryContainer: Color(0xFF222d35), // light gray
background: Color(0xFF08141c), // almost black
),
useMaterial3: true,
),
home: const MyHomePage(title: 'Azure OpenAI Chat Demo'),
);
}
}

Home Page

  • The HomePage is a standard Scaffold with an AppBar
  • The Scaffoldis wrapped in a GestureDetector to dismiss the keyboard when tapping on the chat
class MyHomePage extends StatelessWidget {
const MyHomePage({super.key, required this.title});
final String title;

@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => FocusManager.instance.primaryFocus?.unfocus(),
child: Scaffold(
resizeToAvoidBottomInset: true,
appBar: AppBar(
backgroundColor: const Color(0xFF212c34),
titleTextStyle: const TextStyle(color: Colors.white, fontSize: 24),
title: Text(title),
),
body: const Center(
child: Chat(),
),
),
);
}
}

Setting up Azure OpenAPI

To create an Azure Deployment, you can follow this official tutorial:

If you are a student, you might be able to create a free Azure OpenAPI deployment using your free credits. After creating a deployment, you can find the endpoint and the API key by pressing View Code in the Chat section of the Azure OpenAI studio.

You will find the endpoint and the key in the popup.

For the purposes of this simple demo, the API keys are stored as plain text in the api_keys.dart file. However, it's crucial to emphasize that this is solely for the convenience of creating a self-contained and illustrative example. In a real production environment, it's a fundamental security practice to never store API keys within client-side code, especially in publicly distributed applications.

To securely manage API keys and other sensitive information in production applications, you can consider using a service like Azure Key Vault or AWS Secrets Manager .

const AZURE_OPENAI_ENDPOINT = "Your_Azure_OpenAPI_Endpoint";
const AZURE_OPENAI_KEY = "Your_Azure_OpenAPI_Key";

Class to represent a chat message

The ChatMessage class represents a message within a chat conversation, and it has the following characteristics:

  • content: A string that stores the text content of the message.
  • messageType: An enum called MessageType that categorizes the message as either from the user or the assistant.
  • It defines a toJson method within the class. This method returns a map that represents the message in JSON format. By defining the toJson method, you enable automatic JSON encoding using jsonEncode or similar functions, making it straightforward to convert a ChatMessage object into a JSON string when you need to exchange data with external services or APIs
enum MessageType {
user,
assistant,
}

class ChatMessage {
String content;
MessageType messageType;

// will automatically be called by jsonEncode()
Map<String, String> toJson() {
return {'role': messageType.name, 'content': content};
}

ChatMessage({required this.content, required this.messageType});
}

Chat widget state

In the interest of simplicity, a single stateful widget is used to manage the chat application. Here’s a brief overview of the components of the widget state:

  • _textInputController: This controller is used to manage the input text field within the chat interface. It allows interaction with the text input and retrieves its content.
  • _scrollController: The ScrollController helps manage the scroll behavior of the chat messages. It can be used to scroll to the latest message when a new message is added for example.
  • _isLoading: This boolean variable is used to track whether the chat application is currently processing a request. It can be toggled to indicate a loading state, such as when waiting for a response from the AI model.
  • _chatMessages: This is a list of ChatMessage objects that represent the conversation's history. It starts with a greeting message from the assistant.
final _textInputController = TextEditingController();
final ScrollController _scrollController = ScrollController();
bool _isLoading = false;
final List<ChatMessage> _chatMessages = [
ChatMessage(content: 'Hello! How can I assist you today?', messageType: MessageType.assistant),
];

Fetching assistant response from Azure OpenAI

The next crucial step in our chat application is to fetch responses from the Azure OpenAI service. To do this, we’ll need to send a JSON payload containing the conversation context. This context JSON includes the entire history of the conversation, allowing the assistant to understand and respond coherently. Here’s an example of the context JSON of the presented chat screenshot:

{
"messages": [
{
"role": "assistant",
"content": "Hello! How can I assist you today?"
},
{
"role": "user",
"content": "how to print the size of a list in python"
},
{
"role": "assistant",
"content": "To print the size of a list in Python, you can use the built-in `len()` function. Here's an example:\n\n```python\nmy_list = [1, 2, 3, 4, 5]\nprint(len(my_list)) # Output: 5\n```\n\nIn this example, we have created a list called `my_list` which contains 5 elements. The `len()` function is then used to return the number of elements in the list, which is printed to the console using `print()`."
},
{
"role": "user",
"content": "how to sort a list in descending order?"
}
]
}

In this JSON structure:

  • The conversation history is maintained as an array of messages under the messageskey.
  • Each message has a role (either user or assistant) that indicates who sent the message.
  • The content field contains the actual text of the message.

It’s important to note that the conversation context is essential for the assistant to provide meaningful responses, as it allows the model to understand the ongoing dialogue and respond contextually. The request body will be composed of the JSON.

The _queryAssistantResponse method is responsible for interacting with the Azure OpenAI service to retrieve a response for the user's query. Here's a breakdown of the key actions within this method:

  1. Preparing and Sending the Request:
  • It begins by using the http.post method to send a POST request to the Azure OpenAI endpoint, specified as AZURE_OPENAI_ENDPOINT.
  • It sets the necessary headers, including the content type as JSON and the API key for authentication.
  • As mentioned before, the jsonEncode method will call the toJson method on every ChatMessage of _chatMessages , serializing the conversation properly
 void _queryAssistantResponse() async {
// prepare and send request
final response = await http.post(
Uri.parse(AZURE_OPENAI_ENDPOINT),
headers: <String, String>{
'Content-Type': 'application/json',
'api-key': AZURE_OPENAI_KEY,
},
body: jsonEncode({'messages': _chatMessages}),
);

// make sure response successful
if (response.statusCode != 200) {
print('Failed to fetch from azure OpenAI. Check your Azure OpenAI endpoint and key.');
return;
}

2. Parsing the Response:

  • If the response is successful, it proceeds to parse the response body using jsonDecode.
  • It attempts to extract the assistant’s message from the JSON structure received.
  • The response body will contain an array of possible choices as well as metadata.
....
"choices": [
{
"index": 0,
"finish_reason": "stop",
"message": {
"role": "assistant",
"content": "If you have any more questions or need further help, don't hesitate to ask!"
},
...
  • We will select the first choice and extract the message content.
// parse body to extract message
final body = jsonDecode(response.body);
String? message;
try {
message = body['choices'][0]['message']['content'];
} catch (e) {
print('Failed to parse response body with error ${e.toString()}');
return;
}

// validate message and add it to the message list
if (message == null || message.isEmpty) {
print('Failed to parse response body.');
return;
}

3. Updating the UI:

  • If the message is valid, it adds the assistant’s response as a new ChatMessage to the _chatMessages list with the message content and the message type set as MessageType.assistant.
  • It sets _isLoading to false to indicate that the request has been processed.
  • Finally, it calls _scrollToEnd to scroll to the latest message, ensuring that the user can see the most recent conversation.
// validate message and add it to the message list
if (message == null || message.isEmpty) {
print('Failed to parse response body.');
return;
}
setState(() {
_chatMessages.add(ChatMessage(content: message!, messageType: MessageType.assistant));
_isLoading = false;
});
_scrollToEnd();

Chat Interface

  • The chat interface is displayed using a ListView wrapped in an Expanded widget, which is crucial for ensuring the chat occupies all available vertical space within the app's layout.
  • The use of ListView.builder efficiently manages a potentially large chat history, dynamically loading and rendering items as needed, resulting in smoother and more memory-efficient scrolling in long conversations.
  • The message content itself is rendered using MarkdownBody to support Markdown formatting.
  • The message is rendered based on its type. The usermessages are right aligned and green; the assistant messages are left aligned and black.
Expanded(
child: ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(0),
itemCount: _chatMessages.length,
itemBuilder: (BuildContext context, int index) {
final isClientMessage = _chatMessages[index].messageType == MessageType.user;
return Container(
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 0),
child: Align(
alignment: isClientMessage ? Alignment.topRight : Alignment.topLeft,
child: Container(
decoration: BoxDecoration(
color: isClientMessage ? theme.colorScheme.primaryContainer :
theme.colorScheme.secondaryContainer,
borderRadius: const BorderRadius.all(Radius.circular(12)),
),
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
child: MarkdownBody(
styleSheet: MarkdownStyleSheet.fromTheme(
theme.copyWith(cardTheme: const CardTheme(color: Color(0xFF13181c))),
),
data: _chatMessages[index].content,
),
)),
);
}),
),

Text Input

The text input section of the chat interface is composed of a TextField for user input and a send button, with the appearance dynamically adjusted based on the _isLoading flag.

  1. The TextField widget provides a multiline text input area where users can type their messages.
 Container(
padding: const EdgeInsets.symmetric(vertical: 2.0, horizontal: 0),
decoration: BoxDecoration(
color: theme.colorScheme.secondaryContainer,
borderRadius: const BorderRadius.all(Radius.circular(12)),
),
child: TextField(
// scroll to end after keyboard is visible
onTap: () => Future.delayed(const Duration(milliseconds: 500), _scrollToEnd),
decoration: const InputDecoration(
contentPadding: EdgeInsets.all(10),
border: InputBorder.none,
hintText: 'Send a message',
),
controller: _textInputController,
keyboardType: TextInputType.multiline,
maxLines: 6,
minLines: 1,
),
),

2. Send Button / Loading Widget:

  • The send button allows users to send their queries to the assistant. When not in the loading state (when waiting for a response from Azure OpenAI), the button is an ElevatedButton labeled with an icon.
  • If the _isLoading flag is set, indicating that the application is waiting for a response, the send button is replaced by a loading widget, specifically a CircularProgressIndicator.
  • It is wrapped in a SizedBox to ensure that it maintains a consistent size regardless of whether it's in a loading state or not.
SizedBox(
// using a SizedBox as parent so that ElevatedButton and Loading are the same size
width: 45,
height: 45,
child: _isLoading
? Padding(
padding: const EdgeInsets.all(6.0),
child: CircularProgressIndicator(
color: theme.colorScheme.primaryContainer,
),
)
: ElevatedButton(
onPressed: _onSendPress,
style: ElevatedButton.styleFrom(
shape: const CircleBorder(),
padding: const EdgeInsets.all(0),
backgroundColor: theme.colorScheme.primaryContainer,
),
child: const Icon(
Icons.send,
color: Colors.white,
),
),
),

Further improvements

  • HTTP streaming to progressively stream the assistant’s response, allowing users to see the chat in real-time rather than waiting until the entire response is received.
  • Customize the model by adding context or parameters to the request as explained in the documentation.
  • Save the conversation history enabling users to access and refer back to previous discussions for future use.

--

--