銷售訂單自動化的生成式 AI 虛擬服務專員

1. 總覽

上次更新時間:2024 年 10 月 18 日

作者:Sanggyu Lee (sanggyulee@google.com)

建構項目

在本程式碼研究室中,您將為零售業客戶建構生成式 AI 代理程式

您的應用程式將會:

  • 這項功能適用於行動裝置或電腦。
  • 你可以拍攝商品相片,然後透過語音對話下單。
  • 應用程式的運作方式如下:只要拍攝商品相片,然後說出「我要訂購這個商品,3 盒。我是 Walmart Honolulu 的經理。」應用程式會將相片上傳至 Cloud Storage,並轉錄語音錄音內容。這項資訊隨即會傳送至 Vertex AI 上的 Gemini 模型,該模型會識別商品和商店 (檀香山沃爾瑪)。如果要求符合銷售訂單條件,系統會產生具有專屬 ID 的銷售訂單。

c8333d8139d8764c.png

2. 學習內容

課程內容

  • 如何使用 Vertex AI 建立 AI 代理
  • 如何透過 Speech-to-Text API 服務傳送音訊並接收文字轉錄內容
  • 如何在 Cloud Run 上部署 AI 代理

本程式碼研究室著重於使用 Gemini 的生成式 AI 代理程式應用程式。我們不會對與主題無關的概念和程式碼多做介紹,但會事先準備好這些程式碼區塊,屆時您只要複製及貼上即可。

軟硬體需求

  • Google Cloud 帳戶
  • 熟悉 Python、JavaScript 和 Google Cloud

架構

b21e2a3deedb60ec.png

這個代理程式可透過 Gemini 的多模態功能,使用圖片和文字提示簡化訂餐流程。如果以語音下單,Google Speech 的 Chirp 2 模型會將語音轉錄為文字,然後連同提供的圖片,一併用於查詢 Vertex AI 的 Gemini 模型。

我們將建構:

  1. 建立開發環境
  2. 使用者可透過行動裝置或電腦呼叫的 Flask 應用程式。應用程式會在 Cloud Run 上執行。

3. 設定和需求

自修實驗室環境設定

  1. 登入 Google Cloud 控制台,然後建立新專案或重複使用現有專案。如果沒有 Gmail 或 Google Workspace 帳戶,請先建立帳戶

fbef9caa1602edd0.png

97bdebccea2ba4be.png

3e14a8a504bb53ce.png

  • 專案名稱是專案參與者的顯示名稱。這是 Google API 未使用的字元字串。你隨時可以更新。
  • 專案 ID 在所有 Google Cloud 專案中不得重複,且設定後即無法變更。Cloud 控制台會自動產生專屬字串,通常您不需要理會該字串。在大多數程式碼研究室中,您需要參照專案 ID (通常會標示為 PROJECT_ID)。如果不喜歡系統產生的 ID,可以產生另一個隨機 ID。你也可以嘗試使用自己的名稱,看看是否可用。完成這個步驟後就無法變更,且專案期間會維持不變。
  • 請注意,部分 API 會使用第三個值,也就是專案編號。如要進一步瞭解這三種值,請參閱說明文件
  1. 接著,您需要在 Cloud 控制台中啟用帳單,才能使用 Cloud 資源/API。完成這個程式碼研究室的費用不高,甚至可能完全免費。如要關閉資源,避免在本教學課程結束後繼續產生費用,請刪除您建立的資源或專案。Google Cloud 新使用者可參加價值$300 美元的免費試用計畫。

啟動 Cloud Shell

雖然可以透過筆電遠端操作 Google Cloud,但在本程式碼研究室中,您將使用 Google Cloud Shell,這是可在雲端執行的指令列環境。

Google Cloud 控制台中,點選右上工具列的 Cloud Shell 圖示:

55efc1aaa7a4d3ad.png

佈建並連線至環境的作業需要一些時間才能完成。完成後,您應該會看到如下的內容:

7ffe5cbb04455448.png

