یک نماینده شخصی را در سرویس Cloud Run اجرا کنید (دستیار مدیر کافی‌شاپ)

۱. مقدمه

نمای کلی

در این آزمایشگاه کد، شما یک دستیار هوش مصنوعی شخصی خواهید ساخت که به شما در تجزیه و تحلیل داده‌های تجاری و انجام سایر وظایف از طریق رابط کاربری چت کمک می‌کند. شما از یک سرویس Cloud Run برای میزبانی از نماینده شخصی خود استفاده خواهید کرد.

عامل شما از سندباکس‌های Cloud Run استفاده خواهد کرد. سندباکس‌های Cloud Run یک محیط اجرایی بومی، امن و فوق‌العاده سریع هستند که به‌طور خاص برای اجرای کدهای غیرقابل اعتماد و بارهای کاری عامل، با سرعت شروع از میلی‌ثانیه، ساخته شده‌اند. این سندباکس به عامل هوش مصنوعی شما اجازه می‌دهد تا به‌طور پویا کد را بنویسد، اجرا کند و در لحظه آزمایش کند تا مسائل تحلیلی پیچیده را حل کند.

نکته: برای اطمینان از یک تجربه توسعه یکپارچه هنگام اجرای محلی در مقابل اجرای در محیط عملیاتی:

  • در محیط عملیاتی (Cloud Run sandbox): عامل، کد را به طور ایمن در یک محیط ایزوله و کانتینر شده از طریق یک فایل باینری sandbox اختصاصی (/usr/local/gcp/bin/sandbox) اجرا می‌کند.
  • محلی (دستگاه شما): هنگام اجرای محلی، برنامه تشخیص می‌دهد که محیط sandbox در محیط عملیاتی وجود ندارد (IS_LOCAL_MODE = True). عامل، اسکریپت‌های پایتون و دستورات shell را مستقیماً روی ترمینال سیستم دستگاه میزبان محلی شما اجرا می‌کند.

آنچه خواهید ساخت:

در این سناریو، شما یک کافی‌شاپ را در یک شهر دانشگاهی مدیریت می‌کنید که برای یک آخر هفته‌ی بزرگ فارغ‌التحصیلی آماده می‌شود. شما به نماینده‌ی خود نیاز دارید تا داده‌های خام نقطه‌ی فروش (POS) را با برنامه‌ی مراسم دانشگاه تطبیق دهد تا تنگناهای عملیاتی پنهان را کشف کند.

این نماینده از یک محیط امن برای نوشتن و اجرای اسکریپت‌های پایتون استفاده می‌کند و پیچیدگی نوشیدنی‌ها را در مقابل تعداد کارکنان صندوقدار تجزیه و تحلیل می‌کند تا تنظیمات مربوط به کارکنان و موجودی را توصیه کند.

این عامل از طریق یک رابط کاربری چت ساختگی، مالک را با توصیه‌های مربوط به موجودی و کارکنان مورد نظرش پینگ می‌کند. قبل از به‌روزرسانی صفحه گسترده با TODOهای عملیاتی برای مدیر کافی‌شاپ، منتظر اجازه صریح او می‌ماند.

آنچه یاد خواهید گرفت

  • نحوه ایجاد سرویس Cloud Run
  • نحوه استقرار یک عامل ADK در سرویس Cloud Run
  • چگونه یک عامل را وادار کنیم که کد را در یک محیط سندباکس درون سرویس Cloud Run اجرا کند
  • نحوه ایجاد یک رابط کاربری چت با استفاده از WebSockets برای تعامل با عامل پس‌زمینه

۲. تنظیمات و الزامات

شناسه پروژه خود را تنظیم کنید.

GOOGLE_CLOUD_PROJECT=<YOUR_PROJECT_ID>

منطقه خود را تنظیم کنید.

REGION=<YOUR_REGION>

حالا Google Cloud را طوری پیکربندی کنید که از آن شناسه پروژه و Cloud Run را طوری پیکربندی کنید که از آن منطقه استفاده کند.

gcloud config set project $GOOGLE_CLOUD_PROJECT
gcloud config set run/region $REGION

