Tutorial: Chat con ChatGPT - Parte 1

Pato para el Taller de Innovación 🚀 de SCV
#articles

Tutoriales TDI🚀 Tutoriales TDI🚀

Vamos a crear una webapp desde la cual le permitiremos al usuario comunicarse con un chat de OpenAI que podremos ajustar con nuestros parámetros.

Con este esquema vamos a poder presentar nuestra interfaz y controlar en qué forma llega el prompt a OpenAI, de qué manera se analiza la respuesta y cómo se presenta al usuario.


Partes:


Tecnologías que utilizaremos en este tutorial:

  • Node
  • React
  • Librería de OpenAI
  • WebSocket

Vamos a checkear que estemos utilizando versiones similares de Node:

node -v

Yo estoy usando la v20.16.0


Crear la webapp con React

En la terminal:

npx create-react-app mi-chat-con-asistente

Si create-react-app necesita instalar paquetes faltantes preguntará "Need to install the following packages", debemos aceptar todos.

Troubleshooting

React requiere una interfaz con Python que se llama node-gyp que no instala create-react-app. Si sucede este error durante create-react-app debemos instalar node-gyp globalmente y volver a intentar.

npm install -g node-gyp

Iniciar servidor de desarrollo

cd mi-chat-con-asistente
npm start

Esto va a montar el servicio, por default, en el puerto 3000. Entraremos con el browser a http://localhost:3000/

Y abriremos el proyecto en VS Code.


La interfaz del chat

Editaremos src/App.js. Vamos a borrar el contenido de ejemplo y a llenarlo con un nuevo componente que será nuestro chat:

const MyChat = () => {
  return <div className="container">
    <div className="chat-body">
    </div>
    <form className="chat-footer">
      <input type="text" />
      <button type="submit">Enviar</button>
    </form>
  </div>
};

class App extends Component {
  render() {
    return (
      <MyChat />
    );
  }
}

Luego en src/App.css también debemos borrar el contenido de ejemplo y lo llenaremos con:

body {
  margin: 0;
  padding: 0;
  font-family: sans-serif;
  height: 100dvh; /* dvh es el visual height restando considerando el alto de la barra de url del browser en mobile */
}

.container {
  display: flex; /* este contenedor tendrá el cuerpo del chat y el footer con el input, es conveniente que sea flex para que uno de ellos crezca para adaptarse a los diferentes altos de pantalla */
  flex-direction: column;
  margin-left: auto;
  margin-right: auto;
  min-height: 100dvh;
  width: 600px;
}

.chat-body {
  border: 1px #eee solid;
  box-sizing: border-box;
  flex: 1 1 auto; /* para que se adapte a pantallas de distintos tamaños */
  margin: 0;
  overflow: auto;
  padding: 16px;
}

.chat-footer {
  box-sizing: border-box;
  display: flex;
  flex: 0 0 auto; /* para que permanezca siempre del mismo tamaño */
  margin: 0;
  padding: 16px;
  width: 100%;
}

.chat-footer input[type='text'] {
  border: 0;
  background-color: #eee;
  flex: 1 1 auto;
  padding: 8px 16px;
  margin: auto 0;
}

.chat-footer button {
  border: 0;
  background-color: #eee;
  cursor: pointer;
  flex: 0 0 auto;
  padding: 8px 16px;
  margin: auto 0 auto 16px;
}

Las líneas comentadas son las que arman el layout, el resto son solamente estilo.

Con useState vamos a mantener el estado del input que está completando el usuario y un array que mostrará el historial de los mensajes de chat:

import React, { Component, useEffect, useRef, useState } from 'react';

