Cómo implementar una app de chat potenciada por Gemini en Cloud Run

1. Introducción

Descripción general

En este codelab, verás cómo puedes crear un bot de chat básico escrito en node con la API de Vertex AI Gemini y la biblioteca cliente de Vertex AI. Esta app usa un almacén de sesiones de Express respaldado por Google Cloud Firestore.

Qué aprenderás

  • Cómo usar htmx, tailwindcss y express.js para compilar un servicio de Cloud Run
  • Cómo usar las bibliotecas cliente de Vertex AI para autenticarse en las APIs de Google
  • Cómo crear un chatbot para interactuar con el modelo de Gemini
  • Cómo implementar un servicio de Cloud Run sin un archivo de Docker
  • Cómo usar un almacén de sesiones de Express respaldado por Google Cloud Firestore

2. Configuración y requisitos

Requisitos previos

Activar Cloud Shell

  1. En la consola de Cloud, haz clic en Activar Cloud Shell d1264ca30785e435.png.

cb81e7c8e34bc8d.png

Si es la primera vez que inicias Cloud Shell, verás una pantalla intermedia en la que se describe qué es. Si viste una pantalla intermedia, haz clic en Continuar.

d95252b003979716.png

El aprovisionamiento y la conexión a Cloud Shell solo tomará unos minutos.

7833d5e1c5d18f54.png

Esta máquina virtual se carga con todas las herramientas de desarrollo necesarias. Ofrece un directorio principal persistente de 5 GB y se ejecuta en Google Cloud, lo que mejora en gran medida el rendimiento de la red y la autenticación. Gran parte de tu trabajo en este codelab, si no todo, se puede hacer con un navegador.

Una vez que te conectes a Cloud Shell, deberías ver que te autenticaste y que el proyecto se configuró con el ID de tu proyecto.

  1. En Cloud Shell, ejecuta el siguiente comando para confirmar que tienes la autenticación:
gcloud auth list

Resultado del comando

 Credentialed Accounts
ACTIVE  ACCOUNT
*       <my_account>@<my_domain.com>

To set the active account, run:
    $ gcloud config set account `ACCOUNT`
  1. En Cloud Shell, ejecuta el siguiente comando para confirmar que el comando gcloud conoce tu proyecto.
gcloud config list project

Resultado del comando

[core]
project = <PROJECT_ID>

De lo contrario, puede configurarlo con este comando:

gcloud config set project <PROJECT_ID>

Resultado del comando

Updated property [core/project].

3. Habilita las APIs y establece las variables de entorno

Habilita las APIs

Antes de comenzar a usar este codelab, deberás habilitar varias APIs. Este codelab requiere el uso de las siguientes APIs. Puedes habilitar esas APIs ejecutando el siguiente comando:

gcloud services enable run.googleapis.com \
    cloudbuild.googleapis.com \
    aiplatform.googleapis.com \
    secretmanager.googleapis.com

Configura variables de entorno

Puedes establecer variables de entorno que se usarán en este codelab.

PROJECT_ID=<YOUR_PROJECT_ID>
REGION=<YOUR_REGION, e.g. us-central1>
SERVICE=chat-with-gemini
SERVICE_ACCOUNT="vertex-ai-caller"
SERVICE_ACCOUNT_ADDRESS=$SERVICE_ACCOUNT@$PROJECT_ID.iam.gserviceaccount.com
SECRET_ID="SESSION_SECRET"

4. Crea y configura un proyecto de Firebase

  1. En el Firebase console, haz clic en Agregar proyecto.
  2. Ingresa <YOUR_PROJECT_ID> para agregar Firebase a uno de tus proyectos existentes de Google Cloud.
  3. Si se te solicita, revisa y acepta las Condiciones de Firebase.
  4. Haz clic en Continuar.
  5. Haz clic en Confirmar plan para confirmar el plan de facturación de Firebase.
  6. Es opcional habilitar Google Analytics para este codelab.
  7. Haz clic en Agregar Firebase:
  8. Cuando se cree el proyecto, haz clic en Continuar.
  9. En el menú Compilar, haz clic en Base de datos de Firestore.
  10. Haz clic en Crear base de datos.
  11. Elige tu región en el menú desplegable Ubicación y, luego, haz clic en Siguiente.
  12. Usa el Iniciar en modo de producción predeterminado y, luego, haz clic en Crear.

