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

Хотите посмотреть весь проект?

Полный код проекта вы можете посмотреть здесь.