Создание приложения ChatGPT в SwiftUI

В этой статье мы рассмотрим, как создать чат-бот в стиле 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, или настроить поведение своего бота с помощью системных сообщений, чтобы создать нечто более оригинальное.

Читайте также:

Читайте нас в TelegramVK и Дзен


Перевод статьи Matteo Porcu: Creating a ChatGPT app in SwiftUI

Предыдущая статьяКак работает JavaScript Proxy
Следующая статьяПереход на PgCat — прокси-сервер Postgres следующего поколения