איך פורסים אפליקציית צ'אט מבוססת-Gemini ב-Cloud Run

1. מבוא

סקירה כללית

ב-codelab הזה תלמדו איך ליצור בוט צ'אט בסיסי שנכתב ב-node באמצעות Vertex AI Gemini API וספריית הלקוח של Vertex AI. האפליקציה הזו משתמשת במאגר סשנים של Express שמגובה על ידי Google Cloud Firestore.

מה תלמדו

  • איך משתמשים ב-htmx, ב-tailwindcss וב-express.js כדי ליצור שירות Cloud Run
  • איך משתמשים בספריות הלקוח של Vertex AI כדי לבצע אימות ל-Google APIs
  • איך יוצרים צ'אטבוט כדי לקיים אינטראקציה עם מודל Gemini
  • איך פורסים לשירות Cloud Run בלי קובץ Docker
  • איך משתמשים במאגר סשנים של Express שמגובה על ידי Google Cloud Firestore

2. הגדרה ודרישות

דרישות מוקדמות

הפעלת Cloud Shell

  1. ב-Cloud Console, לוחצים על Activate Cloud Shell d1264ca30785e435.png.

cb81e7c8e34bc8d.png

אם זו הפעם הראשונה שאתם מפעילים את Cloud Shell, יוצג לכם מסך ביניים עם תיאור של השירות. אם הוצג לכם מסך ביניים, לחצו על המשך.

d95252b003979716.png

הקצאת המשאבים והחיבור ל-Cloud Shell נמשכים רק כמה רגעים.

7833d5e1c5d18f54.png

המכונה הווירטואלית הזו כוללת את כל הכלים הדרושים למפתחים. יש בה ספריית בית בנפח מתמיד של 5GB והיא פועלת ב-Google Cloud, מה שמשפר מאוד את הביצועים והאימות ברשת. אפשר לבצע את רוב העבודה ב-codelab הזה, אם לא את כולה, באמצעות דפדפן.

אחרי שמתחברים ל-Cloud Shell, אמור להופיע אימות ושהפרויקט מוגדר לפי מזהה הפרויקט.

  1. מריצים את הפקודה הבאה ב-Cloud Shell כדי לוודא שעברתם אימות:
gcloud auth list

פלט הפקודה

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

To set the active account, run:
    $ gcloud config set account `ACCOUNT`
  1. מריצים את הפקודה הבאה ב-Cloud Shell כדי לוודא שפקודת gcloud מכירה את הפרויקט:
gcloud config list project

פלט הפקודה

[core]
project = <PROJECT_ID>

אם לא, אפשר להגדיר אותו באמצעות הפקודה הבאה:

gcloud config set project <PROJECT_ID>

פלט הפקודה

Updated property [core/project].

3. הפעלת ממשקי API והגדרת משתני סביבה

הפעלת ממשקי ה-API

לפני שמתחילים להשתמש ב-codelab הזה, צריך להפעיל כמה ממשקי API. ב-codelab הזה צריך להשתמש בממשקי ה-API הבאים. כדי להפעיל את ממשקי ה-API האלה, מריצים את הפקודה הבאה:

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

הגדרה של משתני סביבה

אתם יכולים להגדיר משתני סביבה שישמשו אתכם לאורך כל ה-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. יצירה והגדרה של פרויקט Firebase

  1. במסוף Firebase, לוחצים על הוספת פרויקט.
  2. מזינים את הערך <YOUR_PROJECT_ID> כדי להוסיף את Firebase לאחד מהפרויקטים הקיימים ב-Google Cloud
  3. אם תופיע בקשה, תצטרכו לקרוא את התנאים של Firebase ולאשר אותם.
  4. לוחצים על המשך.
  5. לוחצים על אישור תוכנית התשלומים כדי לאשר את תוכנית התשלומים של Firebase.
  6. הפעלת Google Analytics ב-codelab הזה היא אופציונלית.
  7. לוחצים על הוספת Firebase.
  8. אחרי שהפרויקט נוצר, לוחצים על המשך.
  9. בתפריט Build (פיתוח), לוחצים על Firestore database (מסד נתונים של Firestore).
  10. לוחצים על יצירת מסד נתונים.
  11. בוחרים את האזור מהתפריט הנפתח מיקום ולוחצים על הבא.
  12. משתמשים באפשרות ברירת המחדל התחלה במצב ייצור ולוחצים על יצירה.