5. Crea una cuenta de servicio

Cloud Run usará esta cuenta de servicio para llamar a la API de Vertex AI Gemini. Esta cuenta de servicio también tendrá permisos para leer y escribir en Firestore, y leer secretos de Secret Manager.

Primero, crea la cuenta de servicio ejecutando este comando:

gcloud iam service-accounts create $SERVICE_ACCOUNT \
  --display-name="Cloud Run to access Vertex AI APIs"

En segundo lugar, otórgale a la cuenta de servicio el rol de usuario de Vertex AI.

gcloud projects add-iam-policy-binding $PROJECT_ID \
  --member serviceAccount:$SERVICE_ACCOUNT_ADDRESS \
  --role=roles/aiplatform.user

Ahora, crea un secreto en Secret Manager. El servicio de Cloud Run accederá a este secreto como una variable de entorno, que se resuelve en el momento de inicio de la instancia. Puedes obtener más información sobre los secretos y Cloud Run.

gcloud secrets create $SECRET_ID --replication-policy="automatic"
printf "keyboard-cat" | gcloud secrets versions add $SECRET_ID --data-file=-

Además, otórgale a la cuenta de servicio acceso al secreto de la sesión de Express en Secret Manager.

gcloud secrets add-iam-policy-binding $SECRET_ID \
    --member serviceAccount:$SERVICE_ACCOUNT_ADDRESS \
    --role='roles/secretmanager.secretAccessor'

Por último, otórgale a la cuenta de servicio acceso de lectura y escritura a Firestore.

gcloud projects add-iam-policy-binding $PROJECT_ID \
  --member serviceAccount:$SERVICE_ACCOUNT_ADDRESS \
  --role=roles/datastore.user

6. Crea el servicio de Cloud Run

Primero, crea un directorio para el código fuente y cd en ese directorio.

mkdir chat-with-gemini && cd chat-with-gemini

Luego, crea un archivo package.json con el siguiente contenido:

{
  "name": "chat-with-gemini",
  "version": "1.0.0",
  "description": "",
  "main": "app.js",
  "scripts": {
    "start": "node app.js",
    "nodemon": "nodemon app.js",
    "cssdev": "npx tailwindcss -i ./input.css -o ./public/output.css --watch",
    "tailwind": "npx tailwindcss -i ./input.css -o ./public/output.css",
    "dev": "npm run tailwind && npm run nodemon"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@google-cloud/connect-firestore": "^3.0.0",
    "@google-cloud/firestore": "^7.5.0",
    "@google-cloud/vertexai": "^0.4.0",
    "axios": "^1.6.8",
    "express": "^4.18.2",
    "express-session": "^1.18.0",
    "express-ws": "^5.0.2",
    "htmx.org": "^1.9.10"
  },
  "devDependencies": {
    "nodemon": "^3.1.0",
    "tailwindcss": "^3.4.1"
  }
}

A continuación, crea un archivo fuente app.js con el siguiente contenido. Este archivo contiene el punto de entrada del servicio y la lógica principal de la app.

const express = require("express");
const app = express();
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
const path = require("path");

const fs = require("fs");
const util = require("util");
const { spinnerSvg } = require("./spinnerSvg.js");

// cloud run retrieves secret at instance startup time
const secret = process.env.SESSION_SECRET;

const { Firestore } = require("@google-cloud/firestore");
const { FirestoreStore } = require("@google-cloud/connect-firestore");
var session = require("express-session");
app.set("trust proxy", 1); // trust first proxy
app.use(
    session({
        store: new FirestoreStore({
            dataset: new Firestore(),
            kind: "express-sessions"
        }),
        secret: secret,
        /* set secure to false for local dev session history testing */
        /* see more at https://expressjs.com/en/resources/middleware/session.html */
        cookie: { secure: true },
        resave: false,
        saveUninitialized: true
    })
);

const expressWs = require("express-ws")(app);

app.use(express.static("public"));

// Vertex AI Section
const { VertexAI } = require("@google-cloud/vertexai");

// instance of Vertex model
let generativeModel;