در اینجا متغیرهای محیطی که در سراسر این آزمایشگاه کد استفاده خواهند شد، آورده شده است. می‌توانید این موارد را در یک فایل محیطی ذخیره کرده و آن را "source" کنید. مطمئن شوید که مقدار شناسه پروژه و در صورت تمایل، منطقه را به درستی تنظیم کرده‌اید.

SA_NAME=coffee-shop-agent-sa
SERVICE_ACCOUNT_ADDRESS=$SA_NAME@$GOOGLE_CLOUD_PROJECT.iam.gserviceaccount.com

فعال کردن API های مورد نیاز برای این Codelab

gcloud services enable --project $GOOGLE_CLOUD_PROJECT \
    run.googleapis.com \
    cloudbuild.googleapis.com \
    artifactregistry.googleapis.com \
    sheets.googleapis.com \
    aiplatform.googleapis.com

۳. ایجاد حساب کاربری سرویس و صفحه گسترده

ایجاد یک حساب کاربری اختصاصی برای سرویس Cloud Run شما توصیه می‌شود تا فقط نقش‌های ضروری را اعطا کنید.

ایجاد حساب کاربری سرویس برای سرویس Cloud Run

gcloud iam service-accounts create $SA_NAME \
  --description="Service account for the Coffee Shop Agent Codelab" \
  --display-name="Coffee Shop Agent SA"

از آنجایی که عامل از APIهای Gemini (GOOGLE_GENAI_USE_VERTEXAI=1) استفاده می‌کند، به این حساب سرویس، نقش کاربر پلتفرم عامل را در پروژه خود اعطا کنید.

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

برای اینکه بتوانید عامل را به صورت محلی با استفاده از این حساب سرویس اجرا و آزمایش کنید، به هویت شخصی Google Cloud خود اجازه دهید تا این حساب سرویس را جعل هویت کند:

gcloud iam service-accounts add-iam-policy-binding \
coffee-shop-agent-sa@$GOOGLE_CLOUD_PROJECT.iam.gserviceaccount.com \
--member="user:$(gcloud config get-value account)" \
--role="roles/iam.serviceAccountTokenCreator"

صفحه گسترده را ایجاد کنید

این جدول، فروش‌هایی را که سال گذشته در طول آخر هفته‌ی فارغ‌التحصیلی اتفاق افتاده است، نشان می‌دهد.

  1. یک گوگل شیت جدید در گوگل درایو خود ایجاد کنید.
  2. مقادیر جدا شده با کاما (CSV) زیر را در صفحه گوگل جدید خود کپی کنید (مثلاً سلول A1 را انتخاب کرده و جایگذاری کنید).
Day,Time,Drip_Coffee,Cold_Brew,Extra_Espresso,Alt_Milk_Oz,Pastries,Cashiers_Working,Wait_Time_Minutes
Saturday,07:00:00,95,15,5,30,80,2,9
Saturday,08:00:00,80,25,10,35,60,2,7
Saturday,09:00:00,30,30,20,40,20,1,4
Saturday,10:00:00,40,130,95,50,25,2,4
Saturday,11:00:00,25,45,25,40,15,1,5
Saturday,12:00:00,30,35,15,45,20,1,3
Saturday,13:00:00,15,20,10,30,10,1,2
Saturday,14:00:00,45,60,30,65,30,2,8
Saturday,15:00:00,20,30,15,35,15,1,3
Saturday,16:00:00,25,35,10,50,20,1,4
Saturday,17:00:00,10,20,5,35,10,1,2
Saturday,18:00:00,20,40,10,190,65,2,12
Saturday,19:00:00,10,15,5,40,15,1,2
Sunday,07:00:00,90,10,5,35,75,2,8
Sunday,08:00:00,75,20,10,40,65,2,6
Sunday,09:00:00,25,25,15,35,15,1,3
Sunday,10:00:00,60,35,15,60,50,2,7
Sunday,11:00:00,20,30,10,35,10,1,3
Sunday,12:00:00,25,40,10,55,20,1,3
Sunday,13:00:00,15,25,5,40,10,1,2
Sunday,14:00:00,30,50,15,180,60,2,11
Sunday,15:00:00,15,25,10,45,15,1,3
Sunday,16:00:00,20,45,20,40,15,1,4
Sunday,17:00:00,10,25,10,30,10,1,2
Sunday,18:00:00,25,145,110,45,20,2,16
Sunday,19:00:00,15,30,15,35,10,1,4
  1. در حالی که داده‌هایتان هنوز انتخاب شده است، در منوی بالای Google Sheets روی Data > Split text to columns کلیک کنید.