5. יצירה של חשבון שירות

חשבון השירות הזה ישמש את Cloud Run כדי לבצע קריאה ל-Vertex AI Gemini API. לחשבון השירות הזה יהיו גם הרשאות קריאה וכתיבה ל-Firestore וקריאת סודות מ-Secret Manager.

קודם יוצרים את חשבון השירות באמצעות הפקודה הבאה:

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

בשלב השני, מקצים לחשבון השירות את התפקיד Vertex AI User.

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

עכשיו יוצרים סוד ב-Secret Manager. שירות Cloud Run יגש לסוד הזה כמשתנה סביבה, שייפתר בזמן הפעלת המכונה. מידע נוסף על סודות ו-Cloud Run

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

נותנים לחשבון השירות גישה לסוד של סשן Express ב-Secret Manager.

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

לבסוף, מעניקים לחשבון השירות גישת קריאה וכתיבה ל-Firestore.

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

6. יצירת שירות Cloud Run

קודם יוצרים ספרייה לקוד המקור ועוברים לספרייה הזו.

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

לאחר מכן, יוצרים קובץ package.json עם התוכן הבא:

{
  "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"
  }
}

לאחר מכן, יוצרים קובץ מקור app.js עם התוכן שבהמשך. הקובץ הזה מכיל את נקודת הכניסה לשירות ואת הלוגיקה העיקרית של האפליקציה.

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();
});

יוצרים את קובץ tailwind.config.js עבור tailwindCSS.

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

יוצרים את הקובץ metadataService.js כדי לקבל את מזהה הפרויקט והאזור של שירות Cloud Run שנפרס. הערכים האלה ישמשו ליצירת מופע של ספריות הלקוח של 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;
    }
};