這部虛擬機器搭載各種您需要的開發工具,並提供永久的 5GB 主目錄,而且可在 Google Cloud 運作,大幅提升網路效能並強化驗證功能。您可以在瀏覽器中完成本程式碼研究室的所有作業。您不需要安裝任何軟體。

4. 事前準備

啟用 API

啟用實驗室所需的 API。這項作業會在幾分鐘內完成。

gcloud services enable \
  run.googleapis.com \
  cloudbuild.googleapis.com \
  aiplatform.googleapis.com \
  speech.googleapis.com \
  sqladmin.googleapis.com \
  logging.googleapis.com \
  compute.googleapis.com \
  servicenetworking.googleapis.com \
  monitoring.googleapis.com

預期的控制台輸出內容:

Operation "operations/acf.p2-639929424533-ffa3a09b-7663-4b31-8f78-5872bf4ad778" finished successfully.

設定環境

CLI 指令會先設定 Google Cloud 環境的參數。

export PROJECT_ID="<YOUR_PROJECT_ID>"
export VPC_NAME="<YOUR_VPC_NAME>" e.g : demonetwork
export SUBNET_NAME="<YOUR_SUBNET_NAME>" e.g : genai-subnet
export REGION="<YOUR_REGION>" e.g : us-central1
export GENAI_BUCKET="<YOUR BUCKET FOR AGENT>" # eg> genai-${PROJECT_ID}

For example :

export PROJECT_ID=$(gcloud config get-value project)
export VPC_NAME="demonetwork" 
export SUBNET_NAME="genai-subnet" 
export REGION="us-central1" 
export GENAI_BUCKET="genai-${PROJECT_ID}" 

5. 建構基礎架構

為應用程式建立網路

為應用程式建立虛擬私有雲。如要建立名為「demonetwork」的虛擬私有雲,請執行下列指令:

gcloud compute networks create demonetwork \
    --subnet-mode custom

如要在「demonetwork」網路中建立位址範圍為 10.10.0.0/24 的「genai-subnet」子網路,請執行下列指令:

gcloud compute networks subnets create genai-subnet \
    --network demonetwork \
    --region us-central1 \
    --range 10.10.0.0/24

建立 PostgreSQL 適用的 Cloud SQL

分配給私人服務存取權的 IP 位址範圍。

gcloud compute addresses create google-managed-services-my-network \
    --global \
    --purpose=VPC_PEERING \
    --prefix-length=16 \
    --description="peering range for Google" \
    --network=demonetwork

建立私人連線。

gcloud services vpc-peerings connect \
    --service=servicenetworking.googleapis.com \
    --ranges=google-managed-services-my-network \
    --network=demonetwork

執行 gcloud sql instances create 指令,建立 Cloud SQL 執行個體。

gcloud sql instances create sql-retail-genai \
  --database-version POSTGRES_14 \
  --tier db-f1-micro \
  --region=$REGION \
  --project=$PROJECT_ID \
  --network=projects/${PROJECT_ID}/global/networks/${VPC_NAME} \
  --no-assign-ip \
  --enable-google-private-path

這個指令可能需要幾分鐘才能完成。

預期的控制台輸出內容:

Created [https://sqladmin.googleapis.com/sql/v1beta4/projects/evident-trees-438609-q3/instances/sql-retail-genai].
NAME: sql-retail-genai
DATABASE_VERSION: POSTGRES_14
LOCATION: us-central1-c
TIER: db-f1-micro
PRIMARY_ADDRESS: -
PRIVATE_ADDRESS: 10.66.0.3
STATUS: RUNNABLE

為應用程式和使用者建立資料庫

執行 gcloud sql databases create 指令,在 sql-retail-genai 中建立 Cloud SQL 資料庫。

gcloud sql databases create retail-orders \
  --instance sql-retail-genai

建立 PostgreSQL 資料庫使用者,建議您變更密碼。

gcloud sql users create aiagent --instance sql-retail-genai --password "genaiaigent2@"

建立用來儲存圖片的值區

為代理程式建立私有值區

gsutil mb -l $REGION gs://$GENAI_BUCKET

更新 Bucket 權限

gsutil iam ch serviceAccount:<your service account>: roles/storage.objectUser gs://$GENAI_BUCKET

如果假設使用預設的 Compute 服務帳戶:

gsutil iam ch serviceAccount:$(gcloud projects describe $PROJECT_ID --format="value(projectNumber)")-compute@developer.gserviceaccount.com:roles/storage.objectUser gs://$GENAI_BUCKET

6. 準備應用程式的程式碼

準備代碼

訂單網頁應用程式是使用 Flask 建構而成,可在行動裝置或電腦的網頁瀏覽器中執行。這項功能會存取連線裝置的麥克風和相機,並使用 Google Speech 的 Chirp 2 模型和 Vertex AI 的 Gemini Pro 1.5 模型。訂單結果會儲存在 Cloud SQL 資料庫中。

如果您使用上一頁提供的範例環境變數名稱,可以直接使用下列程式碼,不必修改。如果您自訂了環境變數名稱,則須在程式碼中相應變更部分變數值。

建立下列兩個目錄。

mkdir -p genai-agent/templates

建立 requirements.txt

vi ~/genai-agent/requirements.txt

在文字檔中輸入套件清單。

aiofiles==24.1.0
aiohappyeyeballs==2.4.3
aiohttp==3.10.9
aiosignal==1.3.1
annotated-types==0.7.0
asn1crypto==1.5.1
attrs==24.2.0
blinker==1.8.2
cachetools==5.5.0
certifi==2024.8.30
cffi==1.17.1
charset-normalizer==3.3.2
click==8.1.7
cloud-sql-python-connector==1.12.1
cryptography==43.0.1
docstring_parser==0.16
Flask==3.0.3
frozenlist==1.4.1
google-api-core==2.20.0
google-auth==2.35.0
google-cloud-aiplatform==1.69.0
google-cloud-bigquery==3.26.0
google-cloud-core==2.4.1
google-cloud-resource-manager==1.12.5
google-cloud-speech==2.27.0
google-cloud-storage==2.18.2
google-crc32c==1.6.0
google-resumable-media==2.7.2
googleapis-common-protos==1.65.0
greenlet==3.1.1
grpc-google-iam-v1==0.13.1
grpcio==1.66.2
grpcio-status==1.66.2
idna==3.10
itsdangerous==2.2.0
Jinja2==3.1.4
MarkupSafe==3.0.0
multidict==6.1.0
numpy==2.1.2
packaging==24.1
pg8000==1.31.2
pgvector==0.3.5
proto-plus==1.24.0
protobuf==5.28.2
pyasn1==0.6.1
pyasn1_modules==0.4.1
pycparser==2.22
pydantic==2.9.2
pydantic_core==2.23.4
python-dateutil==2.9.0.post0
requests==2.32.3
rsa==4.9
scramp==1.4.5
shapely==2.0.6
six==1.16.0
SQLAlchemy==2.0.35
typing_extensions==4.12.2
urllib3==2.2.3
Werkzeug==3.0.4
yarl==1.13.1

建立 main.py

vi ~/genai-agent/main.py

在 main.py 檔案中輸入 Python 程式碼。

from flask import Flask, render_template, request, jsonify, Response
import os
import base64
from google.api_core.client_options import ClientOptions
from google.cloud.speech_v2 import SpeechClient
from google.cloud.speech_v2.types import cloud_speech

import vertexai
from vertexai.generative_models import GenerativeModel, Part, SafetySetting
from google.cloud import storage
import uuid  # Import the uuid module
from typing import Dict  # Add this import
import datetime
import json
import re

import os
from google.cloud.sql.connector import Connector
import pg8000
import sqlalchemy
from sqlalchemy import create_engine, text

app = Flask(__name__)

# Replace with your actual project ID
project_id = os.environ.get("PROJECT_ID")

# Use a connection pool to reuse connections and improve performance
# This also handles connection lifecycle management automatically
engine = None

# Configure Google Cloud Storage
storage_client = storage.Client()
bucket_name = os.environ.get("GENAI_BUCKET")  
client = SpeechClient(
    client_options=ClientOptions(
        api_endpoint="us-central1-speech.googleapis.com",
    ),
)

def get_engine():
    global engine  # Use global to access/modify the global engine variable
    if engine is None:  # Create the engine only once
        connector = Connector()

        def getconn() -> pg8000.dbapi.Connection:
            conn: pg8000.dbapi.Connection = connector.connect(
                os.environ["INSTANCE_CONNECTION_NAME"],  # Cloud SQL instance connection name
                "pg8000",
                user=os.environ["DB_USER"],
                password=os.environ["DB_PASS"],
                db=os.environ["DB_NAME"],
                ip_type="PRIVATE",
            )
            return conn

        engine = create_engine(
            "postgresql+pg8000://",
            creator=getconn,
            pool_pre_ping=True,  # Check connection validity before use
            pool_size=5,  # Adjust pool size as needed
            max_overflow=2, #  Allow some overflow for bursts
            pool_recycle=300, #  Recycle connections after 5 minutes
        )
    return engine

def migrate_db() -> None:
    engine = get_engine()  # Get the engine (creates it if necessary)
    with engine.begin() as conn:
        sql = """
            CREATE TABLE IF NOT EXISTS image_sales_orders (
                order_id SERIAL PRIMARY KEY,
                vendor_name VARCHAR(80) NOT NULL,
                order_item VARCHAR(100) NOT NULL,
                order_boxes INT NOT NULL,  
                time_cast TIMESTAMP NOT NULL
            );
        """
        conn.execute(text(sql))


@app.before_request
def init_db():
    migrate_db()
    #print("Migration complete.")

@app.route('/')
def index():
    return render_template('index.html')

@app.route('/orderlist')
def orderlist():
    engine = get_engine()
    with engine.connect() as conn:
        sql = text("""
            SELECT order_id, vendor_name, order_item, order_boxes, time_cast
            FROM image_sales_orders
            ORDER BY time_cast DESC
        """)
        result = conn.execute(sql).mappings()  # Use .mappings() for dict-like access
        orders = []
        for row in result:
            order = {
                'OrderId': row['order_id'],
                'VendorName': row['vendor_name'],
                'OrderItem': row['order_item'],
                'OrderBoxes': row['order_boxes'],
                'OrderDate': row['time_cast'].strftime('%Y-%m-%d'),
                'OrderTime': row['time_cast'].strftime('%H:%M:%S'),
            }
            orders.append(order)
    return render_template('orderlist.html', orders=orders)

@app.route("/upload_photo", methods=["POST"])
def upload_photo():
    # Get the uploaded file
    file = request.files["photo"]

    # Generate a unique filename
    filename = f"{uuid.uuid4()}--{file.filename}"

    # Upload the file to Google Cloud Storage
    bucket = storage_client.get_bucket(bucket_name)
    blob = bucket.blob(filename)
    generation_match_precondition = 0
    blob.upload_from_file(file, if_generation_match=generation_match_precondition)

    # Return the destination filename
    image_url = f"gs://{bucket_name}/{filename}"

    # Return the destination filename
    return image_url

@app.route('/upload', methods=['POST'])
def upload():
    audio_data = request.form['audio_data']
    audio_data = base64.b64decode(audio_data.split(',')[1])

    audio_path = f"{uuid.uuid4()}--audio.wav" 

    with open(audio_path, 'wb') as f:
        f.write(audio_data)

    transcript = transcribe_speech(audio_path)
    os.remove(audio_path)
    return jsonify({'transcript': transcript})

@app.route("/orders", methods=["POST"])
def cast_order() -> Response:
    prompt = request.form['transcript']
    image_url = request.form['image_url']
    print(f"Prompt: {prompt}")
    print(f"Image URL: {image_url}")

    model_response = generate(image_url=image_url, prompt=prompt)
    # Extract the text content from the model response
    response_text = model_response.text if hasattr(model_response, 'text') else str(model_response)

    #print(f"Response from Model !!!!!!: {response_text}")

    try:
        response_json = json.loads(response_text)
        function_name = response_json.get("function")
        parameters = response_json.get("parameters")

    except json.JSONDecodeError as e:
        logging.error(f"JSON decoding error: {e}")
        return Response(
            "I cannot fulfill your request because I cannot find the [Product Name], [Quantity (Box)], and [Retail Store Name] in the provided image and prompt.",
            status=500
        )

    if function_name == 'Z_SALES_ORDER_SRV/orderlistSet':
        engine = get_engine()
        with engine.connect() as conn:
            try:
                # Explicitly convert order_boxes to integer
                order_boxes = int(parameters["order_boxes"])
                vendor_name = parameters["vendor_name"]
                order_item = parameters["order_item"]

                # Prepare the SQL statement
                sql = text("""
                    INSERT INTO image_sales_orders (vendor_name, order_item, order_boxes, time_cast)
                    VALUES (:vendor_name, :order_item, :order_boxes, NOW())
                """)

                # Prepare parameters
                params = {
                    "vendor_name": vendor_name,
                    "order_item": order_item,
                    "order_boxes": order_boxes,
                }

                # Execute the SQL statement with parameters
                conn.execute(sql, params)
                conn.commit()

                response_message = f"Dear [{vendor_name}],\n\nYour order has been completed as follows. \n\nItem Name : {order_item}\nQTY(Boxes) : {order_boxes}\n\nThanks."
                return Response(response_message, status=200)

            except (KeyError, ValueError) as e:
                logging.error(f"Error inserting into database: {e}")
                response_message = "Error processing your order. Please check the input data."
                return Response(response_message, status=500)

    else:
        # Handle other function names if necessary
        return Response("Unknown function.", status=400)


def transcribe_speech(audio_file):
    with open(audio_file, "rb") as f:
        content = f.read()

    config = cloud_speech.RecognitionConfig(
        auto_decoding_config=cloud_speech.AutoDetectDecodingConfig(),
        language_codes=["auto"],
        #language_codes=["ko-KR"],    -- In case that needs to choose specific language 
        model="chirp_2",
    )

    request = cloud_speech.RecognizeRequest(
        recognizer=f"projects/{project_id}/locations/us-central1/recognizers/_",
        config=config,
        content=content,
    )

    response = client.recognize(request=request)

    transcript = ""
    for result in response.results:
        transcript += result.alternatives[0].transcript

    return transcript

if __name__ == '__main__':
    app.run(debug=True, host="0.0.0.0", port=int(os.environ.get("PORT", 8080)))
    #app.run(debug=True)

def generate(image_url,prompt):
    vertexai.init(project=project_id, location="us-central1")
    model = GenerativeModel("gemini-1.5-pro-002")
    image1 = Part.from_uri(uri=image_url, mime_type="image/jpeg")

    prompt_default = """A retail store will give you an image with order details as an Input. You will identify the order details and provide an output as the following json format. You should not add any comment on it. The Box quantity should be arabic number. You can extract the item name from a given image or prompt. However, you should extract the retail store name or the quantity from only the text prompt but not the given image. All parameter values are strings. Don't assume any parameters. Do not wrap the json codes in JSON markers.

{\"function\":\"Z_SALES_ORDER_SRV/orderlistSet\",\"parameters\":{\"vendor_name\":Retail store name,\"order_item\":Item name,\"order_boxes\":Box quantity}}

If you are not clear on any parameter, provide the output as follows.
{\"function\":\"None\"}

You should not use the json markdown for the result.

Input :"""

    generation_config = {
        "max_output_tokens": 8192,
        "temperature": 0,
        "top_p": 0.95,
    }

    safety_settings = [
        SafetySetting(
            category=SafetySetting.HarmCategory.HARM_CATEGORY_HATE_SPEECH,
            threshold=SafetySetting.HarmBlockThreshold.OFF
        ),
        SafetySetting(
            category=SafetySetting.HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
            threshold=SafetySetting.HarmBlockThreshold.OFF
        ),
        SafetySetting(
            category=SafetySetting.HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT,
            threshold=SafetySetting.HarmBlockThreshold.OFF
        ),
        SafetySetting(
            category=SafetySetting.HarmCategory.HARM_CATEGORY_HARASSMENT,
            threshold=SafetySetting.HarmBlockThreshold.OFF
        ),
    ]

    responses = model.generate_content(
        [prompt_default, image1, prompt],
        generation_config=generation_config,
        safety_settings=safety_settings,
        stream=True,
    )

    response = ""
    for content in responses:
       response += content.text
       print(f"Content: {content}")
       print(f"Content type: {type(content)}")
       print(f"Content attributes: {dir(content)}")

    print(f"response_texts={response}")

    if response.startswith('json'):
       return clean_json_string(response)
    else:
       return response

def clean_json_string(json_string):
    pattern = r'^```json\s*(.*?)\s*```$'
    cleaned_string = re.sub(pattern, r'\1', json_string, flags=re.DOTALL)
    return cleaned_string.strip()

建立 index.html

vi ~/genai-agent/templates/index.html

在 index.html 檔案中輸入 HTML 程式碼。

<!DOCTYPE html>
<html>
<head>
    <title>GenAI Agent for Retail</title>
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <style>
        /* Styles adjusted for chatbot interface */
        body {
            font-family: Arial, sans-serif;
            background-color: #343541;
            margin: 0;
            padding: 0;
            display: flex;
            flex-direction: column;
            height: 100vh;
        }

        .chat-container {
            flex: 1;
            overflow-y: auto;
            padding: 10px;
            background-color: #343541;
        }

        .message {
            max-width: 80%;
            margin-bottom: 15px;
            padding: 10px;
            border-radius: 10px;
            color: #dcdcdc;
            word-wrap: break-word;
        }

        .user-message {
            background-color: #3e3f4b;
            align-self: flex-end;
        }

        .assistant-message {
            background-color: #444654;
            align-self: flex-start;
        }

        .message-input {
            padding: 10px;
            background-color: #40414f;
            display: flex;
            align-items: center;
        }

        .message-input textarea {
            flex: 1;
            padding: 10px;
            border: none;
            border-radius: 5px;
            resize: none;
            background-color: #40414f;
            color: #dcdcdc;
            height: 40px;
            max-height: 100px;
            overflow-y: auto;
        }

        .message-input button {
            padding: 15px;
            margin-left: 5px;
            background-color: #19c37d;
            border: none;
            border-radius: 5px;
            color: white;
            font-weight: bold;
            cursor: pointer;
            flex-shrink: 0;
        }

        .image-preview {
            max-width: 100%;
            border-radius: 10px;
            margin-bottom: 10px;
        }

        .hidden {
            display: none;
        }

        /* Media queries for responsive design */
        @media screen and (max-width: 600px) {
            .message {
                max-width: 100%;
            }

            .message-input {
                flex-direction: column;
            }

            .message-input textarea {
                width: 100%;
                margin-bottom: 10px;
            }

            .message-input button {
                width: 100%;
                margin: 5px 0;
            }
        }
    </style>
</head>
<body>
    <div class="chat-container" id="chat-container">
        <!-- Messages will be appended here -->
    </div>

    <div class="message-input">
        <input type="file" name="photo" id="photo" accept="image/*" capture="camera" class="hidden">
        <button id="uploadImageButton">📷</button>
        <button id="recordButton">🎤</button>
        <textarea id="transcript" rows="1" placeholder="Enter a message here by voice or typing..."></textarea>
        <button id="sendButton">Send</button>
    </div>

    <script>
        const chatContainer = document.getElementById('chat-container');
        const transcriptInput = document.getElementById('transcript');
        const sendButton = document.getElementById('sendButton');
        const recordButton = document.getElementById('recordButton');
        const uploadImageButton = document.getElementById('uploadImageButton');
        const photoInput = document.getElementById('photo');

        let mediaRecorder;
        let audioChunks = [];
        let imageUrl = '';

        function appendMessage(content, sender) {
            const messageDiv = document.createElement('div');
            messageDiv.classList.add('message', sender === 'user' ? 'user-message' : 'assistant-message');

            if (typeof content === 'string') {
                const messageContent = document.createElement('p');
                messageContent.innerText = content;
                messageDiv.appendChild(messageContent);
            } else {
                messageDiv.appendChild(content);
            }

            chatContainer.appendChild(messageDiv);
            chatContainer.scrollTop = chatContainer.scrollHeight;
        }

        sendButton.addEventListener('click', () => {
            const message = transcriptInput.value.trim();
            if (message !== '') {
                appendMessage(message, 'user');

                // Prepare form data
                const formData = new FormData();
                formData.append('transcript', message);
                formData.append('image_url', imageUrl);

                // Send the message to the server
                fetch('/orders', {
                    method: 'POST',
                    body: formData
                })
                .then(response => response.text())
                .then(data => {
                    appendMessage(data, 'assistant');
                    // Reset imageUrl after sending
                    imageUrl = '';
                })
                .catch(error => {
                    console.error('Error:', error);
                });

                transcriptInput.value = '';
            }
        });

        transcriptInput.addEventListener('keypress', (e) => {
            if (e.key === 'Enter' && !e.shiftKey) {
                e.preventDefault();
                sendButton.click();
            }
        });

        recordButton.addEventListener('click', async () => {
            if (mediaRecorder && mediaRecorder.state === 'recording') {
                mediaRecorder.stop();
                recordButton.innerText = '🎤';
                return;
            }

            let stream = await navigator.mediaDevices.getUserMedia({ audio: true });
            mediaRecorder = new MediaRecorder(stream);
            mediaRecorder.start();
            recordButton.innerText = '⏹️';

            mediaRecorder.ondataavailable = event => {
                audioChunks.push(event.data);
            };

            mediaRecorder.onstop = async () => {
                let audioBlob = new Blob(audioChunks, { type: 'audio/wav' });
                audioChunks = [];

                let reader = new FileReader();
                reader.readAsDataURL(audioBlob);
                reader.onloadend = () => {
                    let base64String = reader.result;

                    // Send the audio data to the server
                    fetch('/upload', {
                        method: 'POST',
                        headers: {
                            'Content-Type': 'application/x-www-form-urlencoded'
                        },
                        body: 'audio_data=' + encodeURIComponent(base64String)
                    })
                    .then(response => response.json())
                    .then(data => {
                        transcriptInput.value = data.transcript;
                    })
                    .catch(error => {
                        console.error('Error:', error);
                    });
                };
            };
        });

        uploadImageButton.addEventListener('click', () => {
            photoInput.click();
        });

        photoInput.addEventListener('change', function() {
            if (photoInput.files && photoInput.files[0]) {
                const file = photoInput.files[0];
                const reader = new FileReader();
                reader.onload = function(e) {
                    const img = document.createElement('img');
                    img.src = e.target.result;
                    img.classList.add('image-preview');
                    appendMessage(img, 'user');
                };
                reader.readAsDataURL(file);

                const formData = new FormData();
                formData.append('photo', photoInput.files[0]);

                // Upload the image to the server
                fetch('/upload_photo', {
                    method: 'POST',
                    body: formData,
                })
                .then(response => response.text())
                .then(url => {
                    imageUrl = url;
                })
                .catch(error => {
                    console.error('Error uploading photo:', error);
                });
            }
        });
    </script>
</body>
</html>

建立 orderlist.html

vi ~/genai-agent/templates/orderlist.html

在 orderlist.html 檔案中輸入 HTML 程式碼。

<!DOCTYPE html>
<html>
<head>
    <title>Order List</title>
    <style>
        body {
            font-family: sans-serif;
            line-height: 1.6;
            margin: 20px;
            background-color: #f4f4f4;
            color: #333;
        }

        h1 {
            text-align: center;
            color: #28a745; /* Green header */
        }

        table {
            width: 100%;
            border-collapse: collapse;
            margin-top: 20px;
            box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); /* Add a subtle shadow */
        }

        th, td {
            padding: 12px 15px;
            text-align: left;
            border-bottom: 1px solid #ddd;
        }

        th {
            background-color: #28a745; /* Green header background */
            color: white;
        }

        tr:nth-child(even) {
            background-color: #f8f9fa; /* Alternating row color */
        }

        tr:hover {
            background-color: #e9ecef; /* Hover effect */
        }

    </style>