مطمئن شوید که حساب کاربری سرویس، دسترسی ویرایشگر به صفحه گسترده را دارد. این مشابه نحوه‌ی اعطای دسترسی به هم‌تیمی شماست.

  1. ایمیل کامل حساب سرویس جدید خود را کپی کنید
echo $SERVICE_ACCOUNT_ADDRESS
  1. از صفحه گوگل خود، روی اشتراک‌گذاری در گوشه بالا سمت راست کلیک کنید
  2. ایمیل حساب سرویس را پیست کنید، مجوز را روی ویرایشگر تنظیم کنید و روی اشتراک‌گذاری کلیک کنید. (می‌توانید تیک گزینه «اطلاع‌رسانی» را بردارید).
  3. شناسه صفحه‌گسترده‌ای را که به نماینده خود منتقل خواهید کرد، ثبت کنید، مثلاً https://docs.google.com/spreadsheets/d/<YOUR_SPREADSHEET_ID>/edit?gid=0#gid=0
SPREADSHEET_ID=<THE SPREADHSEET ID FROM ITS URL>

۴. ایجاد ADK Agent

ابتدا، یک دایرکتوری برای کد خود ایجاد کنید.

mkdir coffee-mgr-agent && cd coffee-mgr-agent

یک فایل requirements.txt ایجاد کنید.

fastapi>=0.100.0
uvicorn>=0.22.0
google-adk>=1.27.1
google-auth
google-api-python-client

ایجاد یک Dockerfile

FROM python:3.11-slim

ENV PYTHONUNBUFFERED=1

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY main.py .

EXPOSE 8080

CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]

یک فایل main.py ایجاد کنید

import os
import asyncio
import subprocess
from pathlib import Path
from typing import List
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse
from pydantic import BaseModel

from google.adk.agents import LlmAgent as Agent
from google.adk.tools import FunctionTool
from google.adk.apps import App
from google.adk import Runner
from google.adk.sessions import InMemorySessionService
from google.genai import types

SANDBOX_CLI = '/usr/local/gcp/bin/sandbox'
IS_LOCAL_MODE = not Path(SANDBOX_CLI).exists()

active_connections: list[WebSocket] = []

SPREADSHEET_ID = os.environ.get("SPREADSHEET_ID")

def run_sandbox_process(args: list[str]):
    cmd = args[2:] if IS_LOCAL_MODE and args[:2] == ['do', '--'] else ([SANDBOX_CLI] + args if not IS_LOCAL_MODE else args)
    return subprocess.run(cmd, capture_output=True, text=True, timeout=10)

def execute_sandbox_command(command: str) -> str:
    """Executes arbitrary POSIX shell/bash commands inside sandbox."""
    mode = "LOCAL" if IS_LOCAL_MODE else "CLOUD RUN SANDBOX"
    print(f"[ADK Sandbox Tool] Starting {mode} shell run...")
    try:
        res = run_sandbox_process(['do', '--', '/bin/sh', '-c', command])
        if res.returncode != 0:
            return f"Execution Failed!\nExit Code: {res.returncode}\nStdout:\n{res.stdout}\nStderr:\n{res.stderr}"
        return res.stdout
    except Exception as err:
        return f"Internal Sandbox Tool Error: {str(err)}"

def get_sheets_service():
    """Initializes and returns the Google Sheets client service."""
    from google.auth import default
    from googleapiclient.discovery import build
    credentials, _ = default(scopes=[
        'https://www.googleapis.com/auth/spreadsheets',
        'https://www.googleapis.com/auth/cloud-platform'
    ])
    return build('sheets', 'v4', credentials=credentials)

