chore: import upstream snapshot with attribution
Continuous Integration / Pre-commit Linter (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.10) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.11) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.12) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.12) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.14) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Has been cancelled
Copybara PR Handler / close-imported-pr (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Has been cancelled
Continuous Integration / Pre-commit Linter (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.10) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.11) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.12) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.12) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.14) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Has been cancelled
Copybara PR Handler / close-imported-pr (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
# GCP Auth Sample
|
||||
|
||||
## Overview
|
||||
|
||||
Demonstrates the use of Agent Identity auth manager with an agent that queries
|
||||
Spotify and Google Maps using auth providers.
|
||||
|
||||
Use `adk web` to run API key and 2-legged oauth flows, while use the included
|
||||
custom agent web client to run 3-legged oauth flows.
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Activate environment
|
||||
|
||||
```bash
|
||||
cd adk-python
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
```
|
||||
|
||||
### 2. Install dependencies
|
||||
|
||||
```bash
|
||||
pip install "google-adk[agent-identity]"
|
||||
```
|
||||
|
||||
### 3. Authenticate your environment
|
||||
|
||||
```bash
|
||||
gcloud auth application-default login
|
||||
export GOOGLE_CLOUD_PROJECT="YOUR_GOOGLE_CLOUD_PROJECT"
|
||||
gcloud auth application-default set-quota-project $GOOGLE_CLOUD_PROJECT
|
||||
```
|
||||
|
||||
### 4. Create auth providers
|
||||
|
||||
Refer to the [public documentation](https://cloud.google.com/iam/docs/manage-auth-providers) to create the following Agent Identity auth providers.
|
||||
|
||||
> **Note:**
|
||||
> The identity running the agent (via Application Default Credentials) must have
|
||||
> the necessary [permissions](https://docs.cloud.google.com/iam/docs/roles-permissions/iamconnectors#iamconnectors.user)
|
||||
> to retrieve credentials from these connectors. Ensure your account has the
|
||||
> necessary role to access these resources.
|
||||
|
||||
```bash
|
||||
export GOOGLE_CLOUD_LOCATION="YOUR_GOOGLE_CLOUD_LOCATION"
|
||||
export MAPS_API_AUTH_PROVIDER_ID="YOUR_MAPS_API_AUTH_PROVIDER_ID"
|
||||
export SPOTIFY_2LO_AUTH_PROVIDER_ID="YOUR_SPOTIFY_2LO_AUTH_PROVIDER_ID"
|
||||
export SPOTIFY_3LO_AUTH_PROVIDER_ID="YOUR_SPOTIFY_3LO_AUTH_PROVIDER_ID"
|
||||
|
||||
gcloud alpha agent-identity connectors create $MAPS_API_AUTH_PROVIDER_ID \
|
||||
--project=$GOOGLE_CLOUD_PROJECT \
|
||||
--location=$GOOGLE_CLOUD_LOCATION \
|
||||
--api-key=YOUR_API_KEY
|
||||
|
||||
gcloud alpha agent-identity connectors create $SPOTIFY_2LO_AUTH_PROVIDER_ID \
|
||||
--project=$GOOGLE_CLOUD_PROJECT \
|
||||
--location=$GOOGLE_CLOUD_LOCATION \
|
||||
--two-legged-oauth-client-id=OAUTH_CLIENT_ID \
|
||||
--two-legged-oauth-client-secret=OAUTH_CLIENT_SECRET \
|
||||
--two-legged-oauth-token-endpoint=OAUTH_TOKEN_ENDPOINT
|
||||
|
||||
gcloud alpha agent-identity connectors create $SPOTIFY_3LO_AUTH_PROVIDER_ID \
|
||||
--project=$GOOGLE_CLOUD_PROJECT \
|
||||
--location=$GOOGLE_CLOUD_LOCATION \
|
||||
--three-legged-oauth-client-id=OAUTH_CLIENT_ID \
|
||||
--three-legged-oauth-client-secret=OAUTH_CLIENT_SECRET \
|
||||
--three-legged-oauth-authorization-url=AUTHORIZATION_URL \
|
||||
--three-legged-oauth-token-url=TOKEN_URL \
|
||||
--allowed-scopes=ALLOWED_SCOPES
|
||||
```
|
||||
|
||||
## Sample Inputs
|
||||
|
||||
- `What is the current weather in New York?`
|
||||
|
||||
*Tests the API key auth provider using the Google Maps tool.*
|
||||
|
||||
- `Tell me about the song: Waving Flag`
|
||||
|
||||
*Tests the 2-legged OAuth (2LO) auth provider using the Spotify search track
|
||||
tool.*
|
||||
|
||||
- `Get my private playlists`
|
||||
|
||||
*Tests the 3-legged OAuth (3LO) auth provider using the custom web client and
|
||||
the Spotify get playlists tool.*
|
||||
|
||||
## How To
|
||||
|
||||
### 1. Register the GCP Auth Provider
|
||||
|
||||
Register the Agent Identity authentication provider with the credential manager
|
||||
so it can resolve GCP auth provider connector schemes.
|
||||
|
||||
```python
|
||||
CredentialManager.register_auth_provider(GcpAuthProvider())
|
||||
```
|
||||
|
||||
### 2. Configure 2-Legged OAuth (2LO)
|
||||
|
||||
Define an `AuthConfig` utilizing `GcpAuthProviderScheme` pointing to the 2LO
|
||||
connector resource name. Attach it to an `AuthenticatedFunctionTool`.
|
||||
|
||||
```python
|
||||
spotify_auth_config_2lo = AuthConfig(
|
||||
auth_scheme=GcpAuthProviderScheme(name=SPOTIFY_2LO_AUTH_PROVIDER)
|
||||
)
|
||||
spotify_search_track_tool = AuthenticatedFunctionTool(
|
||||
func=spotify_search_track,
|
||||
auth_config=spotify_auth_config_2lo,
|
||||
)
|
||||
```
|
||||
|
||||
See https://docs.cloud.google.com/iam/docs/auth-with-2lo for more details.
|
||||
|
||||
### 3. Configure 3-Legged OAuth (3LO)
|
||||
|
||||
For interactive user authorization flows, configure `GcpAuthProviderScheme` with
|
||||
required `scopes` and a `continue_uri` where the OAuth callback will redirect
|
||||
upon completion.
|
||||
|
||||
```python
|
||||
spotify_auth_config_3lo = AuthConfig(
|
||||
auth_scheme=GcpAuthProviderScheme(
|
||||
name=SPOTIFY_3LO_AUTH_PROVIDER,
|
||||
scopes=["playlist-read-private"],
|
||||
continue_uri=CONTINUE_URI,
|
||||
)
|
||||
)
|
||||
spotify_get_playlist_tool = AuthenticatedFunctionTool(
|
||||
func=spotify_get_playlists,
|
||||
auth_config=spotify_auth_config_3lo,
|
||||
)
|
||||
```
|
||||
|
||||
See https://docs.cloud.google.com/iam/docs/auth-with-3lo for more details.
|
||||
|
||||
### 4. Configure Auth for MCP Toolsets
|
||||
|
||||
When utilizing an `McpToolset`, supply the `auth_scheme` directly to enable
|
||||
automatic authentication (such as API key injection) during MCP server
|
||||
communication.
|
||||
|
||||
```python
|
||||
maps_tools = McpToolset(
|
||||
connection_params=StreamableHTTPConnectionParams(url=MAPS_MCP_ENDPOINT),
|
||||
auth_scheme=GcpAuthProviderScheme(name=MAPS_API_AUTH_PROVIDER),
|
||||
errlog=None, # Required for agent-freezing (pickling)
|
||||
)
|
||||
```
|
||||
|
||||
## Testing the Sample
|
||||
|
||||
### 1. Test API key and 2LO auth provider using ADK web client
|
||||
|
||||
```bash
|
||||
adk web contributing/samples
|
||||
```
|
||||
|
||||
- On the ADK web UI, select the agent named `gcp_auth` from the dropdown.
|
||||
- Try the sample queries from the **Sample Inputs** section for API key
|
||||
(Google Maps) and 2LO (Spotify).
|
||||
|
||||
### 2. Test 3LO auth provider using custom web client
|
||||
|
||||
- Navigate to the client directory and install dependencies:
|
||||
|
||||
```bash
|
||||
cd contributing/samples/integrations/gcp_auth/client
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
- Start the client application:
|
||||
|
||||
```bash
|
||||
uvicorn main:app --port 8080 --reload
|
||||
```
|
||||
|
||||
- Open `http://localhost:8080`. (**Note:** You must use `localhost` and not
|
||||
`127.0.0.1`, as the OAuth redirect URL specifically requires it.)
|
||||
- In the sidebar, configure your GCP Project ID and Location, click "Load Remote
|
||||
Agents", choose an engine to query, and click "Save & Apply Settings".
|
||||
- Try the 3LO sample query to fetch private playlists.
|
||||
@@ -0,0 +1,175 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from google.adk.agents import Agent
|
||||
from google.adk.apps import App
|
||||
from google.adk.auth.auth_credential import AuthCredential
|
||||
from google.adk.auth.auth_tool import AuthConfig
|
||||
from google.adk.auth.credential_manager import CredentialManager
|
||||
from google.adk.integrations.agent_identity import GcpAuthProvider
|
||||
from google.adk.integrations.agent_identity import GcpAuthProviderScheme
|
||||
from google.adk.tools.authenticated_function_tool import AuthenticatedFunctionTool
|
||||
from google.adk.tools.mcp_tool.mcp_session_manager import StreamableHTTPConnectionParams
|
||||
from google.adk.tools.mcp_tool.mcp_toolset import McpToolset
|
||||
import httpx
|
||||
|
||||
PROJECT_ID = os.environ.get("GOOGLE_CLOUD_PROJECT")
|
||||
LOCATION = os.environ.get("GOOGLE_CLOUD_LOCATION")
|
||||
MAPS_API_AUTH_PROVIDER_ID = os.environ.get("MAPS_API_AUTH_PROVIDER_ID")
|
||||
SPOTIFY_2LO_AUTH_PROVIDER_ID = os.environ.get("SPOTIFY_2LO_AUTH_PROVIDER_ID")
|
||||
SPOTIFY_3LO_AUTH_PROVIDER_ID = os.environ.get("SPOTIFY_3LO_AUTH_PROVIDER_ID")
|
||||
|
||||
MAPS_API_AUTH_PROVIDER = (
|
||||
f"projects/{PROJECT_ID}/locations/{LOCATION}/connectors/"
|
||||
f"{MAPS_API_AUTH_PROVIDER_ID}"
|
||||
)
|
||||
SPOTIFY_2LO_AUTH_PROVIDER = (
|
||||
f"projects/{PROJECT_ID}/locations/{LOCATION}/connectors/"
|
||||
f"{SPOTIFY_2LO_AUTH_PROVIDER_ID}"
|
||||
)
|
||||
SPOTIFY_3LO_AUTH_PROVIDER = (
|
||||
f"projects/{PROJECT_ID}/locations/{LOCATION}/connectors/"
|
||||
f"{SPOTIFY_3LO_AUTH_PROVIDER_ID}"
|
||||
)
|
||||
|
||||
MAPS_MCP_ENDPOINT = "https://mapstools.googleapis.com/mcp"
|
||||
CONTINUE_URI = "http://localhost:8080/commit"
|
||||
MODEL = "gemini-2.5-flash"
|
||||
|
||||
|
||||
async def spotify_search_track(
|
||||
credential: AuthCredential, query: str
|
||||
) -> str | list:
|
||||
"""Searches for a track on Spotify and returns its details."""
|
||||
headers = {}
|
||||
if http := credential.http:
|
||||
if http.scheme and http.credentials and (token := http.credentials.token):
|
||||
headers["Authorization"] = f"{http.scheme.title()} {token}"
|
||||
if http.additional_headers:
|
||||
headers.update(http.additional_headers)
|
||||
|
||||
if not headers:
|
||||
return "Error: No authentication token available."
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
"https://api.spotify.com/v1/search",
|
||||
headers=headers,
|
||||
params={"q": query, "type": "track", "limit": 1},
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
return f"Error from Spotify API: {response.status_code} - {response.text}"
|
||||
|
||||
data = response.json()
|
||||
items = data.get("tracks", {}).get("items", [])
|
||||
|
||||
if not items:
|
||||
return f"No track found for query '{query}'."
|
||||
|
||||
return items
|
||||
|
||||
|
||||
async def spotify_get_playlists(credential: AuthCredential) -> str | list:
|
||||
"""Fetches the current user's private playlists on Spotify."""
|
||||
headers = {}
|
||||
if http := credential.http:
|
||||
if http.scheme and http.credentials and (token := http.credentials.token):
|
||||
headers["Authorization"] = f"{http.scheme.title()} {token}"
|
||||
if http.additional_headers:
|
||||
headers.update(http.additional_headers)
|
||||
|
||||
if not headers:
|
||||
return "Error: No authentication token available."
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
"https://api.spotify.com/v1/me/playlists",
|
||||
headers=headers,
|
||||
params={"limit": 10},
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
return f"Error from Spotify API: {response.status_code} - {response.text}"
|
||||
|
||||
data = response.json()
|
||||
items = data.get("items", [])
|
||||
|
||||
if not items:
|
||||
return "No playlists found for the current user."
|
||||
|
||||
# Extract useful information
|
||||
return [
|
||||
{
|
||||
"name": item.get("name"),
|
||||
"public": item.get("public"),
|
||||
"total_tracks": item.get("tracks", {}).get("total"),
|
||||
}
|
||||
for item in items
|
||||
if item
|
||||
]
|
||||
|
||||
|
||||
spotify_auth_config_2lo = AuthConfig(
|
||||
auth_scheme=GcpAuthProviderScheme(name=SPOTIFY_2LO_AUTH_PROVIDER)
|
||||
)
|
||||
spotify_search_track_tool = AuthenticatedFunctionTool(
|
||||
func=spotify_search_track,
|
||||
auth_config=spotify_auth_config_2lo,
|
||||
)
|
||||
|
||||
spotify_auth_config_3lo = AuthConfig(
|
||||
auth_scheme=GcpAuthProviderScheme(
|
||||
name=SPOTIFY_3LO_AUTH_PROVIDER,
|
||||
scopes=["playlist-read-private"],
|
||||
continue_uri=CONTINUE_URI,
|
||||
)
|
||||
)
|
||||
spotify_get_playlist_tool = AuthenticatedFunctionTool(
|
||||
func=spotify_get_playlists,
|
||||
auth_config=spotify_auth_config_3lo,
|
||||
)
|
||||
|
||||
maps_tools = McpToolset(
|
||||
connection_params=StreamableHTTPConnectionParams(url=MAPS_MCP_ENDPOINT),
|
||||
auth_scheme=GcpAuthProviderScheme(name=MAPS_API_AUTH_PROVIDER),
|
||||
errlog=None, # Required for agent freezing (pickling)
|
||||
)
|
||||
|
||||
CredentialManager.register_auth_provider(GcpAuthProvider())
|
||||
|
||||
root_agent = Agent(
|
||||
name="gcp_auth_agent",
|
||||
model=MODEL,
|
||||
instruction=(
|
||||
"You are a Spotify and Google Maps assistant. Use your tools to "
|
||||
"search for track details, fetch the user's private playlists, "
|
||||
"and look up locations. Keep responses concise, friendly, and "
|
||||
"emoji-filled!"
|
||||
),
|
||||
tools=[
|
||||
spotify_search_track_tool,
|
||||
spotify_get_playlist_tool,
|
||||
maps_tools,
|
||||
],
|
||||
)
|
||||
|
||||
app = App(
|
||||
name="gcp_auth",
|
||||
root_agent=root_agent,
|
||||
)
|
||||
@@ -0,0 +1,421 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""A FastAPI client for interacting with ADK remote agents and handling GCP authentication."""
|
||||
|
||||
import base64
|
||||
import importlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
from typing import Optional
|
||||
import uuid
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import Request
|
||||
from fastapi import Response
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.responses import StreamingResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from google.adk.auth import AuthConfig
|
||||
import google.auth
|
||||
import google.auth.transport.requests
|
||||
from google.genai import types
|
||||
import httpx
|
||||
from pydantic import BaseModel
|
||||
import uvicorn
|
||||
import vertexai
|
||||
|
||||
TARGET_HOST = (
|
||||
os.environ.get("IAM_CONNECTOR_CREDENTIALS_TARGET_HOST")
|
||||
or "iamconnectorcredentials.googleapis.com"
|
||||
)
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
# Mount static files
|
||||
try:
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
print("Successfully mounted /static")
|
||||
except Exception as e:
|
||||
print(f"Error mounting /static: {e}")
|
||||
|
||||
|
||||
# Serve the index page for the root path
|
||||
@app.get("/")
|
||||
async def get_index():
|
||||
try:
|
||||
return FileResponse("static/index.html")
|
||||
except Exception as e:
|
||||
print(f"Error serving static/index.html: {e}")
|
||||
return {"error": str(e)}, 500
|
||||
|
||||
|
||||
# List remote agents in the given project and location.
|
||||
@app.get("/list_agents")
|
||||
async def list_remote_agents(project_id: str, location: str):
|
||||
try:
|
||||
client = vertexai.Client(
|
||||
project=project_id,
|
||||
location=location,
|
||||
)
|
||||
agents = client.agent_engines.list()
|
||||
agent_list = []
|
||||
for agent in agents:
|
||||
name_parts = agent.api_resource.name.split("/")
|
||||
agent_id = name_parts[-1] if len(name_parts) > 0 else ""
|
||||
|
||||
agent_list.append({
|
||||
"id": agent_id,
|
||||
"name": agent.api_resource.display_name,
|
||||
"full_name": agent.api_resource.name,
|
||||
})
|
||||
return {"agents": agent_list}
|
||||
except Exception as e:
|
||||
print(f"Error listing agents: {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
# Helper function to extract the auth URI and nonce from the auth config
|
||||
def handle_adk_request_credential(auth_config):
|
||||
if (
|
||||
auth_config.exchanged_auth_credential
|
||||
and auth_config.exchanged_auth_credential.oauth2
|
||||
):
|
||||
oauth2 = auth_config.exchanged_auth_credential.oauth2
|
||||
return oauth2.auth_uri, oauth2.nonce
|
||||
return None, None
|
||||
|
||||
|
||||
try:
|
||||
_, default_project = google.auth.default()
|
||||
except Exception:
|
||||
default_project = ""
|
||||
|
||||
|
||||
class ChatRequest(BaseModel):
|
||||
message: str = ""
|
||||
agent_type: str = "remote"
|
||||
local_agent: str = ""
|
||||
project_id: str = os.environ.get(
|
||||
"GOOGLE_CLOUD_PROJECT", default_project or ""
|
||||
)
|
||||
location: str = os.environ.get("GOOGLE_CLOUD_LOCATION", "")
|
||||
agent_id: str = os.environ.get("AGENT_ID", "")
|
||||
user_id: str = "default_user_id"
|
||||
session_id: Optional[str] = None
|
||||
is_auth_resume: Optional[bool] = False
|
||||
auth_config: Optional[dict] = None
|
||||
auth_request_function_call_id: Optional[str] = None
|
||||
|
||||
|
||||
# Endpoint for querying the agent.
|
||||
@app.post("/chat")
|
||||
async def chat(request: ChatRequest, response: Response):
|
||||
session_id = request.session_id or str(uuid.uuid4())
|
||||
current_agent = None
|
||||
client = None
|
||||
|
||||
client = vertexai.Client(
|
||||
project=request.project_id,
|
||||
location=request.location,
|
||||
)
|
||||
remote_agent_name = (
|
||||
f"projects/{request.project_id}/locations/{request.location}"
|
||||
f"/reasoningEngines/{request.agent_id}"
|
||||
)
|
||||
try:
|
||||
current_agent = client.agent_engines.get(name=remote_agent_name)
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
tb_str = traceback.format_exc()
|
||||
err_str = str(e)
|
||||
|
||||
async def error_generator():
|
||||
err_data = {
|
||||
"error": f"Failed to load remote agent: {err_str}",
|
||||
"traceback": tb_str,
|
||||
}
|
||||
yield f"data: {json.dumps(err_data)}\n\n"
|
||||
|
||||
return StreamingResponse(error_generator(), media_type="text/event-stream")
|
||||
|
||||
if not request.session_id and current_agent:
|
||||
try:
|
||||
if hasattr(current_agent, "async_create_session"):
|
||||
print(f"DEBUG: Creating async session for {request.user_id}")
|
||||
session_obj = await current_agent.async_create_session(
|
||||
user_id=request.user_id
|
||||
)
|
||||
else:
|
||||
session_obj = current_agent.create_session(user_id=request.user_id)
|
||||
session_id = (
|
||||
session_obj.id
|
||||
if hasattr(session_obj, "id")
|
||||
else session_obj.get("id")
|
||||
)
|
||||
|
||||
client = vertexai.Client(
|
||||
project=request.project_id,
|
||||
location=request.location,
|
||||
)
|
||||
current_agent = client.agent_engines.get(name=remote_agent_name)
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
print(f"Failed to create session: {e}")
|
||||
tb_str = traceback.format_exc()
|
||||
err_str = str(e)
|
||||
|
||||
async def error_generator():
|
||||
err_data = {
|
||||
"error": f"Failed to create session: {err_str}",
|
||||
"traceback": tb_str,
|
||||
}
|
||||
yield f"data: {json.dumps(err_data)}\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
error_generator(), media_type="text/event-stream"
|
||||
)
|
||||
|
||||
response.set_cookie(
|
||||
key="session_id", value=session_id, httponly=True, samesite="lax"
|
||||
)
|
||||
print(f"Set session_id cookie: {session_id}")
|
||||
|
||||
def process_agent_event(event):
|
||||
# 1. Normalize the event object into a standard Python dictionary
|
||||
# representation.
|
||||
if hasattr(event, "model_dump"):
|
||||
if "mode" in event.model_dump.__code__.co_varnames:
|
||||
event_data = event.model_dump(mode="json")
|
||||
else:
|
||||
event_data = event.model_dump()
|
||||
elif hasattr(event, "dict"):
|
||||
event_data = event.dict()
|
||||
elif hasattr(event, "to_dict"):
|
||||
event_data = event.to_dict()
|
||||
elif isinstance(event, dict):
|
||||
event_data = event
|
||||
else:
|
||||
try:
|
||||
event_data = json.loads(json.dumps(event, default=lambda o: o.__dict__))
|
||||
except Exception:
|
||||
event_data = {"text": str(event)}
|
||||
|
||||
# 2. Extract message content and check for long-running tool calls.
|
||||
print(f"DEBUG: event_data: {event_data}")
|
||||
content = event_data.get("content", {})
|
||||
parts = content.get("parts", []) if isinstance(content, dict) else []
|
||||
long_running = event_data.get("long_running_tool_ids") or event_data.get(
|
||||
"longRunningToolIds", []
|
||||
)
|
||||
|
||||
# 3. Scan tool calls for the special 'adk_request_credential' wrapper tool.
|
||||
for part in parts:
|
||||
fc = (
|
||||
(part.get("function_call") or part.get("functionCall"))
|
||||
if isinstance(part, dict)
|
||||
else None
|
||||
)
|
||||
if fc and fc.get("name") == "adk_request_credential":
|
||||
fc_id = fc.get("id")
|
||||
if not long_running or fc_id in long_running:
|
||||
print("--> Authentication required by agent.")
|
||||
try:
|
||||
args = fc.get("args", {})
|
||||
cfg_data = args.get("authConfig") or args.get("auth_config")
|
||||
if cfg_data:
|
||||
# Parse auth configuration and extract OAuth URI/nonce for popup.
|
||||
if isinstance(cfg_data, dict):
|
||||
auth_config = AuthConfig.model_validate(cfg_data)
|
||||
else:
|
||||
auth_config = cfg_data
|
||||
auth_uri, consent_nonce = handle_adk_request_credential(
|
||||
auth_config
|
||||
)
|
||||
if auth_uri:
|
||||
event_data["popup_auth_uri"] = auth_uri
|
||||
event_data["auth_request_function_call_id"] = fc_id
|
||||
if hasattr(auth_config, "model_dump"):
|
||||
event_data["auth_config"] = auth_config.model_dump()
|
||||
elif hasattr(auth_config, "dict"):
|
||||
event_data["auth_config"] = auth_config.dict()
|
||||
else:
|
||||
event_data["auth_config"] = auth_config
|
||||
event_data["consent_nonce"] = consent_nonce
|
||||
except Exception as e:
|
||||
print(f"Error processing auth wrapper: {e}")
|
||||
break
|
||||
|
||||
return event_data
|
||||
|
||||
async def event_generator():
|
||||
# Keep vertexai Client alive during async streaming to prevent httpx client
|
||||
# from being closed by GC
|
||||
_ = client
|
||||
yield f"data: {json.dumps({'session_id': session_id})}\n\n"
|
||||
|
||||
message_to_send = request.message
|
||||
if (
|
||||
request.is_auth_resume
|
||||
and request.auth_request_function_call_id
|
||||
and request.auth_config
|
||||
):
|
||||
auth_content = types.Content(
|
||||
role="user",
|
||||
parts=[
|
||||
types.Part(
|
||||
function_response=types.FunctionResponse(
|
||||
id=request.auth_request_function_call_id,
|
||||
name="adk_request_credential",
|
||||
response=request.auth_config,
|
||||
)
|
||||
)
|
||||
],
|
||||
)
|
||||
message_to_send = auth_content
|
||||
else:
|
||||
message_to_send = types.Content(
|
||||
role="user", parts=[types.Part(text=request.message)]
|
||||
)
|
||||
|
||||
try:
|
||||
if hasattr(message_to_send, "model_dump"):
|
||||
dumped_msg = message_to_send.model_dump(exclude_none=True)
|
||||
else:
|
||||
dumped_msg = message_to_send.dict(exclude_none=True)
|
||||
|
||||
async for event in current_agent.async_stream_query(
|
||||
user_id=request.user_id,
|
||||
message=dumped_msg,
|
||||
session_id=session_id,
|
||||
):
|
||||
event_data = process_agent_event(event)
|
||||
yield f"data: {json.dumps(event_data)}\n\n"
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
tb_str = traceback.format_exc()
|
||||
err_data = {"error": str(e), "traceback": tb_str}
|
||||
yield f"data: {json.dumps(err_data)}\n\n"
|
||||
|
||||
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
||||
|
||||
|
||||
@app.get("/validateUserId")
|
||||
@app.get("/commit")
|
||||
async def validate_user_id(request: Request):
|
||||
# Session data stored in cookies
|
||||
user_id = request.cookies.get("consent_user_id") or request.cookies.get(
|
||||
"user_id"
|
||||
)
|
||||
consent_nonce = request.cookies.get("consent_nonce")
|
||||
session_id = request.cookies.get("session_id")
|
||||
# Query params
|
||||
user_id_validation_state = request.query_params.get(
|
||||
"user_id_validation_state"
|
||||
)
|
||||
auth_provider_name = request.query_params.get(
|
||||
"connector_name"
|
||||
) or request.query_params.get("auth_provider_name")
|
||||
|
||||
print(
|
||||
f"Callback received: user_id_validation_state={user_id_validation_state},"
|
||||
f" auth_provider_name={auth_provider_name}, user_id={user_id}"
|
||||
)
|
||||
# Note: In production, you should probably throw an if the below checks fail.
|
||||
# For this example, we'll just return an error message to the user and 200 OK.
|
||||
if not user_id:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": (
|
||||
"user_id cookie not found. Please ensure cookies are enabled."
|
||||
),
|
||||
}
|
||||
if not consent_nonce:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": (
|
||||
"consent_nonce cookie not found. Please ensure cookies are enabled."
|
||||
),
|
||||
}
|
||||
if not user_id_validation_state:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "user_id_validation_state query param not found",
|
||||
}
|
||||
if not auth_provider_name:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "connector_name or auth_provider_name query param not found",
|
||||
}
|
||||
|
||||
try:
|
||||
url = (
|
||||
f"https://{TARGET_HOST}/v1alpha/{auth_provider_name}"
|
||||
"/credentials:finalize"
|
||||
)
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload = {
|
||||
"userId": user_id,
|
||||
"userIdValidationState": user_id_validation_state,
|
||||
"consentNonce": consent_nonce,
|
||||
}
|
||||
|
||||
print(f"Calling FinalizeCredentials via HTTP POST to: {url}")
|
||||
print(f"Headers: {headers}")
|
||||
print(f"Payload: {payload}")
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(url, json=payload, headers=headers)
|
||||
|
||||
print(f"HTTP Response Status: {response.status_code}")
|
||||
print(f"HTTP Response Body: {response.text}")
|
||||
|
||||
if response.status_code == 200:
|
||||
# Return a simple HTML page to indicate OAuth success
|
||||
html_content = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Authorization Successful</title>
|
||||
</head>
|
||||
<body>
|
||||
<p>Authorization successful! You can close this window.</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
return HTMLResponse(content=html_content)
|
||||
else:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": f"HTTP Error {response.status_code}: {response.text}",
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error calling FinalizeCredentials via HTTP: {e}")
|
||||
return {
|
||||
"status": "error",
|
||||
"message": f"Failed to finalize credentials: {str(e)}",
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="127.0.0.1", port=8080)
|
||||
@@ -0,0 +1,6 @@
|
||||
fastapi
|
||||
google-adk[agent-engine,agent-identity]
|
||||
google-auth
|
||||
google-cloud-aiplatform
|
||||
httpx
|
||||
uvicorn
|
||||
@@ -0,0 +1,135 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Agent Assistant</title>
|
||||
<!-- Include custom styles -->
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
<!-- Google Material Fonts -->
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Outfit&family=Material+Symbols+Outlined"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<!-- Import Google Material 3 Web Components -->
|
||||
<!-- See https://m3.material.io/develop/web -->
|
||||
<script type="module" src="https://esm.run/@material/web/all.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<!-- Sidebar for Settings (Material 3 styled form) -->
|
||||
<div id="settings-sidebar" class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h2>Configuration Settings</h2>
|
||||
</div>
|
||||
<div class="settings-form">
|
||||
<!-- Remote Agent Settings -->
|
||||
<div id="remote-settings">
|
||||
<md-outlined-text-field
|
||||
id="project-id"
|
||||
label="Project ID"
|
||||
placeholder="e.g. my-gcp-project"
|
||||
required
|
||||
></md-outlined-text-field>
|
||||
<md-outlined-text-field
|
||||
id="location"
|
||||
label="Location"
|
||||
placeholder="e.g. us-west1"
|
||||
required
|
||||
></md-outlined-text-field>
|
||||
|
||||
<md-filled-button id="load-agents-btn" class="btn-full">
|
||||
<span class="material-symbols-outlined" slot="icon">sync</span>
|
||||
Load Remote Agents
|
||||
</md-filled-button>
|
||||
|
||||
<md-outlined-select id="agent-select" label="Select Engine">
|
||||
<md-select-option value="">
|
||||
<div slot="headline">Select an engine...</div>
|
||||
</md-select-option>
|
||||
</md-outlined-select>
|
||||
<!-- Hidden hold for current target ID -->
|
||||
<input type="hidden" id="agent-id" />
|
||||
</div>
|
||||
|
||||
<!-- User ID for authentication tracking -->
|
||||
<md-outlined-text-field
|
||||
id="user-id"
|
||||
label="User ID"
|
||||
value="default_user_id"
|
||||
required
|
||||
></md-outlined-text-field>
|
||||
|
||||
<md-filled-button id="save-settings" class="btn-full">
|
||||
<span class="material-symbols-outlined" slot="icon">save</span>
|
||||
Save & Apply Settings
|
||||
</md-filled-button>
|
||||
|
||||
<!-- Active Agent & Session Profile Pane -->
|
||||
<div id="agent-info-pane" class="agent-info-pane">
|
||||
<h4>Active Agent Profile</h4>
|
||||
<div class="info-grid">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Mode:</span>
|
||||
<span class="info-value" id="info-agent-mode">Remote Vertex AI</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Project ID:</span>
|
||||
<span class="info-value" id="info-project-id">-</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Location:</span>
|
||||
<span class="info-value" id="info-location">-</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Target ID:</span>
|
||||
<span class="info-value" id="info-agent-id">-</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Session ID:</span>
|
||||
<span class="info-value" id="info-session-id">No active session</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">User ID:</span>
|
||||
<span class="info-value" id="info-user-id">default_user_id</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Chat Area -->
|
||||
<div class="chat-container">
|
||||
<!-- Header with title and clear chat button -->
|
||||
<header class="chat-header">
|
||||
<div class="header-left">
|
||||
<div class="agent-info">
|
||||
<h1>Agent Playground</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right"></div>
|
||||
</header>
|
||||
<!-- Message History Container -->
|
||||
<div id="messages-container" class="messages-container"></div>
|
||||
<!-- Input Area -->
|
||||
<footer class="input-container">
|
||||
<input
|
||||
type="text"
|
||||
id="user-input"
|
||||
placeholder="Type a message to your agent..."
|
||||
autofocus
|
||||
autocomplete="off"
|
||||
/>
|
||||
|
||||
<md-filled-icon-button id="send-btn" disabled>
|
||||
<span class="material-symbols-outlined">send</span>
|
||||
</md-filled-icon-button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- jQuery Library -->
|
||||
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
||||
<!-- Local JavaScript Hook -->
|
||||
<script src="/static/script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,407 @@
|
||||
// Copyright 2026 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
$(function() {
|
||||
const $messagesContainer = $('#messages-container');
|
||||
const $userInput = $('#user-input');
|
||||
const $sendBtn = $('#send-btn');
|
||||
|
||||
let currentSessionId = null;
|
||||
|
||||
/**
|
||||
* Updates the active agent profile information panel in the sidebar
|
||||
* based on the currently selected remote agent and user ID configurations.
|
||||
*/
|
||||
const updateAgentInfoPane = () => {
|
||||
const projectId = $('#project-id').val();
|
||||
const location = $('#location').val();
|
||||
const agentId = $('#agent-id').val() || $('#agent-select').val();
|
||||
const userId = $('#user-id').val() || 'default_user_id';
|
||||
|
||||
$('#info-agent-mode').text('Remote Vertex AI');
|
||||
$('#info-project-id').text(projectId || '-');
|
||||
$('#info-location').text(location || '-');
|
||||
$('#info-agent-id').text(agentId || '-');
|
||||
$('#info-session-id').text(currentSessionId || 'No active session');
|
||||
$('#info-user-id').text(userId || 'default_user_id');
|
||||
};
|
||||
|
||||
/**
|
||||
* Resets the message history container to display the initial greeting
|
||||
* and instructions for the user.
|
||||
*/
|
||||
const resetChatFeed = () => {
|
||||
$messagesContainer.html(`
|
||||
<div class="message system-message">
|
||||
<div class="message-content">
|
||||
Hi! I am your AI Assistant. Configure your target remote agent in the panel on the left, and type a query below to load the sandbox stream.
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Asynchronously fetches the list of available remote agents from the backend
|
||||
* API and populates the remote agent selection dropdown.
|
||||
*/
|
||||
function loadRemoteAgents(showAlert = false) {
|
||||
const projectId = $('#project-id').val().trim();
|
||||
const location = $('#location').val().trim();
|
||||
|
||||
updateAgentInfoPane();
|
||||
|
||||
if (!projectId || !location) {
|
||||
if (showAlert) alert('Please configure both Project ID and Location first.');
|
||||
return;
|
||||
}
|
||||
|
||||
const $loadAgentsBtn = $('#load-agents-btn');
|
||||
const $agentSelect = $('#agent-select');
|
||||
|
||||
$loadAgentsBtn.prop('disabled', true).html('<span class="material-symbols-outlined icon-spin" slot="icon">sync</span> Loading...');
|
||||
|
||||
$.getJSON('/list_agents', { project_id: projectId, location: location })
|
||||
.done(data => {
|
||||
if (data.error) {
|
||||
if (showAlert) alert(`GCP Error: ${data.error}`);
|
||||
console.error(`Remote agent fetch error: ${data.error}`);
|
||||
} else if (data.agents) {
|
||||
$agentSelect.html('<md-select-option value=""><div slot="headline">Select an engine...</div></md-select-option>');
|
||||
data.agents.forEach(agent => {
|
||||
$agentSelect.append(
|
||||
$('<md-select-option>').val(agent.id).append(
|
||||
$('<div>').attr('slot', 'headline').text(`${agent.name} (${agent.id})`)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
const currentAgentId = $('#agent-id').val();
|
||||
if (currentAgentId) {
|
||||
$agentSelect.val(currentAgentId);
|
||||
}
|
||||
updateAgentInfoPane();
|
||||
}
|
||||
})
|
||||
.fail((jqXHR, textStatus, errorThrown) => {
|
||||
console.error('Failed to load remote agents:', errorThrown);
|
||||
if (showAlert) alert('Failed to communicate with GCP reasoning engines.');
|
||||
})
|
||||
.always(() => {
|
||||
$loadAgentsBtn.prop('disabled', false).html('<span class="material-symbols-outlined" slot="icon">sync</span> Load Remote Agents');
|
||||
});
|
||||
}
|
||||
|
||||
$('#agent-select').on('change', function() {
|
||||
const selectedId = $(this).val();
|
||||
$('#agent-id').val(selectedId);
|
||||
currentSessionId = null;
|
||||
resetChatFeed();
|
||||
updateAgentInfoPane();
|
||||
});
|
||||
|
||||
/**
|
||||
* Initializes default configuration values on fresh page loads
|
||||
* and updates the active agent profile panel accordingly.
|
||||
*/
|
||||
const loadSettings = () => {
|
||||
const projectId = '';
|
||||
const location = '';
|
||||
const agentId = '';
|
||||
const userId = 'default_user_id';
|
||||
|
||||
$('#project-id').val(projectId);
|
||||
$('#location').val(location);
|
||||
$('#agent-id').val(agentId);
|
||||
$('#user-id').val(userId);
|
||||
|
||||
updateAgentInfoPane();
|
||||
};
|
||||
|
||||
// Apply configs to active session
|
||||
$('#save-settings').on('click', () => {
|
||||
const selectVal = $('#agent-select').val();
|
||||
if (selectVal) {
|
||||
$('#agent-id').val(selectVal);
|
||||
}
|
||||
|
||||
currentSessionId = null;
|
||||
document.cookie =
|
||||
'session_id=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; samesite=lax';
|
||||
|
||||
resetChatFeed();
|
||||
updateAgentInfoPane();
|
||||
alert('Settings applied and session reset successfully!');
|
||||
});
|
||||
|
||||
$('#load-agents-btn').on('click', () => loadRemoteAgents(true));
|
||||
|
||||
// Initialize
|
||||
loadSettings();
|
||||
|
||||
// Handle send button states on user inputs
|
||||
$userInput.on('input', function() {
|
||||
$sendBtn.prop('disabled', $(this).val().trim() === '');
|
||||
});
|
||||
|
||||
$userInput.on('keydown', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
});
|
||||
|
||||
$sendBtn.on('click', sendMessage);
|
||||
|
||||
/**
|
||||
* Handles the user message submission workflow, disabling input controls
|
||||
* during processing, appending the user's message to the chat container, and
|
||||
* initiating the backend streaming request.
|
||||
*/
|
||||
async function sendMessage() {
|
||||
const text = $userInput.val().trim();
|
||||
if (!text) return;
|
||||
|
||||
$userInput.val('');
|
||||
$sendBtn.prop('disabled', true);
|
||||
|
||||
appendMessage(text, 'user-message');
|
||||
await executeChatRequest(text, false, null, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a POST request to the /chat API endpoint and processes the
|
||||
* Server-Sent Events (SSE) stream. Handles agent messages, tool execution
|
||||
* progress, and OAuth popup authentication resumes.
|
||||
*
|
||||
* @param {?string} text - The user query or prompt to send to the agent.
|
||||
* @param {?boolean} isAuthResume - Indicates whether the request is resuming
|
||||
* from an OAuth popup authentication flow.
|
||||
* @param {?string|null} authRequestId - The function call ID associated with
|
||||
* the credentials request.
|
||||
* @param {?object|null} authConfig - The authentication configuration
|
||||
* parameters returned by the agent tool.
|
||||
* @param {?HTMLElement|null=} existingAgentMessageDiv - An existing message
|
||||
* container element to append streaming responses into.
|
||||
*/
|
||||
async function executeChatRequest(
|
||||
text, isAuthResume, authRequestId, authConfig,
|
||||
existingAgentMessageDiv = null) {
|
||||
let $agentMessageDiv;
|
||||
let $contentDiv;
|
||||
let isFirstEvent = true;
|
||||
|
||||
if (existingAgentMessageDiv) {
|
||||
$agentMessageDiv = $(existingAgentMessageDiv);
|
||||
$contentDiv = $agentMessageDiv.find('.message-content');
|
||||
isFirstEvent = false;
|
||||
} else {
|
||||
$agentMessageDiv = appendMessage(
|
||||
'<div class="agent-loader"><span class="material-symbols-outlined icon-spin">sync</span> Thinking...</div>',
|
||||
'agent-message');
|
||||
$contentDiv = $agentMessageDiv.find('.message-content');
|
||||
}
|
||||
|
||||
const agentType = 'remote';
|
||||
const localAgent = '';
|
||||
const projectId = $('#project-id').val();
|
||||
const location = $('#location').val();
|
||||
const agentId = $('#agent-id').val() || $('#agent-select').val();
|
||||
const userId = $('#user-id').val();
|
||||
|
||||
const formatAgentText = (inputVal) => {
|
||||
if (typeof inputVal !== 'string') return inputVal;
|
||||
return inputVal.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
.replace(/\n/g, '<br>');
|
||||
};
|
||||
|
||||
try {
|
||||
const requestBody = {
|
||||
message: text || '',
|
||||
agent_type: agentType,
|
||||
local_agent: localAgent,
|
||||
project_id: projectId,
|
||||
location: location,
|
||||
agent_id: agentId,
|
||||
user_id: userId,
|
||||
is_auth_resume: isAuthResume,
|
||||
auth_request_function_call_id: authRequestId,
|
||||
auth_config: authConfig
|
||||
};
|
||||
|
||||
if (currentSessionId) {
|
||||
requestBody.session_id = currentSessionId;
|
||||
}
|
||||
|
||||
const response = await fetch('/chat', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorDetail = '';
|
||||
try {
|
||||
const errData = await response.json();
|
||||
errorDetail = errData.detail ? JSON.stringify(errData.detail) :
|
||||
JSON.stringify(errData);
|
||||
} catch (e) {
|
||||
try {
|
||||
errorDetail = await response.text();
|
||||
} catch (t) {
|
||||
errorDetail = `Status ${response.status}`;
|
||||
}
|
||||
}
|
||||
throw new Error(`HTTP connection error (Status ${response.status}): ${
|
||||
errorDetail}`);
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const {value, done} = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, {stream: true});
|
||||
while (true) {
|
||||
const eventEnd = buffer.indexOf('\n\n');
|
||||
if (eventEnd === -1) break;
|
||||
|
||||
const event = buffer.substring(0, eventEnd);
|
||||
buffer = buffer.substring(eventEnd + 2);
|
||||
|
||||
if (event.startsWith('data: ')) {
|
||||
const dataStr = event.substring(6);
|
||||
try {
|
||||
const data = JSON.parse(dataStr);
|
||||
|
||||
if (data.session_id) {
|
||||
currentSessionId = data.session_id;
|
||||
document.cookie =
|
||||
`session_id=${currentSessionId}; path=/; samesite=lax`;
|
||||
updateAgentInfoPane();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isFirstEvent) {
|
||||
$contentDiv.empty();
|
||||
isFirstEvent = false;
|
||||
}
|
||||
|
||||
if (data.popup_auth_uri) {
|
||||
if (data.consent_nonce) {
|
||||
document.cookie = `consent_nonce=${
|
||||
data.consent_nonce}; path=/; samesite=lax`;
|
||||
}
|
||||
const currentUserId = $('#user-id').val();
|
||||
document.cookie =
|
||||
`consent_user_id=${currentUserId}; path=/; samesite=lax`;
|
||||
const popup = window.open(data.popup_auth_uri, '_blank');
|
||||
if (popup) {
|
||||
const timer = setInterval(() => {
|
||||
if (popup.closed) {
|
||||
clearInterval(timer);
|
||||
$contentDiv.append(
|
||||
'<br><em>Authentication complete. Resuming session...</em><br>');
|
||||
$messagesContainer.scrollTop(
|
||||
$messagesContainer.prop('scrollHeight'));
|
||||
executeChatRequest(
|
||||
'', true, data.auth_request_function_call_id,
|
||||
data.auth_config, $agentMessageDiv[0]);
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
$contentDiv.append(
|
||||
`<span>Please log in to complete authorization in the popup. <a href="${
|
||||
data.popup_auth_uri}" target="_blank">Open login window manually.</a></span>`);
|
||||
}
|
||||
|
||||
const errorMsg = data.error || data.error_message ||
|
||||
data.errorMessage || data.error_code || data.errorCode;
|
||||
if (errorMsg) {
|
||||
const $err = $('<div>')
|
||||
.addClass('error-header')
|
||||
.text(`Error: ${errorMsg}`);
|
||||
$contentDiv.append($err);
|
||||
|
||||
if (data.traceback) {
|
||||
const $pre = $('<pre>')
|
||||
.addClass('error-traceback')
|
||||
.text(data.traceback);
|
||||
$contentDiv.append($pre);
|
||||
}
|
||||
$agentMessageDiv.addClass('error-message');
|
||||
} else if (data.content && data.content.parts) {
|
||||
data.content.parts.forEach(part => {
|
||||
if (part.text) {
|
||||
$contentDiv.append(
|
||||
$('<div>').html(formatAgentText(part.text)));
|
||||
}
|
||||
});
|
||||
} else if (data.text) {
|
||||
$contentDiv.append($('<div>').html(formatAgentText(data.text)));
|
||||
} else if (typeof data === 'string') {
|
||||
$contentDiv.append($('<div>').html(formatAgentText(data)));
|
||||
}
|
||||
|
||||
$messagesContainer.scrollTop(
|
||||
$messagesContainer.prop('scrollHeight'));
|
||||
} catch (err) {
|
||||
console.error('Error parsing JSON event chunk:', dataStr, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error during query stream processing:', error);
|
||||
if (isFirstEvent) {
|
||||
$contentDiv.empty();
|
||||
}
|
||||
$contentDiv.append(
|
||||
$('<div>')
|
||||
.addClass('error-header')
|
||||
.text(`Network / connection error: ${error.message}`));
|
||||
$agentMessageDiv.addClass('error-message');
|
||||
$messagesContainer.scrollTop($messagesContainer.prop('scrollHeight'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper utility to create and append a new message container element (user,
|
||||
* agent, or system) to the chat history DOM, automatically scrolling the view
|
||||
* to the latest message.
|
||||
*
|
||||
* @param {string} text - The HTML or plaintext content of the message.
|
||||
* @param {string} type - The CSS class defining the message type (e.g.,
|
||||
* 'user-message', 'agent-message').
|
||||
* @returns {!jQuery} The jQuery wrapper representing the newly created message
|
||||
* element.
|
||||
*/
|
||||
function appendMessage(text, type) {
|
||||
const $messageDiv = $('<div>').addClass(`message ${type}`);
|
||||
const $contentDiv = $('<div>')
|
||||
.addClass('message-content')
|
||||
.html(text ? text.replace(/\n/g, '<br>') : '');
|
||||
|
||||
$messageDiv.append($contentDiv);
|
||||
$messagesContainer.append($messageDiv);
|
||||
$messagesContainer.scrollTop($messagesContainer.prop('scrollHeight'));
|
||||
return $messageDiv;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,291 @@
|
||||
/* ==========================================================================
|
||||
Root Variables & Theming
|
||||
Defines the core Google Material 3 color palette, backgrounds, and borders.
|
||||
========================================================================== */
|
||||
:root {
|
||||
--bg-color: #f8f9fa;
|
||||
--text-color: #1f1f1f;
|
||||
--text-muted: #5f6368;
|
||||
--sidebar-bg: #f0f4f9;
|
||||
--chat-bg: #ffffff;
|
||||
--message-user-bg: #d3e3fd;
|
||||
--message-user-text: #041e49;
|
||||
--message-agent-bg: #f1f3f4;
|
||||
--message-agent-text: #1f1f1f;
|
||||
--border-color: #dadce0;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Global Resets & Base Layout
|
||||
Establishes box-sizing, typography, and the full-bleed application container.
|
||||
========================================================================== */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: 'Google Sans', 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
overflow: hidden;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
display: flex;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Sidebar Configuration Panel
|
||||
Styles the pinned left navigation drawer housing agent and user settings.
|
||||
========================================================================== */
|
||||
.sidebar {
|
||||
width: 320px;
|
||||
background-color: var(--sidebar-bg);
|
||||
border-right: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 18px 24px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.sidebar-header h2 {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.settings-form {
|
||||
padding: 24px;
|
||||
flex-grow: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
#remote-settings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.btn-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Main Chat Playground Area
|
||||
Configures the primary chat interface, header, and dynamic message history feed.
|
||||
========================================================================== */
|
||||
.chat-container {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--chat-bg);
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
padding: 14px 24px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background-color: var(--chat-bg);
|
||||
}
|
||||
|
||||
.agent-info h1 {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.messages-container {
|
||||
flex-grow: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Message Bubbles & Formatting
|
||||
Provides distinct visual styling for user, agent, system, and error bubbles.
|
||||
========================================================================== */
|
||||
.message {
|
||||
max-width: 75%;
|
||||
padding: 12px 18px;
|
||||
border-radius: 18px;
|
||||
line-height: 1.55;
|
||||
word-wrap: break-word;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.system-message {
|
||||
align-self: center;
|
||||
background-color: var(--bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
max-width: 85%;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.user-message {
|
||||
align-self: flex-end;
|
||||
background-color: var(--message-user-bg);
|
||||
color: var(--message-user-text);
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
.agent-message {
|
||||
align-self: flex-start;
|
||||
background-color: var(--message-agent-bg);
|
||||
color: var(--message-agent-text);
|
||||
border-bottom-left-radius: 4px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
align-self: center;
|
||||
background-color: #fce8e6;
|
||||
color: #c5221f;
|
||||
border: 1px solid rgba(197, 34, 31, 0.25);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.error-header {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.error-traceback {
|
||||
margin-top: 8px;
|
||||
background-color: #fce8e6;
|
||||
border: 1px solid rgba(197, 34, 31, 0.2);
|
||||
color: #c5221f;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.8rem;
|
||||
overflow-x: auto;
|
||||
font-family: Consolas, Courier, monospace;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Input Bar & Actions
|
||||
Designs the bottom prompt bar and send button.
|
||||
========================================================================== */
|
||||
.input-container {
|
||||
padding: 18px 24px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
background-color: var(--chat-bg);
|
||||
}
|
||||
|
||||
.input-container input {
|
||||
flex-grow: 1;
|
||||
padding: 14px 18px;
|
||||
border-radius: 24px;
|
||||
background-color: #f1f3f4;
|
||||
border: 1px solid #transparent;
|
||||
color: var(--text-color);
|
||||
font-size: 0.95rem;
|
||||
outline: none;
|
||||
transition: background-color 150ms ease, border-color 150ms ease;
|
||||
}
|
||||
|
||||
.input-container input:focus {
|
||||
background-color: var(--chat-bg);
|
||||
border-color: var(--border-color);
|
||||
box-shadow: 0 1px 2px 0 rgba(60, 64, 67, 0.15);
|
||||
}
|
||||
|
||||
/* Material symbols loading spins */
|
||||
.agent-loader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.icon-spin {
|
||||
display: inline-block;
|
||||
animation: spin 2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Active Agent Profile configurations */
|
||||
.agent-info-pane {
|
||||
margin-top: 20px;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
background-color: var(--chat-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.agent-info-pane h4 {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.8rem;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: var(--text-color);
|
||||
font-weight: 600;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* Custom scrollbars */
|
||||
::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(60, 64, 67, 0.2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(60, 64, 67, 0.35);
|
||||
}
|
||||
Reference in New Issue
Block a user