В этой статье мы рассмотрим, как создать чат-бот в стиле 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-сообщений, это поле может быть использовано для различения между образцами assistant-сообщений и user-сообщений (как показано здесь в разделе Few-shot prompting). В противном случае это поле можно опустить.
Помимо 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-ключа внутри приложения не рекомендуется. Всегда есть риск, что злоумышленник получит доступ к ключу, хранящемуся внутри приложения. Даже обфускация или получение ключа во время выполнения через интернет не дают гарантии полной безопасности.
OpenAI настоятельно рекомендует хранить ключ не на стороне клиента, а на управляемом вами сервере и устанавливать связь с API OpenAI исключительно через ваш сервер (см. рекомендации по этой ссылке).
Такой подход является наиболее безопасным. Если для вас это невыполнимо, рассмотрите один из менее безопасных вариантов, но помните о сопутствующих рисках.
Теперь, когда мы создали сетевого менеджера, займемся экраном чата. Здесь пользователь сможет читать диалог и отправлять свои сообщения.
Начнем с создания представления, используемого для отображения одного сообщения на экране, и структуры для его заполнения.
import SwiftUI
struct MessageRow: View {
// MARK: - Свойства
// Данные для наполнения MessageRow
var args: MessageRowItem
// MARK: - Тело
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
}
Поскольку system-сообщения не будут отображаться в пользовательском интерфейсе, мы определяем только случаи 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:
// Если пользователь может отправить новое сообщение, показать текстовое поле и кнопку "Отправить".
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:
// Если приложение ожидает ответа от API, показать ProgressView
ProgressView()
.progressViewStyle(CircularProgressViewStyle())
.padding()
case .error:
// Если последний запрос не прошел, показать кнопку "Resend"
Button {
viewModel.sendMessage(isResend: true)
} label: {
Text("Resend")
.frame(maxWidth: .infinity)
.foregroundColor(.black)
.padding()
.background(Color.buttonBackground)
.cornerRadius(16)
}
.padding()
}
}
// Обновление массива элементов при появлении представления (это вызовет обновление List для показа текущих сообщений)
.onAppear {
viewModel.updateItems()
}
// Обновление массива элементов при добавлении сообщения в массив сообщений (это вызовет обновление List для отображения текущих сообщений)
.onChange(of: viewModel.messages) { _ in
viewModel.updateItems()
}
}
}
Для отображения диалога используем List, который будет заполнен массивом в модели ViewModel. Этот массив будет обновляться при появлении MainView и при добавлении в диалог нового сообщения.
Вы будете управлять возможностью пользователя отправлять новые сообщения в зависимости от состояния:
- в обычном режиме пользователь может написать и отправить новое сообщение;
- пользователь не сможет отправлять новые сообщения, пока приложение ожидает ответа от API;
- пользователь сможет повторно отправить предыдущее сообщение, если последний запрос не прошел.
import Foundation
enum MainViewModelState {
case ready
case loading
case error
}
class MainViewModel: ObservableObject {
// MARK: - Свойства
@Published var state: MainViewModelState = .ready
// Массив, содержащий данные, используемые для заполнения представления; каждый элемент содержит данные для одного сообщения (текст сообщения и то, было ли сообщение написано пользователем или помощником)
@Published var items: [MessageRowItem] = []
// Этот массив содержит весь разговор; каждый раз, когда пользователь пишет новое сообщение, этот разговор будет отправляться в API
@Published var messages: [Message] = [
// Вставка system-сообщения в начале разговора, чтобы повлиять на то, какие ответы будет генерировать языковая модель
// Содержание текстового поля сообщения
@Published var currentMessage: String = ""
// MARK: - Публичные методы
// Метод отправки сообщения в API; этот метод вызывается как при отправке пользователем нового сообщения, так и при возникновении ошибки и необходимости повторной отправки сообщения.
// При необходимости добавление нового сообщения пользователя
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
// Отправка разговора
Network.fetchNextMessage(messages: messages) { response in
switch response.result {
case .success(let chatResponse):
self.state = .ready
if let message = chatResponse.choices?.first?.message {
// Добавить только что полученное сообщение
self.messages.append(message)
}
case .failure(let error):
self.state = .error
print(error)
}
}
}
// Сопоставить массив сообщений с массивом MessageRowItems
func updateItems() {
var items = messages.compactMap { item -> MessageRowItem? in
// Поскольку для перечисления Role мы определили только случаи пользователя и помощника, system-сообщения не будут включены в массив (и поэтому они не будут показаны пользователю).
guard let role = Role(rawValue: item.role ?? "") else { return nil }
return MessageRowItem(
role: role,
message: item.content ?? ""
)
}
items.insert(
// Вставка "фейкового" сообщения; оно будет показано пользователю в качестве начального сообщения чат-бота.
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 управляет данными, необходимыми для View.
stateв текущий момент. Может ли пользователь отправить новое сообщение? Ожидается ли ответ от API? Был ли успешным последний запрос?messages-массив с содержанием диалога, который фактически отправляется в API. Его первый элемент —system-сообщение — заставляет бота отвечать в рифму.item-массив, содержащий данные для сообщений, которые будут показаны в пользовательском интерфейсе.currentMessage— текущее сообщение, содержащееся в текстовом поле.
Метод sendMessage использует созданный ранее менеджер для отправки диалога в API. Если есть новое сообщение для отправки, оно добавляется в конец messages-массива перед отправкой. При получении успешного ответа новое сообщение также добавляется в конец массива.
Метод updateItems обновляет items-массив, заставляя пользовательский интерфейс обновляться, чтобы показать новые сообщения.
Сообщение, которое будет показано пользователю как начальное сообщение от бота, вставляется в начало диалога, чтобы избежать показа пустого экрана и необходимости ждать ответа от API в самом начале.
Можно было бы добавить сообщение в messages-массив и также отправить его в API, но лучше сэкономить несколько токенов, показав его только в пользовательском интерфейсе.
Заключение
Вы узнали, как работает API чата OpenAI и как использовать его для создания чат-бота в SwiftUI. Эти знания можно применить, чтобы создать приложение, которое работает так же, как ChatGPT, или настроить поведение своего бота с помощью системных сообщений, чтобы создать нечто более оригинальное.
Читайте также:
- Как перевести код R в Python с помощью ChatGPT
- Загрузочные представления в SwiftUI
- Как освоить API-интерфейсы Metal с UIView и SwiftUI
Читайте нас в Telegram, VK и Дзен
Перевод статьи Matteo Porcu: Creating a ChatGPT app in SwiftUI





