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