יוצרים קובץ בשם 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>`;

לבסוף, יוצרים קובץ input.css ל-tailwindCSS.

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

עכשיו יוצרים ספרייה חדשה בשם public.

mkdir public
cd public

בתוך הספרייה הציבורית הזו, יוצרים את הקובץ index.html עבור ממשק הקצה, שבו ישמש 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. הפעלת השירות באופן מקומי

קודם כל, מוודאים שאתם בספריית הבסיס chat-with-gemini של ה-codelab.

cd .. && pwd

לאחר מכן, מריצים את הפקודה הבאה כדי להתקין את יחסי התלות:

npm install

שימוש ב-ADC בזמן הרצה מקומית

אם אתם מריצים את הפקודה ב-Cloud Shell, אתם כבר מריצים אותה במכונה וירטואלית של Google Compute Engine. פרטי הכניסה שמשויכים למכונה הווירטואלית הזו (כפי שמוצג בהרצת הפקודה gcloud auth list) ישמשו אוטומטית את Application Default Credentials, כך שלא צריך להשתמש בפקודה gcloud auth application-default login. אפשר לדלג לקטע יצירת סוד מקומי לסשן

אבל אם אתם מריצים את הפקודה במסוף המקומי (כלומר לא ב-Cloud Shell), תצטרכו להשתמש ב-Application Default Credentials כדי לבצע אימות ל-Google APIs. אתם יכולים 1) להתחבר באמצעות פרטי הכניסה שלכם (בתנאי שיש לכם את התפקידים 'משתמש Vertex AI' ו'משתמש Datastore') או 2) להתחבר באמצעות התחזות לחשבון השירות שבו נעשה שימוש ב-codelab הזה.

אפשרות 1) שימוש בפרטי הכניסה שלכם ל-ADC

אם רוצים להשתמש באמצעי האימות, אפשר קודם להריץ את הפקודה gcloud auth list כדי לבדוק איך מתבצע האימות ב-gcloud. לאחר מכן, יכול להיות שתצטרכו להקצות לזהות שלכם את התפקיד 'משתמש ב-Vertex AI'. אם לזהות שלכם יש תפקיד של בעלים, כבר יש לכם את תפקיד המשתמש הזה ב-Vertex AI. אם לא, אפשר להריץ את הפקודה הזו כדי להעניק לזהות שלכם את תפקיד המשתמש ב-Vertex AI ואת תפקיד המשתמש במאגר הנתונים.

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

לאחר מכן מריצים את הפקודה הבאה

gcloud auth application-default login

אפשרות 2) התחזות לחשבון שירות ל-ADC

אם רוצים להשתמש בחשבון השירות שנוצר ב-codelab הזה, לחשבון המשתמש צריך להיות מוקצה התפקיד 'יצירת אסימונים בחשבון שירות'. כדי לקבל את התפקיד הזה, מריצים את הפקודה הבאה:

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

בשלב הבא, מריצים את הפקודה הבאה כדי להשתמש ב-ADC עם חשבון השירות

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

יצירת סוד לסשן מקומי

עכשיו יוצרים סוד מקומי לסשן לצורך פיתוח מקומי.

export SESSION_SECRET=local-secret

הפעלת האפליקציה באופן מקומי

לבסוף, מריצים את הסקריפט הבא כדי להפעיל את האפליקציה. הסקריפט הזה ייצור גם את הקובץ output.css מ-tailwindCSS.

npm run dev

כדי לראות תצוגה מקדימה של האתר, פותחים את הלחצן Web Preview (תצוגה מקדימה של אתר) ובוחרים באפשרות Preview Port 8080 (תצוגה מקדימה של יציאה 8080).

web preview - preview on port 8080 button

8. פריסת השירות

קודם מריצים את הפקודה הזו כדי להתחיל את הפריסה ולציין את חשבון השירות שבו רוצים להשתמש. אם לא מציינים חשבון שירות, המערכת משתמשת בחשבון השירות שמוגדר כברירת מחדל בשירות Compute.

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

אם מוצגת ההודעה 'פריסה ממקור דורשת מאגר Docker ב-Artifact Registry לאחסון קונטיינרים שנבנו. ייווצר מאגר בשם [cloud-run-source-deploy] באזור [us-central1].", מקישים על 'y' כדי לאשר ולהמשיך.

9. בדיקת השירות

אחרי הפריסה, פותחים את כתובת ה-URL של השירות בדפדפן האינטרנט. אחר כך שואלים את Gemini שאלה, למשל: "אני מתאמן בגיטרה אבל אני גם מהנדס תוכנה. כשאני רואה את הסימן C#, האם אני צריך להתייחס אליו כשפת תכנות או כתו מוזיקלי? באיזה מהם כדאי לבחור?"

10. מעולה!

כל הכבוד, סיימתם את ה-Codelab!

מומלץ לעיין במסמכי התיעוד בנושא Cloud Run ו-Vertex AI Gemini APIs.

מה נכלל

  • איך משתמשים ב-htmx, ב-tailwindcss וב-express.js כדי ליצור שירות Cloud Run
  • איך משתמשים בספריות הלקוח של Vertex AI כדי לבצע אימות ל-Google APIs
  • איך יוצרים צ'אטבוט לאינטראקציה עם מודל Gemini
  • איך פורסים לשירות Cloud Run בלי קובץ Docker
  • איך משתמשים במאגר סשנים של Express שמגובה על ידי Google Cloud Firestore

11. הסרת המשאבים

כדי להימנע מחיובים לא מכוונים (לדוגמה, אם שירותי Cloud Run מופעלים בטעות יותר פעמים מההקצאה החודשית של הפעלות Cloud Run בתוכנית בחינם), אפשר למחוק את Cloud Run או את הפרויקט שיצרתם בשלב 2.

כדי למחוק את שירות Cloud Run, עוברים אל Cloud Run Cloud Console בכתובת https://console.cloud.google.com/run ומוחקים את השירות chat-with-gemini. כדי למנוע קריאות לא מכוונות ל-Gemini, מומלץ גם למחוק את חשבון השירות vertex-ai-caller או לבטל את התפקיד 'משתמש ב-Vertex AI'.

אם אתם רוצים למחוק את הפרויקט כולו, אתם יכולים להיכנס לכתובת https://console.cloud.google.com/cloud-resource-manager, לבחור את הפרויקט שיצרתם בשלב 2 וללחוץ על 'מחיקה'. אם תמחקו את הפרויקט, תצטרכו לשנות את הפרויקטים ב-Cloud SDK. כדי לראות את רשימת כל הפרויקטים הזמינים, מריצים את הפקודה gcloud projects list.