</head>
<body>
    <h1>Order List</h1>
    <table>
        <thead>
            <tr>
                <th>Order ID</th>
                <th>Retail Store Name</th>
                <th>Order Item</th>
                <th>Order Boxes</th>
                <th>Order Date</th>
                <th>Order Time</th>
            </tr>
        </thead>
        <tbody>
            {% for order in orders %}
            <tr>
                <td>{{ order.OrderId }}</td>
                <td>{{ order.VendorName }}</td>
                <td>{{ order.OrderItem }}</td>
                <td>{{ order.OrderBoxes }}</td>
                <td>{{ order.OrderDate }}</td>
                <td>{{ order.OrderTime }}</td>
            </tr>
            {% endfor %}
        </tbody>
    </table>
</body>
</html>

7. 將 Flask 應用程式部署至 Cloud Run

從 genai-agent 目錄使用下列指令,將應用程式部署至 Cloud Run:

cd ~/genai-agent
gcloud run deploy --source . genai-agent-sales-order \
--set-env-vars=PROJECT_ID=$PROJECT_ID \
--set-env-vars=REGION=$REGION \
--set-env-vars=INSTANCE_CONNECTION_NAME="${PROJECT_ID}:${REGION}:sql-retail-genai" \
--set-env-vars=DB_USER=aiagent \
--set-env-vars=DB_PASS=genaiaigent2@ \
--set-env-vars=DB_NAME=retail-orders \
--set-env-vars=GENAI_BUCKET=$GENAI_BUCKET \
--network=$PROJECT_ID \
--subnet=$SUBNET_NAME \
--vpc-egress=private-ranges-only \
--region=$REGION \
--allow-unauthenticated