// on startup
const port = parseInt(process.env.PORT) || 8080;
app.listen(port, async () => {
    console.log(`demo1: listening on port ${port}`);

    // get project and location from metadata service
    const metadataService = require("./metadataService.js");

    const project = await metadataService.getProjectId();
    const location = await metadataService.getRegion();

    // Vertex client library instance
    const vertex_ai = new VertexAI({
        project: project,
        location: location
    });

    // Instantiate models
    generativeModel = vertex_ai.getGenerativeModel({
        model: "gemini-1.0-pro-001"
    });
});

app.ws("/sendMessage", async function (ws, req) {
    if (!req.session.chathistory || req.session.chathistory.length == 0) {
        req.session.chathistory = [];
    }

    let chatWithModel = generativeModel.startChat({
        history: req.session.chathistory
    });

    ws.on("message", async function (message) {

        console.log("req.sessionID: ", req.sessionID);
        // get session id

        let questionToAsk = JSON.parse(message).message;
        console.log("WebSocket message: " + questionToAsk);

        ws.send(`<div hx-swap-oob="beforeend:#toupdate"><div
                        id="questionToAsk"
                        class="text-black m-2 text-right border p-2 rounded-lg ml-24">
                        ${questionToAsk}
                    </div></div>`);

        // to simulate a natural pause in conversation
        await sleep(500);

        // get timestamp for div to replace
        const now = "fromGemini" + Date.now();

        ws.send(`<div hx-swap-oob="beforeend:#toupdate"><div
                        id=${now}
                        class=" text-blue-400 m-2 text-left border p-2 rounded-lg mr-24">
                        ${spinnerSvg} 
                    </div></div>`);

        const results = await chatWithModel.sendMessage(questionToAsk);
        const answer =
            results.response.candidates[0].content.parts[0].text;

        ws.send(`<div
                        id=${now}
                        hx-swap-oob="true"
                        hx-swap="outerHTML"
                        class="text-blue-400 m-2 text-left border p-2 rounded-lg mr-24">
                        ${answer}
                    </div>`);

                    // save to current chat history
        let userHistory = {
            role: "user",
            parts: [{ text: questionToAsk }]
        };
        let modelHistory = {
            role: "model",
            parts: [{ text: answer }]
        };

        req.session.chathistory.push(userHistory);
        req.session.chathistory.push(modelHistory);

        // console.log(
        //     "newly saved chat history: ",
        //     util.inspect(req.session.chathistory, {
        //         showHidden: false,
        //         depth: null,
        //         colors: true
        //     })
        // );
        req.session.save();
    });

    ws.on("close", () => {
        console.log("WebSocket was closed");
    });
});

function sleep(ms) {
    return new Promise((resolve) => {
        setTimeout(resolve, ms);
    });
}

// gracefully close the web sockets
process.on("SIGTERM", () => {
    server.close();
});

Crea el archivo tailwind.config.js para tailwindCSS.

/** @type {import('tailwindcss').Config} */
module.exports = {
    content: ["./**/*.{html,js}"],
    theme: {
        extend: {}
    },
    plugins: []
};

Crea el archivo metadataService.js para obtener el ID del proyecto y la región del servicio de Cloud Run implementado. Estos valores se usarán para crear una instancia de las bibliotecas cliente de Vertex AI.

const your_project_id = "YOUR_PROJECT_ID";
const your_region = "YOUR_REGION";

const axios = require("axios");

module.exports = {
    getProjectId: async () => {
        let project = "";
        try {
            // Fetch the token to make a GCF to GCF call
            const response = await axios.get(
                "http://metadata.google.internal/computeMetadata/v1/project/project-id",
                {
                    headers: {
                        "Metadata-Flavor": "Google"
                    }
                }
            );

            if (response.data == "") {
                // running locally on Cloud Shell
                project = your_project_id;
            } else {
                // running on Clodu Run. Use project id from metadata service
                project = response.data;
            }
        } catch (ex) {
            // running locally on local terminal
            project = your_project_id;
        }

        return project;
    },

    getRegion: async () => {
        let region = "";
        try {
            // Fetch the token to make a GCF to GCF call
            const response = await axios.get(
                "http://metadata.google.internal/computeMetadata/v1/instance/region",
                {
                    headers: {
                        "Metadata-Flavor": "Google"
                    }
                }
            );

            if (response.data == "") {
                // running locally on Cloud Shell
                region = your_region;
            } else {
                // running on Clodu Run. Use region from metadata service
                let regionFull = response.data;
                const index = regionFull.lastIndexOf("/");
                region = regionFull.substring(index + 1);
            }
        } catch (ex) {
            // running locally on local terminal
            region = your_region;
        }
        return region;
    }
};

