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

1. מבוא

סקירה כללית

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

מה תלמדו

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

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

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

הפעלת Cloud Shell

  1. במסוף Cloud, לוחצים על 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. לוחצים על Confirm Plan (אישור התוכנית) כדי לאשר את תוכנית החיוב של Firebase.
  6. לא חובה להפעיל את Google Analytics ב-Codelab הזה.
  7. לוחצים על הוספת Firebase.
  8. לאחר יצירת הפרויקט, לוחצים על Continue.
  9. בתפריט Build, לוחצים על Firestore repository.
  10. לוחצים על Create dataset.
  11. בוחרים את האזור שלכם מהתפריט הנפתח מיקום ולוחצים על הבא.
  12. משתמשים באפשרות ברירת המחדל Start inProduction mode ואז לוחצים על Create.

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=-

ומעניקים לחשבון השירות גישה לסוד הסשן האקספרס ב-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

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

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 User. אם לזהות שלכם יש תפקיד 'בעלים', כבר יש לכם את תפקיד המשתמש הזה ב-Vertex AI. אם לא, תוכלו להריץ את הפקודה הזו כדי להקצות לזהות שלכם את תפקיד המשתמש ב-Vertex AI ואת התפקיד 'משתמש ב-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

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

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

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

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

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 וב-exp.js כדי ליצור שירות Cloud Run
  • איך משתמשים בספריות הלקוח של Vertex AI כדי לבצע אימות ל-Google APIs
  • איך יוצרים צ'אט בוט לאינטראקציה עם המודל של Gemini
  • איך לפרוס בשירות שפועל בענן ללא קובץ Docer
  • איך משתמשים במאגר סשנים אקספרס שמגובה על ידי Google Cloud Firestore

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

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

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

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