def read_spreadsheet_values(spreadsheet_id: str, range_name: str) -> str:
    """Reads a range of cells from a Google Spreadsheet."""
    try:
        service = get_sheets_service()
        result = service.spreadsheets().values().get(spreadsheetId=spreadsheet_id, range=range_name).execute()
        rows = result.get('values', [])
        return str(rows) if rows else "No data found in the specified range."
    except Exception as e:
        return f"Read Error: {str(e)}"

def update_spreadsheet_values(spreadsheet_id: str, range_name: str, values: List[List[str]]) -> str:
    """Updates a range of cells in a Google Spreadsheet with the provided values."""
    try:
        service = get_sheets_service()
        result = service.spreadsheets().values().update(
            spreadsheetId=spreadsheet_id, range=range_name,
            valueInputOption="USER_ENTERED", body={'values': values}).execute()
        return f"Successfully updated {result.get('updatedCells')} cells in {range_name}."
    except Exception as e:
        return f"Write Error: {str(e)}"

def create_spreadsheet_tab(spreadsheet_id: str, tab_name: str) -> str:
    """Creates a new sheet tab in a Google Spreadsheet if it doesn't already exist."""
    try:
        service = get_sheets_service()
        # Check if tab exists
        spreadsheet = service.spreadsheets().get(spreadsheetId=spreadsheet_id).execute()
        for sheet in spreadsheet.get('sheets', []):
            if sheet.get('properties', {}).get('title') == tab_name:
                return f"Sheet tab '{tab_name}' already exists."
        # Create tab
        body = {'requests': [{'addSheet': {'properties': {'title': tab_name}}}]}
        service.spreadsheets().batchUpdate(spreadsheetId=spreadsheet_id, body=body).execute()
        return f"Successfully created new sheet tab '{tab_name}'."
    except Exception as e:
        return f"Error creating sheet tab: {str(e)}"

# ==========================================
# ADK AGENT & RUNNER SETUP
# ==========================================

root_agent = Agent(
    name='secure_coding_assistant',
    description='ADK agent capable of executing shell commands and managing Google Spreadsheets.',
    model=os.environ.get('GEMINI_MODEL', 'gemini-3.1-flash-lite'),
    instruction=(
        f'You are an expert AI Business Analyst for a coffee shop during university graduation weekend.\n'
        f'The Google Spreadsheet ID you are managing is: "{SPREADSHEET_ID}". Use this ID for all sheet operations.\n'
        '1. Comparative Analysis Policy:\n'
        f'   - Ingest historical POS data from the "POS-2025" sheet tab using read_spreadsheet_values with spreadsheet_id="{SPREADSHEET_ID}".\n'
        '   - Receive the current graduation schedule directly from the manager\'s prompt (the manager will paste it and indicate it is the same schedule sequence as last year).\n'
        '   - Write a python3 script via the sandbox tool to:\n'
        '     a. Correlate the 2025 product spikes (Cold Brew, Alt Milk, Extra Espresso) with the specific ceremonies ending at those times.\n'
        '     b. Map those beverage profiles to the pasted schedule (which is the same sequence) to predict exactly when and where the 2026 spikes will occur.\n'
        '     c. Identify expected wait-time bottlenecks in 2026 based on the 2025 wait times for those same profiles.\n'
        '2. Bottleneck Diagnostics (Playbook):\n'
        '   - If a predicted high-volume slot in 2026 is expected to have Wait_Time_Minutes > 10:\n'
        '     - If Cashiers_Working < 2: Recommend scheduling another cashier.\n'
        '     - If Cashiers_Working == 2 and complex items (Cold Brew, Extra Espresso, Alt Milk) spike: Deduce that the bottleneck is barista output, not cashiers. Recommend adding a "Support Barista" role to handle fulfillment.\n'
        '3. Human-in-the-Loop Policy:\n'
        '   - Present your detailed data discoveries, wait-time bottlenecks, and actionable recommendations (stocking and staffing changes) to the manager.\n'
        '   - Highlight only two or three findings for specific ceremonies.\n'
        '   - Frame your recommendations as a clean list of suggested tasks for the manager\'s TODO list.\n'
        '   - Explicitly ask: "Would you like me to add these tasks to your \'TODO-2026\' TODO list?"\n'
        '   - Do NOT modify any spreadsheet data until explicit approval is given.\n'
        '4. Post-Approval Policy:\n'
        f'   - Upon receiving explicit user approval, first verify if the "TODO-2026" sheet tab exists in spreadsheet "{SPREADSHEET_ID}".\n'
        f'   - If the "TODO-2026" sheet tab does not exist, use the tool create_spreadsheet_tab to create it in spreadsheet "{SPREADSHEET_ID}".\n'
        f'   - Once the tab exists, use update_spreadsheet_values to append the approved adjustments as tasks to the "TODO-2026" sheet tab.\n'
        '   - Write the rows under the headers: Task (the actionable job, e.g., "Schedule a Support Barista role for Saturday morning"), Category ("Staffing" or "Inventory"), Ceremony, and Date_Added (today\'s date).\n'
        '   - Always confirm to the user exactly what tasks you have written to their "TODO-2026" TODO list.'
    ),
    tools=[
        FunctionTool(func=execute_sandbox_command),
        FunctionTool(func=read_spreadsheet_values),
        FunctionTool(func=update_spreadsheet_values),
        FunctionTool(func=create_spreadsheet_tab)
    ]
)

