In a recent tutorial, I took you through the basics of creating a chat app where you could ask a question and then receive an answer using the ChatGPT from OpenAI. That tutorial covered basic HTTP post requests and responses and included the structs that allowed a request to be encoded and a response to be decoded. It also had a simple view that allowed you to enter your API key from OpenAI and a TextField to ask a question.
In this tutorial, we will take this in the direction of a note-taking app for meetings with ChatGPT integration. The app will allow you to take notes and then have ChatGPT to do several things, such as extract action points, summarise lengthy notes, extract dates and times and put them into a bullet list. With AI, there are many options. I’ve only touched on a tiny amount of them here.
Rather than create a new app, this app will build off of the previous one, although the UI will be different, so I’ll leave that out of the starter project so we can add it. You can go ahead and download the starter project here.
Designing and Creating the View
The previous tutorials on ChatGPT just had a simple user interface with enough for you to get moving. We need something a little different for this app, and although the focus will be on something other than a beautifully designed app, we need more than just a TextField and a button. Instead, we’ll have a TextEditor that allows us to write more text than a single line. We’ll have a picker view that will allow you to select one of ten suggested prompts to ask ChatGPT to analyse your meeting notes, and then we’ll have the usual prompt TextField that can be used for customising a selected prompt or asking your questions.
@State private var apiKey = ""
@State private var cancellable: AnyCancellable? = nil
@State private var messages: [Message] = []
@State private var noteText = "My meeting notes..."
@State private var prompt = ""
@State private var resultText: String = "Result..."
@State private var selectedPrompt: MeetingPrompt = .noPrompt
Let’s look at the property wrappers first and get those in place.
We have the apiKey which is a place where you can paste in your OpenAI apiKey.
Cancellable is used for keeping a reference for the HTTP request we must make. Assuming the view is active, the HTTP request will stay active. We’ll look closer at this later on in this tutorial.
We next have our messages array. The chat completions API endpoint accepts an array of messages. The first message is typically when you ask your first question, and when you receive an answer, you add that answer as the second item in the array, and then your next question is the third item. The reason for this is that it gives ChatGPT context to your conversation.
noteText is where the notes you write will be stored.
prompt is for when you ask a question. The idea with this app is to make notes, perhaps for a meeting, and then enter a prompt such as “summarise the notes I have made into a bulleted list of a few words for each item”.
result is where we store the response from ChatGPT, and we also use it to display it on the view.
selectedPrompt is a dropdown list of suggested items for if you are taking notes in a meeting. It provides some pre-determined prompts, such as a prompt for listing all people present in the meeting.
For selectedPrompt, prompt, and noteText, these are put together in a single string and sent to the completions endpoint. If selectedPrompt or prompt is empty, then it just adds nothing in place of them.
var body: some View {
NavigationView {
VStack {
TextField("API Key", text: $apiKey)
.textFieldStyle(.roundedBorder)
.padding()
TextEditor(text: $noteText)
.padding(.all, 10.0)
TextEditor(text: $resultText)
.padding(.all, 10.0)
Picker("Select Prompt", selection: $selectedPrompt) {
ForEach(MeetingPrompt.allCases) { prompt in
Text(prompt.rawValue)
.tag(prompt)
}
}
.pickerStyle(MenuPickerStyle())
.padding(.bottom, 30)
HStack {
TextField("Custom Prompt...", text: $prompt)
.padding(.all, 10.0)
Button(action: {
submit()
}) {
Image(systemName: "paperplane")
.font(.title)
}
.padding()
}
}
.padding()
.navigationTitle("My Notes")
}
}
This view differs from our previous chat bot implementation a couple of tutorials ago.
All of it is nested in a NavigationView so that a title can be added to the top, although in this tutorial, we won’t be navigating anywhere. All requests and results will be shown on this main view.
We next have a TextField for you to enter your API key from OpenAI. This wouldn’t necessarily be common practice, but it’s suitable for a demo. Just make sure you don’t share your API key with anybody. This, and the remaining items, are stacked in a VStack.
Next, we have two TextEditor views. The first is where you write your notes, and the second is where the result will show after sending an API request to OpenAI. The first one has a two-way binding to noteText, and the second also has a two-way binding, in this case, to resultText.
I added a Picker next that stores the selected option in selectedPrompt. This is an enum, similar to how the image bot used to select an image size. In this case, we’re using it for ten suggested prompts, for which you might use ChatGPT to analyse your notes. The picker prompt defaults to an empty string, as you might want to use the prompt TextField detailed below.
Below this, we have an HStack to horizontally align a prompt and submit button. The prompt will be used to either augment one of the preselected prompts from above or if none of those are selected, it will just use this prompt provided.
The button calls the submit() function.
The Picker uses this enum for its data. Feel free to adjust and remove or add more as needed:
enum MeetingPrompt: String, CaseIterable, Identifiable {
case noPrompt = ""
case meetingDateTime = "Please state the date and time of the meeting for the record."
case attendees = "Kindly provide a list of all attendees present in the meeting."
case agenda = "Could you outline the agenda topics discussed during the meeting?"
case actionItems = "What are the action items identified during the meeting and who is responsible for each?"
case deadlines = "Can you specify the deadlines for each action item?"
case decisionsMade = "Please highlight any significant decisions or resolutions made during the meeting."
case nextSteps = "What are the next steps or follow-up actions to be taken after the meeting?"
case meetingObjectives = "What were the main objectives or goals of this meeting?"
case challengesAndConcerns = "Were there any challenges or concerns raised during the meeting?"
case upcomingMeetings = "Are there any upcoming meetings scheduled to continue the discussion on specific topics?"
var id: String { self.rawValue }
}
Encoding and Decoding JSON
The decodable/encodable structs needed to decode and code the JSON result and request are identical to the chatbot tutorial. We’re sending and receiving the exact shape of the data, but I’ll post them here as a reminder.
struct APIResponse: Decodable {
let id: String?
let object: String
let created: Int
let model: String
let choices: [Choice]
let usage: Usage
}
struct Usage: Decodable {
let prompt_tokens: Int
let completion_tokens: Int
let total_tokens: Int
}
struct APIRequest: Encodable {
let model: Model
let messages: [Message]
}
struct Choice: Decodable {
let index: Int
let message: Message
let finish_reason: String
}
struct Message: Codable {
let role: Role
let content: String
}
enum Role: String, Codable {
case system
case user
case assistant
case function
}
enum Model: String, Codable {
case gpt_3_5_turbo = "gpt-3.5-turbo"
}
These follow the exact shape of data described in the OpenAI documentation so that when a request is sent as an APIRequest, we can ensure we are sending what is needed. Likewise, an APIResponse comes back. Our struct matches that so that the JSON response can be decoded.
Making the API Request to OpenAI
The final piece to making this work is to add the submit function. This is almost identical to the chatbot tutorial, so I’ll only briefly explain what’s happening here.
func submit() {
guard let url = URL(string: "https://api.openai.com/v1/chat/completions") else {
print("Invalid URL")
return
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
messages.append(Message(role: .user, content: "\(selectedPrompt) \(prompt) \(noteText)"))
do {
let payload = APIRequest(model: .gpt_3_5_turbo, messages: messages)
let jsonData = try JSONEncoder().encode(payload)
request.httpBody = jsonData
} catch {
print("Error: \(error)")
return
}
cancellable = URLSession.shared.dataTaskPublisher(for: request)
.tryMap { $0.data }
.decode(type: APIResponse.self, decoder: JSONDecoder())
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { completion in
switch completion {
case .failure(let error):
resultText = "Error: \(error.localizedDescription)"
case .finished:
break
}
},
receiveValue: { response in
resultText = response.choices.first?.message.content ?? "No response"
messages.append(Message(role: .system, content: resultText))
prompt = ""
}
)
}
We first create the URL, which is the endpoint at OpenAI. It is guarded in case a problem occurs, although a fixed URL shouldn’t give a problem under normal circumstances. We do have a place to handle it gracefully where the Invalid URL is printed.
We then create the URLRequest, which takes in the URL we just created. We set the httpMethod to POST and add the bearer with the API Key as provided in the TextField for the API key.
The API requires that we send an array of messages to this endpoint and its model, so here, we append a new message specifying the role and content. This adds in any selectedPrompt, the prompt we manually entered, as well as the noteText. This has the message part ready to be added to the APIRequest. This new message is being created and added to any previous messages. This would typically contain messages from a current thread if used in production.
We then create the payload for the APIRequest with the model, in this case, the .gpt_3_5_turbo model from the enum and the messages we just created.
We then encode this into JSON data and set the request body to this JSON data.
This is everything ready for us to make an API call.
Next we kick off the URLSession with the request:
- .tryMap gets the first item in the returned tuple and maps it.
- .decode attempts to decode the response into an APIResponse. If the shape matches, then it will be successful.
- .receive specifies that we want this dispatched on the main thread (because it is related to the view)
- .sink is where we receive the value and also where the completion happens. This will either fail or complete; if all is good, we will receive value in this process. We can get this response and store it in resultText, at which point the view will refresh and show the response on the screen. If anything fails within the request, the message will be stored in resultText which will also update the view and display the error in place of where you would expect the answer to appear.
The code also appends the result from the response to the array of messages to maintain context.
It then wipes out the prompt so you can ask your next question.
The code is relatively simple. My advice here is to handle errors better. This isn’t a tutorial about error handling, so I’ve just added enough for it not to crash, but if you were to move this to production, then you would need to handle what would happen if something went wrong.
Project
The finished code as well as the starter code is available over here. If you have any questions then please ask below.
Leave a Reply
You must be logged in to post a comment.