Crea un archivo llamado spinnerSvg.js.

module.exports.spinnerSvg = `<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-blue-500"
                    xmlns="http://www.w3.org/2000/svg"
                    fill="none"
                    viewBox="0 0 24 24"
                >
                    <circle
                        class="opacity-25"
                        cx="12"
                        cy="12"
                        r="10"
                        stroke="currentColor"
                        stroke-width="4"
                    ></circle>
                    <path
                        class="opacity-75"
                        fill="currentColor"
                        d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
                    ></path></svg>`;

Por último, crea un archivo input.css para tailwindCSS.

@tailwind base;
@tailwind components;
@tailwind utilities;

Ahora, crea un directorio public nuevo.

mkdir public
cd public

Dentro de ese directorio público, crea el archivo index.html para el frontend, que usará htmx.

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <meta
            name="viewport"
            content="width=device-width, initial-scale=1.0"
        />
        <script
            src="https://unpkg.com/htmx.org@1.9.10"
            integrity="sha384-D1Kt99CQMDuVetoL1lrYwg5t+9QdHe7NLX/SoJYkXDFfX37iInKRy5xLSi8nO7UC"
            crossorigin="anonymous"
        ></script>

        <link href="./output.css" rel="stylesheet" />
        <script src="https://unpkg.com/htmx.org/dist/ext/ws.js"></script>

        <title>Demo 1</title>
    </head>
    <body>
        <div id="herewego" text-center>
            <!-- <div id="replaceme2" hx-swap-oob="true">Hello world</div> -->
            <div
                class="container mx-auto mt-8 text-center max-w-screen-lg"
            >
                <div
                    class="overflow-y-scroll bg-white p-2 border h-[500px] space-y-4 rounded-lg m-auto"
                >
                    <div id="toupdate"></div>
                </div>
                <form
                    hx-trigger="submit, keyup[keyCode==13] from:body"
                    hx-ext="ws"
                    ws-connect="/sendMessage"
                    ws-send=""
                    hx-on="htmx:wsAfterSend: document.getElementById('message').value = ''"
                >
                    <div class="mb-6 mt-6 flex gap-4">
                        <textarea
                            rows="2"
                            type="text"
                            id="message"
                            name="message"
                            class="block grow rounded-lg border p-6 resize-none"
                            required
                        >
Is C# a programming language or a musical note?</textarea
                        >
                        <button
                            type="submit"
                            class="bg-blue-500 text-white px-4 py-2 rounded-lg text-center text-sm font-medium"
                        >
                            Send
                        </button>
                    </div>
                </form>
            </div>
        </div>
    </body>
</html>

7. Ejecuta el servicio de forma local

Primero, asegúrate de estar en el directorio raíz chat-with-gemini de tu codelab.

cd .. && pwd

A continuación, instala las dependencias ejecutando el siguiente comando:

npm install

Usa ADC cuando se ejecuta de forma local

Si ejecutas en Cloud Shell, ya lo haces en una máquina virtual de Google Compute Engine. Las credenciales asociadas a esta máquina virtual (como se muestra cuando se ejecuta gcloud auth list) se usarán automáticamente con las credenciales predeterminadas de la aplicación, por lo que no es necesario usar el comando gcloud auth application-default login. Puedes omitir la sección Crea un secreto de sesión local.

Sin embargo, si ejecutas en tu terminal local (es decir, no en Cloud Shell), deberás usar las credenciales predeterminadas de la aplicación para autenticarte en las APIs de Google. Puedes 1) acceder con tus credenciales (siempre que tengas los roles de usuario de Vertex AI y usuario de Datastore) o 2) acceder con la identidad de la cuenta de servicio que se usa en este codelab.

Opción 1: Usa tus credenciales para ADC

Si deseas usar tus credenciales, primero puedes ejecutar gcloud auth list para verificar cómo te autenticas en gcloud. Es posible que debas otorgarle a tu identidad el rol de usuario de Vertex AI. Si tu identidad tiene el rol de propietario, ya tienes este rol de usuario de Vertex AI. De lo contrario, puedes ejecutar este comando para otorgarle a tu identidad el rol de usuario de Vertex AI y el rol de usuario de Datastore.

