ChatGPT clone with Flutter and Azure OpenAI
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:
Packages used
Only 2 packages(both official packages) will be required
- http (1.1.0): Used to make HTTP requests to communicate with the Azure OpenAI API.
- 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 anAppBar
- The
Scaffold
is wrapped in aGestureDetector
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 calledMessageType
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 thetoJson
method, you enable automatic JSON encoding usingjsonEncode
or similar functions, making it straightforward to convert aChatMessage
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
: TheScrollController
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 ofChatMessage
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
messages
key. - Each message has a
role
(either
user orassistant
) 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:
- Preparing and Sending the Request:
- It begins by using the
http.post
method to send a POST request to the Azure OpenAI endpoint, specified asAZURE_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 thetoJson
method on everyChatMessage
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 asMessageType.assistant
. - It sets
_isLoading
tofalse
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 anExpanded
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
user
messages are right aligned and green; theassistant
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.
- 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 aCircularProgressIndicator
. - 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.
Thank you for reading! You can find the full source code on Github: