Aprende a crear una app para comunicarte con ChatGPT-4 en SwiftUI
Aprende a crear una app para comunicarte con ChatGPT-4 en SwiftUI

SwiftUI y SwiftOpenAI: Creando una App con ChatGPT

Descubre cómo crear un chat en SwiftUI con ChatGPT-4 en 30 min. Sigue nuestra guía para desarrollar una interfaz de IA interactiva.

SwiftBeta

Tabla de contenido


👇 SÍGUEME PARA APRENDER SWIFTUI, SWIFT, XCODE, etc 👇
Aprende a crear un Chat en SwiftUI que se comunique con ChatGPT-4
Aprende a crear un Chat en SwiftUI que se comunique con ChatGPT-4

Hoy en SwiftBeta vamos a crear una aplicación que use ChatGPT y SwiftUI, para ser más exactos, vamos a crear un Chat para podernos comunicar con ChatGPT. Podremos escribir la pregunta que queramos y ChatGPT nos contestará e iremos viendo en tiempo real el resultado. Va a ser un video muy interesante ya que vamos a integrar un SDK que he ido creando en las últimas semanas. Este SDK se llama SwiftOpenAI, y puedes ver todo el código en este repositorio de Github.

De un simple vistazo puedes ver cómo lo he estructurado, sus tests, documentación, etc en su README encontrarás todas la información actualizada de lo que puedes construir. Y justo en este video vamos a crear la aplicación que aparece en el gif.
Va a ser un video muy interesante y práctico donde vamos a explorar varios temas que hemos visto en el canal. SwiftUI, Swift Package Manager, Arquitectura MVVM, etc. Vamos a empezar.

Creamos proyecto en Xcode

Lo primero de todo que vamos hacer es crear un nuevo proyecto en Xcode. Le asignamos un nombre y en Interface muy importante seleccionamos SwiftUI. En mi caso voy a llamar al proyecto JARVIS, en honor a JARVIS de las películas de MARVEL.

Una vez tenemos el proyecto creado, volvemos a nuestro repositorio y aquí copiamos la URL. Puedes seguir los pasos de instalación que hay en el README del proyecto, y es justo lo que vamos hacer. Copiamos la URL del repositorio y volvemos a Xcode.

Importamos SwiftOpenAI con Swift Package Manager

Y si quieres ver cómo está creado el SDK y tips para crear un proyecto open source, ponlo en los comentarios 👍, ya que es un tema muy muy interesante con muchos puntos a tener en cuenta.

Y antes de continuar, si quieres apoyar el contenido que subo cada semana, suscríbete. De esta manera seguiré publicando contenido completamente gratuito en Youtube.

Perfecto, vamos a continuar. Al añadir SwiftOpenAI al proyecto de Xcode, podemos ver todo el código en la parte izquierda de Xcode. Ahora tenemos acceso a todas las clases públicas del SDK.

Creamos el ViewModel

Vamos a crear un ViewModel para poder interactuar con nuestro SDK, pulsamos COMMAND+N, vamos a llamar a este nuevo fichero ViewModel. Aquí dentro importamos SwiftOpenAI.

Lo siguiente que vamos hacer es seguir los pasos del README, en este caso debemos inicializar el SDK con un apiKey, de momento copiamos la línea y la añadimos a nuestro ViewModel:

import SwiftOpenAI

final class ViewModel {
    var openAI = SwiftOpenAI(apiKey: "YOUR-API-KEY")
}

Y a continuación, escogemos uno de los casos de uso que hay en el SDK, están todos listados en el README, vamos a ver algunos de ellos.

En mi caso me voy a quedar con el de chatCompletions with Streams. Copio el código de ejemplo y lo pego dentro del ViewModel, y ahora explicaré línea por línea:

import SwiftOpenAI

final class ViewModel {
    var openAI = SwiftOpenAI(apiKey: "YOUR-API-KEY")

    let messages: [MessageChatGPT] = [
      MessageChatGPT(text: "You are a helpful assistant.", role: .system),
      MessageChatGPT(text: "Who won the world series in 2020?", role: .user)
    ]
    let optionalParameters = ChatCompletionsOptionalParameters(temperature: 0.7, stream: true, maxTokens: 50)

    do {
        let stream = try await openAI.createChatCompletionsStream(model: .gpt4(.base), messages: messages, optionalParameters: optionalParameters)
        
        for try await response in stream {
            print(response)
        }
    } catch {
        print("Error: \(error)")
    }
}

Como ves, hay un error, vamos a añadir el código que acabamos de pegar dentro de un método. De esta manera este método lo podremos llamar desde la vista en SwiftUI que crearemos en los próximos minutos.

import SwiftOpenAI

final class ViewModel {
    var openAI = SwiftOpenAI(apiKey: "YOUR-API-KEY")
    
    func send() async {
        let messages: [MessageChatGPT] = [
            MessageChatGPT(text: "You are a helpful assistant.", role: .system),
            MessageChatGPT(text: "Who won the world series in 2020?", role: .user)
        ] // 1
        let optionalParameters = ChatCompletionsOptionalParameters(temperature: 0.7,
                                                                   stream: true,
                                                                   maxTokens: 50) // 2
        
        do {
            let stream = try await openAI.createChatCompletionsStream(model: .gpt4(.base),
                                                                      messages: messages,
                                                                      optionalParameters: optionalParameters) // 3
            
            for try await response in stream { // 4
                print(response)
            }
        } catch {
            print("Error: \(error)") // 5
        }
    }
}

Vamos a ver qué hace este método:

  1. Creamos un Array de mensajes, el primer mensaje es de role system para indicar a ChatGPT como queremos que se comporte chatGPT, es decir actúa como una directiva que puede afectar el enfoque de las respuestas que hagamos a ChatGPT. Y el segundo mensaje es un mensaje que preguntamos nosotros a ChatGPT. En nuestro caso vamos a modificar la pregunta a Cuando se lanzó el primer iPhone?
  2. El siguiente punto es muy interesante, son los parámetros que queremos customizar para crear nuestra petición HTTP. En realidad en este caso nosotros no hacemos la petición HTTP, sino que hay una clase dentro del SDK que se encargará de hacerlo y de retornarnos los datos sin nosotros preocuparnos por esta lógica de negocio.
  3. La API de ChatGPT permite obtener el resultado de las preguntas que le lanzamos de golpe, es decir haces una pregunta, y ChatGPT cuando la procese entonces obtenemos el resultado de golpe. Pero en este caso, hace poco añadí la funcionalidad para recibir esta información a medida que ChatGPT va procesando la información, de esta manera tenemos un feedback mucho más rápido y vamos obteniendo chunks, bloques de información que podemos ir mostrando a un user. Si no te queda claro, cuando ejecutemos este método vas a ver que es muy sencillo.
  4. Por cada mensaje obtenido, de momento lo vamos a printar por consola
  5. Y en caso de error, mostraremos el error que hemos obtenido por consola.

En este punto, ya podemos probar nuestro código. Pero nos falta una pieza muy importante, y es el apiKey que le debemos pasar al SDK de SwiftOpenAI para que se inicialice. Para hacerlo, vamos a crear un nuevo apiKey, vamos a la siguiente URL https://platform.openai.com

OpenAI API
An API for accessing new AI models developed by OpenAI

Y aquí, nos vamos al menu, a la sección de View API Keys y aquí dentro vamos a crear una nueva. Y una vez he creado una API Key nueva, la paso como parámetro a SwiftOpenAI.

Es decir pasamos de tener:

var openAI = SwiftOpenAI(apiKey: "YOUR-API-KEY")

a:

var openAI = SwiftOpenAI(apiKey: "TU-API-KEY-LA-QUE-ACABS-DE-CREAR")

Ya hemos acabado (de momento) con el ViewModel. Ahora vamos a nuestra View ContentView, y vamos a crear un Button para que lance la acción del método que hemos creado en el ViewModel. De esta manera vamos conectando partes de nuestra aplicación.

Dentro de nuestro ContentView, lo primero es creamos una instancia de nuestro ViewModel, de esta manera podremos llamar al método send que acabamos de crear.

Y a continuación creamos el Button que se encargará de llamar al método send:

import SwiftUI

struct ContentView: View {
    var viewModel = ViewModel()
    
    var body: some View {
        VStack {
            Button(action: {
                Task {
                    await viewModel.send()
                }
            }) {
                Image(systemName: "paperplane.fill")
                    .foregroundColor(Color.white)
                    .frame(width: 44, height: 44)
                    .background(Color.blue)
                    .cornerRadius(22)
            }
            .padding(.leading, 8)
        }
        .padding()
    }
}

Una vez creado el Button, ahora solo nos falta probarlo. Vamos a compilar nuestra aplicación en el simulador. Si pulsamos el Button vamos a ver qué ocurre.

Funciona! en la consola vemos como se están printando mensajes en tiempo real. Y cada mensaje mostrado es una parte de la respuesta. Para centrarnos en la parte importante, busca en la consola por el contenido content:

Una vez hemos probado que funciona, lo siguiente que vamos hacer es centrarnos en la UI. Más tarde volveremos al ViewModel para modificar la lógica del método send.

Creamos TextMessageView

Vamos a crear 2 subvistas y luego las vamos a componer en ContentView. La primera vista que vamos a crear es la del mensaje. Una vista muy sencilla que tendrá 2 variantes, una para los mensajes que nosotros enviamos a ChatGPT, y otra para los mensajes que recibimos.

Creamos una carpeta llamada Subviews, y dentro creamos TextMessageView. Partimos de este código que genera Xcode al crear una View en SwiftUI:

import SwiftUI

struct TextMessageView: View {
    var body: some View {
        Text("Hello world")
    }
}

Nosotros, lo primero que necesitamos es saber la información que vamos a mostrar. Para ello creamos una propiedad llamada message de tipo MessageChatGPT, y al hacerlo vemos que tenemos un error. Eso es porque tenemos que importar el framework SwiftOpenAI, ya que el tipo MessageChatGPT pertenece al SDK.

struct TextMessageView: View {
    var message: MessageChatGPT
    
    var body: some View {
        Text(message.text)
    }
}

Para entender mejor esta vista, vamos a crear unas Previews, así vamos a ver los dos diseños que vamos a aplicar:

struct TextMessageView_Previews: PreviewProvider {
    static let chatGPTMessage: MessageChatGPT = .init(text: "Hola SwiftBeta, estoy aquí para ayudarte y contestar todas tus preguntas", role: .system)
    static let myMessage: MessageChatGPT = .init(text: "¿Cuándo se lanzó el primer iPhone?", role: .user)
    static var previews: some View {
        Group {
            TextMessageView(message: Self.chatGPTMessage)
                .previewDisplayName("ChatGPT Message")
            TextMessageView(message: Self.myMessage)
                .previewDisplayName("My Message")
        }.previewLayout(.sizeThatFits)
    }
}

Ahora desde el Canvas podemos ver las 2 Previews que se crean. Si quitamos el modo Preview, podemos focalizarnos en la vista TextMessageView, sin ver ningún dispositivo.

Vamos a continuar, vamos a crear la implementación de nuestra View, y la vamos a añadir dentro de un HStack:

struct TextMessageView: View {
    var message: MessageChatGPT
    
    var body: some View {
        HStack {
            Spacer()
            Text(message.text)
                .multilineTextAlignment(.trailing)
                .foregroundColor(.white)
                .padding(.all, 10)
                .background(
                    RoundedRectangle(cornerRadius: 16)
                        .fill(Color.blue)
                )
                .frame(maxWidth: 240, alignment: .trailing)
        }
    }
}

Si miramos la preview del Canvas podemos ver que ahora tiene un diseño más atractivo para nuestra app de Chat con ChatGPT. Pero, ¿qué ocurre? las 2 previews tienen el mismo diseño, vamos a diferenciar claramente cuando un mensaje es escrito por el User, otro menaje es escrito por ChatGPT.

¿Cómo podemos diferenciar cuándo es el mensaje de un User y cuando de ChatGPT? El tipo MessageChatGPT nos da esta información, sería tan sencillo como crear un if:

struct TextMessageView: View {
    var message: MessageChatGPT
    
    var body: some View {
        HStack {
            if message.role == .user {
            Spacer()
            Text(message.text)
                .multilineTextAlignment(.trailing)
                .foregroundColor(.white)
                .padding(.all, 10)
                .background(
                    RoundedRectangle(cornerRadius: 16)
                        .fill(Color.blue)
                )
                .frame(maxWidth: 240, alignment: .trailing)
                
            } else {
                Text(message.text)
                    .multilineTextAlignment(.leading)
                    .foregroundColor(.white)
                    .padding(.all, 10)
                    .background(
                        RoundedRectangle(cornerRadius: 16)
                            .fill(Color.gray)
                    )
                    .frame(maxWidth: 240, alignment: .leading)
                Spacer()
            }
        }
    }
}

Ahora podemos apreciar el cambio en las previews. ChatGPT Message está alineado a la izquierda, y My Message está alineado a la derecha (a parte de los cambios considerables como el backgroundColor).

Una vez hemos construido la Vista para almacenar los mensajes que intercambiamos con ChatGPT, tiene que haber otra vista que maneje todos los mensajes que vamos recibiendo, de esta manera podemos hacer scroll y repasar toda la conversación. Para hacerlo, vamos a crear una vista llamada ConversationView.

ConversationView

Creamos una nueva vista en SwiftUI llamada ConversationView y la añadimos a la carpeta Subviews. Al crearla, vamos a añadir un Property Wrapper EnvironmentObject de tipo ViewModel.

struct ConversationView: View {
    @EnvironmentObject var viewModel: ViewModel

    var body: some View {
        Text("Hello, World!")
    }
}

Al hacerlo obtenemos un error. El error nos indica que ViewModel debe conformar el protocolo ObservabelObject. Así que volvemos al tipo ViewModel y conformamos ObservableObject.

final class ViewModel: ObservableObject {
...
}

Y ya que estamos aquí, vamos a crear una propiedad con nuestro primer mensaje, de esta manera podremos verlo en el listado de mensajes que crearemos a continuación, en la vista ConversationView:

final class ViewModel: ObservableObject {
    @Published var messages: [MessageChatGPT] = [.init(text: "¡Hola! Soy el asistente de SwiftBeta, estoy aquí para contestarte todas las preguntas relacionadas de Swift, SwiftUI, Xcode ¡y mucho más!", role: .system)]
    ...
}

Una vez hecho esto, ahora volvemos otra vez a nuestra vista ConversationView, vamos a mostrar todos los mensajes dentro de un ScrollView:

struct ConversationView: View {
    @EnvironmentObject var viewModel: ViewModel
    
    var body: some View {
        ScrollView {
            ForEach(viewModel.messages) { message in
                TextMessageView(message: message)
            }
        }
    }
}

Para poder ver la vista que estamos construyendo, podemos pasar una instancia de ViewModel en la preview, podemos hacerlo de la siguiente manera:

struct ConversationView_Previews: PreviewProvider {
    static var previews: some View {
        ConversationView().environmentObject(ViewModel())
    }
}

Este primer mensaje va a indicar el tipo de assistente que vamos a tener de ChatGPT. Puedes hacer que tenga un toque más profesional, más coloquial, gracioso, etc.

De momento ya hemos creado la Bubble del comentario de nuestro Chat, y también hemos creado una capa superior en la Jerarquía de vistas para poder listar todos los mensajes y hacer Scroll. Ahora sería interesante dejar que un user escriba un mensaje, y que este mensaje se envíe a ChatGPT para constestar el prompt del user.

Enviar mensaje a ChatGPT

Volvemos a nuestra vista ContentView, y aquí vamos a añadir una propiedad @State de tipo String llamada prompt:

struct ContentView: View {
    @StateObject var viewModel = ViewModel()
    @State var prompt: String = "Por favor, dime un resumen del señor de los anillos"
    ...
 }

Fíjate que también hemos actualizado la propiedad ViewModel para que sea @StateObject. Ahora vamos a añadir el TextField, justo debajo del VStack añadimos un HStack y añadimos el TextField, y justo debajo el Button.

struct ContentView: View {
    @StateObject var viewModel = ViewModel()
    @State var prompt: String = "Por favor, dime un resumen del señor de los anillos"
    
    var body: some View {
        VStack {
            HStack {
                TextField("Write something for ChatGPT", text: $prompt, axis: .vertical)
                    .padding(12)
                    .background(Color(.systemGray6))
                    .cornerRadius(25)
                    .lineLimit(6)
                    .onSubmit {
                        Task {
                            await viewModel.send()
                            prompt = ""
                        }
                    }
                Button(action: {
                    Task {
                        await viewModel.send()
                    }
                }) {
                    Image(systemName: "paperplane.fill")
                        .foregroundColor(Color.white)
                        .frame(width: 44, height: 44)
                        .background(Color.blue)
                        .cornerRadius(22)
                }
            }
            .padding(.leading, 8)
        }
        .padding()
    }
}

Una vez tenemos esta parte, ahora vamos a añadir la vista ConversationView. Y la añadimos justo debajo del VStack:

struct ContentView: View {
    @StateObject var viewModel = ViewModel()
    @State var prompt: String = "Por favor, dime un resumen del señor de los anillos"
    
    var body: some View {
        VStack {
            ConversationView()
                .environmentObject(viewModel)
                .padding(.horizontal, 12)
                .frame(maxWidth: .infinity, maxHeight: .infinity)

            Spacer()
            HStack {
                TextField("Write something for ChatGPT", text: $prompt, axis: .vertical)
                    .padding(12)
                    .background(Color(.systemGray6))
                    .cornerRadius(25)
                    .lineLimit(6)
                Button(action: {
                    Task {
                        await viewModel.send()
                    }
                }) {
                    Image(systemName: "paperplane.fill")
                        .foregroundColor(Color.white)
                        .frame(width: 44, height: 44)
                        .background(Color.blue)
                        .cornerRadius(22)
                }
            }
            .padding(.leading, 8)
        }
        .padding()
    }
}

Esto ya va cogiendo forma! y está quedando muy bien. Si compilamos, y damos al Button de send, fíjate que aún no está conectado el resultado con la información que estamos recibiendo de ChatGPT y tampoco le estamos enviando el texto de nuestro TextField, vamos a añadir estas mejoras en nuestro código:

  • Primero, vamos a arreglar que podamos enviar el campo del TextField, para hacerlo, vamos a enviar el prompt como parámetro en el método del ViewModel llamado send.

Dentro del Button action, actualizamos el siguiente código:

Task {
  await viewModel.send(message: prompt)
}

Estamos obteniendo un error, ya que el método send no espera un parámetro de entrada. Vamos a añadirlo:

func send(message: String) async {
...
}

Y también, vamos a crear una nueva propiedad @Published, llamada currentMessage. Esta propiedad nos va a servir para ir actualizando el mensaje que no está enviando por partes desde ChatGPT:

final class ViewModel: ObservableObject {
...
    @Published var currentMessage: MessageChatGPT = .init(text: "", role: .assistant)

}

Al hacerlo @Published, por cada valor que asignemos a esta propiedad, la UI que esté escuchando el cambio se refrescará para mostrar el nuevo contenido.

Antes de llamar al SDK de SwiftOpenAI, tenemos que actualizar el Array de mensajes que le pasamos como parámetro. Dentro de este Array tiene que ir el mensaje que acaba de escribir el User, de esta manera ChatGPT sabrá qué contesta, así que justo antes del do, añadimos el siguiente código:

let myMessage = MessageChatGPT(text: message, role: .user)
self.messages.append(myMessage)

Y también, para poder tener una referencia del mensaje que nos va a contestar ChatGPT por fragmentos, vamos a añadir un mensaje a nuestro Array messages de tipo asistant y que esté vacío, así que justo debajo de las líneas que acabas de añadir, añade las siguientes:

await MainActor.run {
    self.currentMessage = MessageChatGPT(text: "", role: .assistant)
    self.messages.append(self.currentMessage)
}

Solo nos queda un último paso. Actualizar el mensaje que nos va retornando ChatGPT, y así poderlo ver desde la UI, desde nuestra vista.

Creamos el siguiente método:

@MainActor
private func onReceive(newMessage: ChatCompletionsStreamDataModel) {
    let lastMessage = newMessage.choices[0]
    guard lastMessage.finishReason == nil else {
        print("Finished streaming messages")
        return
    }
    
    guard let content = lastMessage.delta?.content else {
        print("Message with no content")
        return
    }
    
    currentMessage.text = currentMessage.text + content
    messages[messages.count-1].text = currentMessage.text
}

Y una vez creado lo llamamos desde dentro del for, y quedaría el siguiente código:

for try await response in stream { // 4
    print(response)
    await onReceive(newMessage: response)
}

Con este último paso, si ahora compilamos, vamos a ver qué ocurre. Funciona perfectamente, podemos escribir la pregunta que queramos que ChatGPT la va a contestar.

Al ejecutar nuestra app podemos escribir la pregunta que queramos, y vemos como obtenemos el resultado por bloques, según ChatGPT nos va constestando. Antes de finalizar el video vamos a arreglar el pequeño error que tenemos. Esto es debido a que estamos intentando actualizar un @Published desde un background thread, pero lo podemos solucionar de la siguiente manera:

await MainActor.run {
    let myMessage = MessageChatGPT(text: message, role: .user)
    self.messages.append(myMessage)
    
    self.currentMessage = MessageChatGPT(text: "", role: .assistant)
    self.messages.append(self.currentMessage)
}

Solucionado.

Esta app tiene muchas posibles mejoras, si quieres hacer autoscroll a medida que recibimos mensajes, tener una animación para mostrar un loading al esperar la respuesta de ChatGPT, o incluso añadir la API de DALL-E2 para crear imágenes, etc escríbemelo en los comentarios, si tiene suficiente apoyo crearé otro video sobre este tema tan interesante!

Conclusión

Hoy hemos aprendido a integrar un SDK de terceros dentro de nuestra aplicación, en este caso hemos añadido un SDK llamado SwiftOpenAI, es un SDK que se comunica con la API de OpenAI y es en el que he estado trabajando las últimas semanas. Una vez integrado hemos aprendido a usarlo para obtener resultados de ChatGPT en tiempo real, mostrando los mensajes como si de una app de mensajería se tratara.

Y hasta aquí el video de hoy!