USER=<YOUR_PRINCIPAL_EMAIL>

gcloud projects add-iam-policy-binding $PROJECT_ID \
  --member user:$USER \
  --role=roles/aiplatform.user

gcloud projects add-iam-policy-binding $PROJECT_ID \
  --member user:$USER \
  --role=roles/datastore.user

Luego, ejecuta el siguiente comando.

gcloud auth application-default login

Opción 2: Usa la identidad de una cuenta de servicio para ADC

Si deseas usar la cuenta de servicio creada en este codelab, tu cuenta de usuario deberá tener el rol de creador de tokens de cuentas de servicio. Puedes obtener este rol ejecutando el siguiente comando:

gcloud projects add-iam-policy-binding $PROJECT_ID \
  --member user:$USER \
  --role=roles/iam.serviceAccountTokenCreator

A continuación, ejecutarás el siguiente comando para usar ADC con la cuenta de servicio.

gcloud auth application-default login --impersonate-service-account=$SERVICE_ACCOUNT_ADDRESS

Crea un secreto de sesión local

Ahora, crea un secreto de sesión local para el desarrollo local.

export SESSION_SECRET=local-secret

Ejecuta la app de forma local

Por último, puedes iniciar la app ejecutando la siguiente secuencia de comandos. Esta secuencia de comandos también generará el archivo output.css desde tailwindCSS.

npm run dev

Para obtener una vista previa del sitio web, abre el botón Vista previa en la Web y selecciona Vista previa del puerto 8080.

Vista previa en la Web: botón de vista previa en el puerto 8080

8. Implemente el servicio

Primero, ejecuta este comando para iniciar la implementación y especificar la cuenta de servicio que se usará. Si no se especifica una cuenta de servicio, se usa la cuenta de servicio de procesamiento predeterminada.

gcloud run deploy $SERVICE \
 --service-account $SERVICE_ACCOUNT_ADDRESS \
 --source . \
  --region $REGION \
  --allow-unauthenticated \
  --set-secrets="SESSION_SECRET=$(echo $SECRET_ID):1"

Si se te solicita que "La implementación desde el código fuente requiere un repositorio de Docker de Artifact Registry para almacenar contenedores compilados. Se creará un repositorio llamado [cloud-run-source-deploy] en la región [us-central1]", presiona "y" para aceptar y continuar.

9. Prueba el servicio

Una vez implementado, abre la URL de servicio en tu navegador web. Luego, hazle una pregunta a Gemini, por ejemplo: "Practico guitarra, pero también soy ingeniero de software. Cuando veo "C#", ¿debería pensar en él como un lenguaje de programación o una nota musical? ¿Cuál debería elegir?".

10. ¡Felicitaciones!

Felicitaciones por completar el codelab.

Te recomendamos que revises la documentación de Cloud Run y las APIs de Vertex AI Gemini.

Temas abordados

  • Cómo usar htmx, tailwindcss y express.js para compilar un servicio de Cloud Run
  • Cómo usar las bibliotecas cliente de Vertex AI para autenticarse en las APIs de Google
  • Cómo crear un bot de chat para interactuar con el modelo de Gemini
  • Cómo implementar un servicio de Cloud Run sin un archivo de Docker
  • Cómo usar un almacén de sesiones de Express respaldado por Google Cloud Firestore

11. Limpia

Para evitar cargos involuntarios (por ejemplo, si los servicios de Cloud Run se invocan de forma involuntaria más veces que tu asignación mensual de invocaciones de Cloud Run en el nivel gratuito), puedes borrar Cloud Run o el proyecto que creaste en el paso 2.

Para borrar el servicio de Cloud Run, ve a la consola de Cloud Run en https://console.cloud.google.com/run y borra el servicio chat-with-gemini. También puedes borrar la cuenta de servicio vertex-ai-caller o revocar el rol de usuario de Vertex AI para evitar llamadas involuntarias a Gemini.

Si eliges borrar todo el proyecto, puedes ir a https://console.cloud.google.com/cloud-resource-manager, seleccionar el proyecto que creaste en el paso 2 y elegir Borrar. Si borras el proyecto, deberás cambiar los proyectos en tu SDK de Cloud. Para ver la lista de todos los proyectos disponibles, ejecuta gcloud projects list.