adk_app = App(name="secure_sandbox_app", root_agent=root_agent)
runner = Runner(app=adk_app, session_service=InMemorySessionService(), auto_create_session=True)

app = FastAPI(title="Secure ADK Sandbox Assistant")

# ==========================================
# ENDPOINTS & WEBSOCKET ROUTING
# ==========================================

@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
    await websocket.accept()
    active_connections.append(websocket)
    await websocket.send_text("🔌 System: Connected. Agent is ready...")

    try:
        while True:
            owner_reply = await websocket.receive_text()
            print(f"Owner replied via WS: {owner_reply}")
            await websocket.send_text("_Agent is running tools and thinking..._")
            
            new_message = types.Content(parts=[types.Part(text=owner_reply)])
            events = await asyncio.to_thread(
                runner.run,
                user_id="local_user",
                session_id="local_session",
                new_message=new_message
            )
            
            final_response = "".join(
                part.text
                for event in events
                if event.content and event.content.parts
                for part in event.content.parts
                if part.text
            ) or "Agent completed execution updates without text output."
            
            await websocket.send_text(final_response.strip())
            
    except WebSocketDisconnect:
        active_connections.remove(websocket)

class UserPrompt(BaseModel):
    prompt: str

@app.post("/chat")
def chat_with_agent(payload: UserPrompt):
    """Fallback HTTP POST endpoint if UI is not used."""
    try:
        events = runner.run(
            user_id="local_user",
            session_id="local_session",
            new_message=types.Content(parts=[types.Part(text=payload.prompt)])
        )
        final_response = "".join(
            part.text
            for event in events
            if event.content and event.content.parts
            for part in event.content.parts
            if part.text
        )
        return {"status": "success", "response": final_response.strip()}
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Agent loop failed: {str(e)}")

