В этой статье мы увидим, как создать приложение чат-бота в стиле ChatGPT, используя API чата OpenAI и SwiftUI.
API
Прежде всего, давайте начнем с того, как работает API чата.
API позволяет разработчикам получать доступ к языковым моделям, используемым для ChatGPT, для создания чат-ботов, виртуальных помощников или даже функций, связанных с генерацией текста в целом. Чтобы сделать запрос к API, нам нужно вставить в качестве параметров модель чата, которую мы хотим использовать (по состоянию на июнь 2023 года единственной доступной для всех моделью является gpt-3.5-turbo
, а для использования gpt-4
вам необходимо предоставить доступ OpenAI) и беседа, которая по сути представляет собой список сообщений. API ответит следующим сообщением в диалоге, сгенерированным моделью чата.
Также есть некоторые необязательные параметры, которые мы можем вставить в запрос, чтобы повлиять на вывод; мы рассмотрим некоторые из них в ближайшее время.
Этот API, как и другие API OpenAI, не является бесплатным; с вас будет взиматься плата за каждый запрос, исходя из количества токенов как во входном чате, так и в сгенерированном сообщении.
Но подождите, что такое токен?
Токен — это единица, используемая языковыми моделями для разбивки текста; у него нет фиксированной длины, он может быть даже короче одного символа. Количество токенов в тексте может варьироваться в зависимости от модели, используемых слов, структуры каждой фразы и даже от языка. Для языковых моделей OpenAI токен эквивалентен примерно 4 символам английского текста.
Также существует ограничение на общее количество токенов, разрешенных в каждом запросе, включая ввод и вывод. Для gpt-3.5-turbo
установлен лимит в 4096 токенов.
Кроме того, API накладывает ограничения на количество разрешенных запросов и токенов в минуту.
Запрос
Чтобы сделать запрос, вам необходимо включить свой ключ API в качестве токена носителя в заголовок авторизации. Вы можете сгенерировать ключ API здесь.
Давайте посмотрим на пример тела запроса только с необходимыми параметрами:
{ "model": "gpt-3.5-turbo", "messages": [{"role": "user", "content": "Hello!"}] }
Как упоминалось ранее, обязательными параметрами являются:
model
, идентификатор модели чата, которую мы собираемся использоватьmessages
, список сообщений, представляющих разговор до этого момента; поскольку модель чата не сохраняет состояние, нам нужно будет отправлять разговор в каждом запросе.
Каждый объект сообщения должен иметь эти два поля:
role
Роль автора сообщения. Поле может иметь одно из трех возможных значений:
user
,assistant
илиsystem
.user
иassistant
определяют сообщения, автором которых является пользователь или модель чата соответственно, тогда как сообщенияsystem
могут быть вставлены в беседу (обычно в начале), чтобы проинструктировать модель о том, как себя вести, но они не рассматриваются как фактические сообщения разговора по модели.content
Содержание сообщения.
Объект сообщения также может иметь поле name
; в случае, если мы хотим вставить пример разговора в виде серии system
сообщений, это поле можно использовать для различения примеров сообщений помощника и примеров пользовательских сообщений (как мы можем видеть здесь, в параграфе Подсказка о нескольких выстрелах). В противном случае поле можно опустить.
В дополнение к model
и messages
запрос также может содержать некоторые необязательные параметры. Пройдемся по некоторым из них:
n
— количество сообщений, которые необходимо создать для каждого запроса; значение по умолчанию равно 1, поэтому, если этот параметр не назначен, генерируется один ответmax_tokens
— это максимальное количество токенов для генерации ответа.
Ответ
Рассмотрим пример ответа:
{ "id": "chatcmpl-123", "object": "chat.completion", "created": 1677652288, "choices": [{ "index": 0, "message": { "role": "assistant", "content": "\n\nHello there, how may I assist you today?", }, "finish_reason": "stop" }], "usage": { "prompt_tokens": 9, "completion_tokens": 12, "total_tokens": 21 } }
Сгенерированное сообщение находится в поле choices
, которое представляет собой массив, содержащий один или несколько потенциальных ответов. Количество сообщений в массиве определяется значением, присвоенным параметру n
в запросе; поскольку, как было сказано ранее, значение по умолчанию для этого параметра равно 1, если мы не укажем значение для параметра n
, массив choices
будет содержать только одно сообщение.
Давайте кодировать!
Начнем с создания модели ответа:
struct ChatResponse: Codable { let id: String? let object: String? let created: Int? let choices: [Choice]? let usage: Usage? } struct Choice: Codable { let index: Int? let message: Message? let finish_reason: String? } struct Message: Codable, Equatable { let role: String? let content: String? } struct Usage: Codable { let prompt_tokens: Int? let completion_tokens: Int? let total_tokens: Int? }
Затем мы создаем класс, который управляет связью с API. В своей реализации я использую Alamofire для обработки запросов, но не стесняйтесь выбирать предпочитаемую сетевую библиотеку или подход.
import Foundation import Alamofire class Network { typealias FetchNextMessageHandler = (DataResponse<ChatResponse, AFError>) -> Void @discardableResult static func fetchNextMessage(messages: [Message], handler: @escaping FetchNextMessageHandler) -> DataRequest { guard let apiKey = Bundle.main.infoDictionary?["API_KEY"] as? String else { fatalError("API key missing") } let url = URL(string: "https://api.openai.com/v1/chat/completions")! var parameters = Parameters() parameters["model"] = "gpt-3.5-turbo" parameters["messages"] = messages.map { message in ["role": message.role ?? "", "content": message.content ?? ""] } let headers = HTTPHeaders([ .authorization("Bearer \(apiKey)"), .contentType("application/json") ]) return AF .request( url, method: .post, parameters: parameters, encoding: JSONEncoding.default, headers: headers ) .validate() .responseDecodable(of: ChatResponse.self, completionHandler: handler) } }
ВНИМАНИЕ! В проекте ключ API извлекается из файла .xcconfig. На GitHub вместо этого используется строка-заполнитель. Это позволяет легко вставить туда свой ключ API и протестировать приложение.
Однако важно отметить, что хранить ключ API в приложении не рекомендуется для общедоступных выпусков. Всегда существует вероятность того, что злоумышленник может получить доступ к ключу, если он хранится в приложении. Даже его запутывание или получение во время выполнения через Интернет не является полностью безопасным.
Для получения дополнительной информации по этой теме я рекомендую прочитать статью Управление секретами в iOS на NSHipster и послушать доклад Ивана Родригеса Использование общих уязвимостей приложений iOS. .
OpenAI настоятельно рекомендует не хранить ключ на стороне клиента. Вместо этого рекомендуется хранить его на управляемом вами сервере и устанавливать связь с API OpenAI исключительно через ваш сервер (см. эту ссылку).
Такой подход является самый безопасный. Если это невозможно для вас, рассмотрите один из менее безопасных вариантов, но помните о связанных с этим рисках.
Теперь, когда мы создали сетевой менеджер, нам нужно создать экран чата. Здесь пользователь сможет читать беседу и отправлять новые сообщения.
Мы начинаем с создания представления, используемого для отображения одного сообщения на экране, и структуры, используемой для его заполнения.
import SwiftUI struct MessageRow: View { // MARK: - Properties // The data to populate the MessageRow var args: MessageRowItem // MARK: - Body var body: some View { HStack { if args.role == .user { Spacer(minLength: 50) } Text(args.message) .padding() .background(args.color) .cornerRadius(16) if args.role == .assistant { Spacer(minLength: 50) } } .padding() .listRowSeparator(.hidden) .listRowInsets(EdgeInsets()) } } import SwiftUI struct MessageRowItem: Hashable { let role: Role let message: String var color: Color { switch role { case .user: return Color.userMessageBackground case .assistant: return Color.assistantMessageBackground } } } enum Role: String { case user case assistant }
Поскольку мы не будем отображать системные сообщения в пользовательском интерфейсе, мы определяем только случаи user
и assistant
для перечисления Role
.
Следующим шагом будет создание экрана чата (MainView
) и его ViewModel.
import SwiftUI struct MainView: View { // MARK: - ViewModel @ObservedObject var viewModel = MainViewModel() // MARK: - Body var body: some View { VStack { List(viewModel.items, id: \.self) { item in MessageRow(args: item) } .listStyle(.plain) Spacer() switch viewModel.state { case .ready: // If the user can send a new message, show the text field and the "Send" button HStack { TextField("", text: $viewModel.currentMessage) .textFieldStyle(.roundedBorder) Spacer() Button { viewModel.sendMessage() } label: { Text("Send") .foregroundColor(.black) .padding() .background(Color.buttonBackground) .cornerRadius(16) } } .padding() case .loading: // If the app is waiting for a response from the API, show a ProgressView ProgressView() .progressViewStyle(CircularProgressViewStyle()) .padding() case .error: // If the last request failed, show the "Resend" button Button { viewModel.sendMessage(isResend: true) } label: { Text("Resend") .frame(maxWidth: .infinity) .foregroundColor(.black) .padding() .background(Color.buttonBackground) .cornerRadius(16) } .padding() } } // Update the items array when the view appears (this will trigger the List to update to show the the current messages) .onAppear { viewModel.updateItems() } // Update the items array when a message is added to the messages array (this will trigger the List to update to show the the current messages) .onChange(of: viewModel.messages) { _ in viewModel.updateItems() } } }
Чтобы отобразить диалог, мы будем использовать список, который будет заполнен массивом в ViewModel. Этот массив будет обновляться при появлении MainView и всякий раз, когда в беседу добавляется новое сообщение.
Мы также управляем способностью пользователя отправлять новые сообщения в зависимости от состояния:
- обычно пользователь сможет написать и отправить новое сообщение
- пока приложение ожидает ответа от API, пользователь не сможет отправлять новые сообщения
- если последний запрос не удался, пользователь сможет повторно отправить предыдущее сообщение.
import Foundation enum MainViewModelState { case ready case loading case error } class MainViewModel: ObservableObject { // MARK: - Properties @Published var state: MainViewModelState = .ready // Array containing the data used to populate the View; each element contains the data for one message (the message text and if the message was written by the user or the assistant) @Published var items: [MessageRowItem] = [] // This array contains the entire conversation; each time the user writes a new message this conversation will be sent to the API @Published var messages: [Message] = [ // Inserting a system message at the beginning of the conversation, to influence the kind of responses the language model will generate Message(role: "system", content: "Talk in rhyme") ] // Content of the message text field @Published var currentMessage: String = "" // MARK: - Public methods // The method to send the conversation to the API; this is called both when the user sends a new message, and when when an error occured and the message needs to be resent func sendMessage(isResend: Bool = false) { // Add the new user message if needed if !isResend { guard !currentMessage.isEmpty else { return } // Return if the message text field is empty messages.append( Message(role: "user", content: currentMessage) ) } currentMessage = "" state = .loading // Send the conversation Network.fetchNextMessage(messages: messages) { response in switch response.result { case .success(let chatResponse): self.state = .ready if let message = chatResponse.choices?.first?.message { // Add the newly received message self.messages.append(message) } case .failure(let error): self.state = .error print(error) } } } // Map the array of messages to an array of MessageRowItems func updateItems() { var items = messages.compactMap { item -> MessageRowItem? in // Since for the Role enum we only defined the user and assistant cases, system messages will not be included in the array (and so they will not be shown to the user) guard let role = Role(rawValue: item.role ?? "") else { return nil } return MessageRowItem( role: role, message: item.content ?? "" ) } items.insert( // Inserting a "faked" message; this will be shown to the user as an initial message from the chatbot MessageRowItem( role: .assistant, message: """ In rhymes that dance and verses anew, I'm ready to converse and rhyme with you. """ ), at: 0 ) self.items = items } }
ViewModel управляет данными, которые нам нужны для представления:
state
, в котором мы находимся (может ли пользователь отправить новое сообщение? Мы ждем ответа от API? Последний запрос не удался?)- массив
messages
, содержащий диалог, который мы фактически отправляем в API; его первый элемент — системное сообщение, чтобы бот отвечал в рифму - массив
items
, содержащий данные для сообщений, которые будут отображаться в пользовательском интерфейсе currentMessage
, содержащееся в текстовом поле.
Метод sendMessage
использует менеджер, который мы создали ранее, для отправки диалога в API. Если есть новое сообщение для отправки, оно добавляется в конец массива messages
перед его отправкой. Когда получен успешный ответ, новое сообщение также добавляется в конец массива.
Метод updateItems
обновляет массив items
, в результате чего пользовательский интерфейс обновляется для отображения новых сообщений.
Мы также вставляем сообщение в начале, которое будет показано пользователю как начальное сообщение от бота, чтобы не показывать пустой экран, но в то же время не ждать ответа от бота. API в начале.
Если бы мы хотели, мы могли бы добавить сообщение в массив messages
и также отправить его в API, но мы можем сэкономить несколько токенов, показывая его только в пользовательском интерфейсе.
Заключение
Мы рассмотрели, как работает API чата OpenAI и как использовать его для создания приложения чат-бота в SwiftUI.
Вы можете использовать то, что вы узнали из этой статьи, для создания приложения, которое работает так же, как ChatGPT, или настроить поведение вашего «бота», использующего системные сообщения для создания чего-то более конкретного.
Спасибо Davide, Lavinia и Agostino за консультации при написании статьи, а также Franceska за создание избранного изображения.
Хотите посмотреть весь проект?
Полный код проекта вы можете посмотреть здесь.