預期輸出內容:

Deploying from source requires an Artifact Registry Docker repository to store built containers. A repository named [cloud-run-source-deploy] in region [us-central1] will be created.

Do you want to continue (Y/n)?  Y

這項作業需要幾分鐘,如果順利完成,您會看到服務網址。

預期輸出內容:

..........
Building using Buildpacks and deploying container to Cloud Run service [genai-agent-sales-order] in project [xxxx] region [us-central1]
✓ Building and deploying... Done.                                                                                                                                                                                                                                                                                                                               
  ✓ Uploading sources...                                                                                                                                                                                                                                                                                                                                        
  ✓ Building Container... Logs are available at [https://console.cloud.google.com/cloud-build/builds/395d141c-2dcf-465d-acfb-f97831c448c3?project=xxxx].                                                                                                                                                                                                
  ✓ Creating Revision...                                                                                                                                                                                                                                                                                                                                        
  ✓ Routing traffic...                                                                                                                                                                                                                                                                                                                                          
  ✓ Setting IAM Policy...                                                                                                                                                                                                                                                                                                                                       
Done.                                                                                                                                                                                                                                                                                                                                                           
Service [genai-agent-sales-order] revision [genai-agent-sales-order-00013-ckp] has been deployed and is serving 100 percent of traffic.
Service URL: https://genai-agent-sales-order-xxxx.us-central1.run.app

您也可以在 Cloud Run 控制台中查看服務網址。

8. Test

  1. 在行動裝置或筆電中,輸入 Cloud Run Deployment 上一個步驟中產生的服務網址。
  2. 拍攝訂單中的商品相片,然後輸入訂單數量 (箱數) 和零售商店名稱 (可使用打字或語音輸入)。<ex> 「我要訂購這三盒。抱歉,是七個盒子。這是 Walmart Mountain View」
  3. 按一下「傳送」,並確認訂單是否已完成。
  4. 你可以在 {Service URL}/orderlist 查看訂單記錄

de0db1a08082c634.png

9. 恭喜

恭喜!您已建構生成式 AI 代理,能夠運用 Vertex AI 多模態 Gemini 自動執行業務程序。

我們很期待您修改提示,並根據特定需求調整代理程式。