@app.get("/", response_class=HTMLResponse)
async def get_chat_ui():
    """Serves the warm coffee-themed chat interface."""
    return """
    <!DOCTYPE html>
    <html>
    <head>
        <title>Coffee Shop Agent</title>
        <!-- Load marked.js for client-side markdown rendering -->
        <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
        <style>
            body { display: flex; height: 100vh; margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; }
            #sidebar { width: 250px; background: #3E2723; color: #EFEBE9; padding: 20px; }
            #sidebar h2 { font-size: 1.3em; margin-top: 10px; color: #FFF; border-bottom: 1px solid #5D4037; padding-bottom: 10px; }
            #main { flex-grow: 1; display: flex; flex-direction: column; background: #FAF8F6; }
            #chat-history { flex-grow: 1; padding: 20px; overflow-y: auto; background: #F5EFEB; }
            #input-area { padding: 20px; border-top: 1px solid #D7CCC8; background: #FAF8F6; display: flex;}
            input { 
                flex-grow: 1; 
                padding: 12px; 
                border-radius: 6px; 
                border: 1px solid #D7CCC8; 
                margin-right: 10px;
                font-size: 1em;
                background: #FFF;
                transition: border-color 0.2s, box-shadow 0.2s;
            }
            input:focus {
                outline: none;
                border-color: #8D6E63;
                box-shadow: 0 0 0 2px rgba(141, 110, 99, 0.25);
            }
            button { 
                padding: 10px 24px; 
                background: #6D4C41; 
                color: white; 
                border: none; 
                border-radius: 6px; 
                cursor: pointer; 
                font-weight: bold;
                font-size: 1em;
                transition: background-color 0.2s, transform 0.1s;
            }
            button:hover {
                background: #5D4037;
            }
            button:active {
                background: #4E342E;
                transform: scale(0.98);
            }
            .message { margin-bottom: 15px; padding: 12px 16px; border-radius: 8px; max-width: 85%; line-height: 1.5; }
            .user-msg { background: #EFEBE9; color: #3E2723; align-self: flex-end; margin-left: auto; border: 1px solid #D7CCC8;}
            .agent-msg { background: #fff; color: #3E2723; border: 1px solid #E0DCD8; box-shadow: 0 1px 3px rgba(62,39,35,0.06); }
            
            .msg-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
            .agent-name { font-weight: bold; color: #5D4037; margin: 0; font-size: 0.95em;}
            .user-name { font-weight: bold; color: #8D6E63; margin: 0; font-size: 0.95em;}
            .msg-timestamp { font-size: 0.8em; color: #8D6E63; font-weight: normal; }
            
            .day-divider {
                display: flex;
                align-items: center;
                text-align: center;
                color: #8D6E63;
                margin: 20px 0;
                font-size: 0.85em;
                font-weight: bold;
            }
            .day-divider::before, .day-divider::after {
                content: '';
                flex: 1;
                border-bottom: 1px solid #D7CCC8;
            }
            .day-divider:not(:empty)::before {
                margin-right: .5em;
            }
            .day-divider:not(:empty)::after {
                margin-left: .5em;
            }

            /* Markdown Styling inside Messages */
            .message p { margin: 4px 0 8px 0; }
            .message p:last-child { margin-bottom: 0; }
            .message ul, .message ol { margin: 4px 0 8px 0; padding-left: 20px; }
            .message li { margin-bottom: 3px; }
            .message h1, .message h2, .message h3, .message h4 { margin: 12px 0 6px 0; font-size: 1.15em; color: #3E2723; font-weight: bold; }
            .message h1:first-child, .message h2:first-child, .message h3:first-child { margin-top: 0; }
            
            /* Table Styling */
            .message table { border-collapse: collapse; width: 100%; margin: 10px 0; font-size: 0.95em; }
            .message th, .message td { border: 1px solid #D7CCC8; padding: 8px 10px; text-align: left; }
            .message th { background-color: #F5EFEB; font-weight: bold; color: #3E2723; }
            .message tr:nth-child(even) { background-color: #FAF8F6; }
            
            /* Code / Blockquote styling */
            .message code { background: #EFEBE9; padding: 2px 4px; border-radius: 3px; font-family: monospace; font-size: 0.9em; color: #5D4037; }
            .message pre { background: #F5EFEB; padding: 10px; border-radius: 5px; overflow-x: auto; margin: 8px 0; border: 1px solid #D7CCC8; }
            .message pre code { background: none; padding: 0; }
            .message blockquote { margin: 8px 0; padding-left: 12px; border-left: 4px solid #6D4C41; color: #5D4037; }
        </style>
    </head>
    <body>
        <div id="sidebar">
            <h2>☕ Coffee Shop Monitor</h2>
            <p>Monitoring sheet...</p>
        </div>
        <div id="main">
            <div id="chat-history"></div>
            <div id="input-area">
                <input type="text" id="msg" placeholder="Message Coffee Shop Monitor..." onkeypress="if(event.key === 'Enter') sendMessage()">
                <button onclick="sendMessage()">Send</button>
            </div>
        </div>

        <script>
            const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
            const ws = new WebSocket(`${protocol}//${window.location.host}/ws`);
            const history = document.getElementById('chat-history');

            // Scroll to the bottom on load to show latest messages
            history.scrollTop = history.scrollHeight;

            ws.onmessage = function(event) {
                // Parse markdown content from the agent
                const parsedHtml = marked.parse(event.data);
                const timeStr = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
                history.innerHTML += `
                    <div class="message agent-msg">
                        <div class="msg-header">
                            <span class="agent-name">Inventory Agent APP</span>
                            <span class="msg-timestamp">${timeStr}</span>
                        </div>
                        <div>${parsedHtml}</div>
                    </div>`;
                history.scrollTop = history.scrollHeight;
            };

            function sendMessage() {
                const input = document.getElementById('msg');
                const text = input.value;
                if (!text) return;
                
                // Parse user markdown if any (optional, but keeps styling consistent)
                const parsedHtml = marked.parse(text);
                const timeStr = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
                history.innerHTML += `
                    <div class="message user-msg">
                        <div class="msg-header">
                            <span class="user-name">You</span>
                            <span class="msg-timestamp">${timeStr}</span>
                        </div>
                        <div>${parsedHtml}</div>
                    </div>`;
                
                ws.send(text);
                input.value = '';
                history.scrollTop = history.scrollHeight;
            }
        </script>
    </body>
    </html>
    """