const MyChat = () => {
  const [userNewMessage, setUserNewMessage] = useState('');
  const [messages, setMessages] = useState([]);

Necesitamos también dos funciones. Una que actualice userNewMessage en la medida que el usuario va escribiendo y otro que envíe el mensaje al servidor.

const MyChat = () => {
...
  const handleUserInput = (e) => {
    setUserNewMessage(e.target.value);
  };

  const handleSubmit = (e) => {
    e.preventDefault();

    setMessages((prevMessages) => [...prevMessages, {
      text: userNewMessage
    }]);

    // Aquí enviaremos el mensaje al servidor

    setUserNewMessage(''); // Aquí vaciamos el input para que esté listo para el siguiente mensaje
  };
...

Y las agregamos a los eventos de los elementos input y form de nuestra app:

...
        <form className="chat-footer" onSubmit={handleSubmit}>
          <input type="text" value={userNewMessage} onChange={handleUserInput} />
...

También agregaremos dentro del elemento con la class .chat-body el loop para mostrar el historial de mensajes:

...
    <div className="chat-body">
      <ul>
        {messages.map((eachMessage, index) => (
          <li key={index}>{eachMessage.text}</li>
        ))}
      </ul>
    </div>
...

A esta altura ya es posible probar visualmente cómo funciona agregar mensajes al chat. Probar escribir "Hola" y enviar en el navegador.


Crear la comunicación por WebSocket

Del lado del cliente

Es momento de agregar el cliente y el servidor de WebSocket para tener comunicación constante con el servidor.

...
  const myWsConnection = useRef(null); // contendrá nuestra conexión a WS para que esté disponible en las distintas funciones del componente.

  // Usamos el hook useEffect para asegurarnos de que se genere una única conexión de WS aunque React requiera correr la función del componente muchas veces.
  useEffect(() => {
    const myChatServerUrl = 'http://localhost:8000';
    const ws = new WebSocket(`${myChatServerUrl}/ws`);

    // Al recibir un mensaje lo agregará al array del estado del historial de mensajes que está manteniendo react
    ws.onmessage = async (message) => {
      const receivedWsMessage = JSON.parse(message.data);
      setMessages((prevMessages) => [...prevMessages, {
        text: receivedWsMessage.text
      }]);
    };

    myWsConnection.current = ws;
  }, []); // Array vacío de dependencias hará que se ejecute una única vez y no en cada actualización del componente.
...

Del lado del servidor

Vamos a desarrollar un servidor WebSocket. En otra terminal vamos a instalar (en la misma carpeta o en un nuevo proyecto npm) la librería "ws" para manejar WebSocket server side:

npm install ws --save

Y a crear un nuevo archivo:

touch chat-ws-server.js

Dentro de él pondremos:

const http = require('http');
const { WebSocketServer } = require('ws');

const server = http.createServer((req, res) => {
});

const myWsServer = new WebSocketServer({ server });
myWsServer.on('connection', (theConnection) => {
  console.log('Recibí una nueva conexión');
});

server.listen(8000, () => {
  console.log('Server started on port 8000');
});

E iniciarlo con:

node chat-ws-server.js

Con esto ya tenemos la app de React corriendo en http://localhost:3000 y el servidor de WebSocket corriendo en http://localhost:8000. Como ya habíamos seteado el chat en React para que se conecte a un WebSocket en esa url, al refrescar la pantalla del navegador ya debería verse "Recibí una nueva conexión" en la consola donde corre el servidor WS.


Respuestas

Le agregaremos al servidor una respuesta:

...
myWsServer.on('connection', (theConnection) => {
  /* theConnection es una sesión específica con un navegador, lo que suceda aquí adentro está aislado a ese usuario en ese momento */
  theConnection.send(JSON.stringify({ text: 'Esta es la bienvenida del servidor a cada usuario' }));
});
...

Como no hicimos un "modo dev" para el servidor, hay que detenerlo y volverlo a arrancar para que tome los cambios con "control + c" y node chat-ws-server.js nuevamente.

A esta altura nuestra app de React ya estará recibiendo el mensaje de bienvenida y mostrandolo dentro de nuestro chat. Ahora agregaremos al servidor la posibilidad de responder al recibir mensajes.

...
  theConnection.send(JSON.stringify({ text: 'Esta es la bienvenida del servidor a cada usuario' }));

  theConnection.on('message', async (receivedBufferData) => {
    theConnection.send(JSON.stringify({ text: 'He recibido eso' }));
  });
...

Reiniciar el servidor nuevamente.

Y ahora del lado del cliente haremos que envíe cada mensaje que escribe el usuario, al servidor por la conexión de WebSocket establecida.

Dentro de la función handleSubmit agregaremos:

...
  const handleSubmit = (e) => {
    ...
    myWsConnection.current.send(JSON.stringify({
      text: userNewMessage,
    }));
...

Ahora probaremos nuestra app, recibiremos el mensaje de bienvenida y al escribir cualquier texto, la respuesta. Como verán la respuesta es prácticamente inmediata, según el tipo de experiencia buscada se puede aplicar un delay artificial para dar la sensación de que el asistente está pensando la respuesta y escribiendo como si fuera una persona.

Mejoras al estilo del chat (opcional)

Diferenciar los mensajes enviados de los recibidos

Como describimos nuestro mensaje, no como un string, sino como un objeto que contiene la propiedad text cuyo valor es el mensaje, podemos usar otras propiedades del objeto para mejorar la presentación de otros aspectos del chat.

Vamos a crear una class para diferenciar el diseño de los mensajes enviados y los mensajes recibidos.

...
.messagesContainer {
  display: flex;
  flex-direction: column;
  list-style: none;
  padding: 0;
}
.messageInTl {
  border-radius: 8px;
  display: block;
  padding: 16px;
}
.messageInTl.notMine {
  background: #ccc;
  margin: 8px auto 0 0;
}
.messageInTl.mine {
  margin: 8px 0 0 auto;
  background: #eee;
}
...

Y en el componente de React modificaremos nuestro loop de mensajes para que incluya las clases:

...
      <ul className="messagesContainer">
        {messages.map((eachMessage, index) => (
          <li key={index} className={`messageInTl ${eachMessage.mine ? 'mine' : 'notMine'}`}>{eachMessage.text}</li>
        ))}
      </ul>
...

Y la propiedad en nuestros mensajes

...
    setMessages((prevMessages) => [...prevMessages, {
      text: userNewMessage,
      mine: true,
    }]);
...

Espacio para el teclado en mobile

Al hacer foco en el input en mobile desplegará el teclado para escribir y ese teclado estará sobre el input impidiendo que se pueda ver lo que se está escribiendo. Para evitar eso podemos modificar el tag meta viewport en public/index.html. La opción resizes-content indica

...
  <meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content" />
...

Otras propiedades del objeto mensaje, en otro tipo de chats, podrían indicar: hora de emitido y hora de recibido, identificación del emisor, llaves de encriptado, si es de un tipo especial, entrada y salida de participantes del chat, cambio de avatar de participantes del chat si incluye adjuntos, si es mensaje de audio, si ha sido recibido por el interlocutor, si ha sido visto por el interlocutor y otros.


Mantener el scroll del historial de chat

Como el navegador intenta seguir mostrando el scroll en la misma posición incluso si se agregan elementos dentro, resulta extraño para el usuario no ver los últimos mensajes primero. Para corregir esto podemos crear una función que modifique la posición del scroll hasta el final cada vez que se actualiza el contenido.

Le agregaremos un id al contenedor que tiene el scroll.

...
    <div className="chat-body" id="historyScroll">
...

Y un nuevo useEffect que modifica el scroll con cada nuevo mensaje.

...
  useEffect(() => {
    const historyScrollElement = document.getElementById('historyScroll');
    historyScrollElement.scrollTop = historyScrollElement.scrollHeight;
  }, [messages]);
...

Mostrar "Escribiendo..."

    ws.onmessage = async (message) => {
      const receivedWsMessage = JSON.parse(message.data);
      // Aquí tomamos la property type del mensaje recibido para reconocer qué tipo es. Creamos un switch para poder tener más tipos de ser necesario
      switch (receivedWsMessage.type) {
        case 'writing':
          setIsWriting(true);
          break;
        default:
          // Esto es lo que hacía por default antes, ahora lo hará solo si no es de tipo "writing"
          setIsWriting(false);
          setMessages((prevMessages) => [...prevMessages, {
            text: receivedWsMessage.text
          }]);
          break;
      }
    };

En el servidor:

  // Los await son solo para simular la tardanza al escribir mientras estamos desarrollando.
  theConnection.send(JSON.stringify({ type: 'writing' }));
  await new Promise((resolve) => setTimeout(() => { resolve(true); }, 1500));
  theConnection.send(JSON.stringify({ type: 'message', text: 'Bienvenido' }));

  theConnection.on('message', async (receivedBufferData) => {
    await new Promise((resolve) => setTimeout(() => { resolve(true); }, 1000));
    theConnection.send(JSON.stringify({ type: 'writing' }));
    
    await new Promise((resolve) => setTimeout(() => { resolve(true); }, 2000));
    theConnection.send(JSON.stringify({ type: 'message', text: 'Esta es la respuesta' }));
  });

Resultado

Aquí terminamos esta primera parte del tutorial