if __name__ == "__main__":
    import uvicorn
    port_val = int(os.environ.get('PORT', 8080))
    uvicorn.run(app, host='0.0.0.0', port=port_val)

۵. سرویس Cloud Run را مستقر کنید

به جای ساختن یک ایمیج، از بسته‌های ساخت گوگل کلود برای تبدیل کد منبع برنامه خود به یک ایمیج کانتینر آماده برای اجرا در کلود ران استفاده خواهید کرد.

gcloud beta run deploy coffee-mgr-agent \
    --source=. \
    --region=$REGION \
    --sandbox-launcher \
    --max-instances=1 \
    --session-affinity \
    --allow-unauthenticated \
    --no-cpu-throttling \
    --labels dev-tutorial=codelab-cloud-run-personal-agent-coffee-shop \
    --set-env-vars GOOGLE_GENAI_USE_VERTEXAI=1,GOOGLE_CLOUD_PROJECT=$GOOGLE_CLOUD_PROJECT,GOOGLE_CLOUD_LOCATION=global,SPREADSHEET_ID=$SPREADSHEET_ID \
    --service-account $SERVICE_ACCOUNT_ADDRESS

۶. با نماینده خود چت کنید

پس از استقرار، URL: https://coffee-mgr-agent-YOUR_PROJECT_ID.YOUR_REGION.run.app

این آدرس اینترنتی را در مرورگر خود باز کنید.

پیام زیر را برای نماینده خود ارسال کنید.

The 2026 graduation schedule was just posted. It's the same schedule as last year. Can you review last year's POS data to help me prepare for this year?
Saturday, June 13:
College of Business (8:30 a.m.)
College of Science and Mathematics (12:30 p.m.)
College of Liberal Arts (4:30 p.m.)
Sunday, June 14:
College of Agriculture (8:30 a.m.)
College of Architecture (12:30 p.m.)
College of Engineering (4:30 p.m.)

نماینده شما باید با چیزی شبیه به توصیه‌های زیر پاسخ دهد:

Based on the 2025 POS data, I have correlated graduation ceremony patterns with beverage demand spikes. Last year, the post-ceremony windows consistently experienced high demand for complex items (Cold Brew, Extra Espresso, and Alt Milk).

Key Data Discoveries for 2026

- College of Business (Saturday 8:30 a.m. ceremony):
  - Significant demand spike observed at 10:00 a.m. Saturday.
  - Data: 130 Cold Brews and 95 Extra Espresso shots. This is a primary bottleneck for barista output.
- College of Engineering (Sunday 4:30 p.m. ceremony):
  - Significant demand spike observed at 6:00 p.m. Sunday.
  - Data: 145 Cold Brews and 110 Extra Espresso shots. This is the highest intensity period of the weekend.
- Alt-Milk Surge (Saturday/Sunday 2:00 p.m. post-ceremonies):
  - Consistent, high-volume Alt Milk consumption (65-180 oz) after the 12:30 p.m. ceremonies.

Diagnostic & Staffing Recommendations

Given that these complex beverage items (Cold Brew, Extra Espresso) correlate directly with your high-traffic post-ceremony windows, the risk of hitting wait times > 10 minutes is high. Assuming you typically have 2 cashiers, my diagnostic is that these are barista output bottlenecks, not cashier bottlenecks.

Suggested Tasks for your TODO-2026 List:

1. Staffing: Schedule a dedicated "Support Barista" for Saturday 9:30 a.m. – 11:00 a.m. to handle the Cold Brew/Espresso rush.
2. Staffing: Schedule a dedicated "Support Barista" for Sunday 5:30 p.m. – 7:00 p.m. to manage the high volume after the Engineering ceremony.
3. Inventory: Pre-stage extra Alt Milk cartons at the mobile station for Saturday 1:30 p.m. and Sunday 1:30 p.m. shifts.

Would you like me to add these tasks to your 'TODO-2026' TODO list?

سپس نماینده شما از شما (به عنوان نیروی انسانی در حلقه) اجازه ایجاد/به‌روزرسانی برگه تب TODO-2026 شما را خواهد گرفت.

با Yes به نماینده خود پاسخ دهید.

حالا وقتی به برگه‌ی تب TODO-2026 خود نگاه می‌کنید، چیزی شبیه به تصویر زیر خواهید دید:

Task	Category	Ceremony	Date_Added
Schedule a Support Barista role for Saturday morning rush	Staffing	College of Business	2025-05-22
Schedule a Support Barista role for Sunday evening rush	Staffing	College of Engineering	2025-05-22
Pre-stage extra Alt Milk cartons at mobile station	Inventory	College of Science/Math & Architecture	2025-05-22

۷. تبریک می‌گویم!

تبریک می‌گویم که آزمایشگاه کد را تمام کردید!

توصیه می‌کنیم مستندات Cloud Run ، مستندات Cloud Run sandboxes و مستندات ADK را بررسی کنید.

آنچه ما پوشش داده‌ایم

  • نحوه ایجاد سرویس Cloud Run
  • نحوه استقرار یک عامل ADK در سرویس Cloud Run
  • چگونه یک عامل را وادار کنیم که کد را در یک محیط سندباکس درون سرویس Cloud Run اجرا کند

۸. تمیز کردن

برای جلوگیری از تحمیل هزینه به حساب گوگل کلود خود برای منابع استفاده شده در این آموزش، می‌توانید پروژه یا منابع تکی را حذف کنید.

گزینه ۱: حذف منابع

در اینجا خلاصه‌ای از مراحل انجام پروژه و جلوگیری از هزینه‌های غیرضروری ارائه شده است.

سرویس Cloud Run را حذف کنید

gcloud run services delete coffee-mgr-agent --region $REGION

حذف حساب سرویس: حساب سرویس صفحه گسترده اختصاصی را حذف کنید

gcloud iam service-accounts delete $SERVICE_ACCOUNT_ADDRESS

گزینه ۲: حذف پروژه

برای حذف کل پروژه، به مدیریت منابع بروید، پروژه‌ای را که در مرحله ۲ ایجاد کرده‌اید انتخاب کنید و حذف را انتخاب کنید. اگر پروژه را حذف کنید، باید پروژه‌ها را در Cloud SDK خود تغییر دهید. می‌توانید با اجرای gcloud projects list لیست تمام پروژه‌های موجود را مشاهده کنید. اگر می‌خواهید از خط فرمان استفاده کنید، می‌توانید از این دستور نیز استفاده کنید:

gcloud projects delete ${GOOGLE_CLOUD_PROJECT}