Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 75 additions & 9 deletions core/python/deep-search/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ uvx google-agents-cli setup
**Create the project from this recipe** (replace `my-deep-search-agent` with your project name):

```bash
agents-cli create my-deep-search-agent -a adk@deep-search
agents-cli create my-deep-search-agent \
-a https://github.com/google/adk-recipes/tree/main/core/python/deep-search
```

The Google Agents CLI will prompt you to select deployment options and set up your Google Cloud project.
Expand Down Expand Up @@ -121,31 +122,96 @@ Then run `make install && make dev` to start the agent.

## Cloud Deployment

> **Note:** Cloud deployment applies only to projects created with **google-agents-cli**.
> **Note:** Cloud deployment applies to projects created with **google-agents-cli**.

**Prerequisites:**
```bash
gcloud components update
gcloud config set project YOUR_PROJECT_ID
```

#### Option 1: Deploy with ADK Web UI (Default)
#### Option 1: Deploy with ADK Web UI (Default Backend)

For a quick deployment using the built-in [adk-web](https://github.com/google/adk-web) interface:
By default, the scaffolded project creates a container for the FastAPI backend (`app/`) exposing port 8080. It uses the built-in [adk-web](https://github.com/google/adk-web) interface.

Deploy to Cloud Run with Identity-Aware Proxy (IAP):

```bash
agents-cli deploy --iap
```

Or deploy without IAP:

```bash
make deploy IAP=true
agents-cli deploy
```

#### Option 2: Deploy with Custom UI (React Frontend)

This agent includes a custom React frontend. To deploy it:
This agent includes a custom React frontend in `frontend/`. In local development, the Vite dev server (`make dev-frontend`) proxies API calls to the backend on `http://127.0.0.1:8000`.

In production, the default container is **backend-only** and does not package the frontend. To bundle and serve both the React frontend and the ADK agent from a single container:

1. **Update the `Dockerfile` to a multi-stage build** that builds the frontend and bundles the static distribution into the image:

```dockerfile
# Stage 1: Build the React frontend
FROM node:20-slim AS frontend-builder
WORKDIR /frontend
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build

# Stage 2: Backend + bundled frontend
FROM python:3.11-slim
Comment thread
Anurag-M1 marked this conversation as resolved.
WORKDIR /app
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev --no-install-project
COPY app/ ./app/
COPY --from=frontend-builder /frontend/dist ./frontend/dist

RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
USER appuser

EXPOSE 8080
CMD ["uv", "run", "uvicorn", "app.fast_api_app:app", "--host", "0.0.0.0", "--port", "8080"]
```

2. **Mount the static files and route `/api/*` to ADK in `app/fast_api_app.py`**:

The React frontend sends requests to `/api/run_sse`, `/api/docs`, and `/api/apps/...`. In local development, the Vite dev server strips the `/api` prefix via its dev proxy. In production, add an ASGI middleware to strip the `/api` prefix so calls route directly to ADK endpoints on FastAPI, and mount the static frontend files at the root:

```python
from pathlib import Path
from fastapi.staticfiles import StaticFiles
from starlette.types import ASGIApp, Receive, Scope, Send


# Strip '/api' prefix so frontend requests route to ADK endpoints (/run_sse, /apps, /docs)
class StripApiPrefixMiddleware:
def __init__(self, app: ASGIApp):
self.app = app

async def __call__(self, scope: Scope, receive: Receive, send: Send):
if scope["type"] == "http" and scope["path"].startswith("/api/"):
scope["path"] = scope["path"][4:]
await self.app(scope, receive, send)


app.add_middleware(StripApiPrefixMiddleware)

# Mount frontend static build at root
frontend_dist = Path("frontend/dist")
if frontend_dist.exists():
app.mount("/", StaticFiles(directory=str(frontend_dist), html=True), name="frontend")
```

1. **Configure the Dockerfile** - See the [Deploy UI Guide](https://github.com/google/agents-cli) for the required Dockerfile changes.
3. **Deploy with Google Agents CLI:**

2. **Deploy with the frontend port:**
```bash
make deploy IAP=true PORT=5173
agents-cli deploy --iap
```

#### After Deployment
Expand Down
14 changes: 14 additions & 0 deletions core/python/deep-search/frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,17 @@
// 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
//
// https://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.

import { useCallback, useEffect, useRef, useState } from "react";
import { v4 as uuidv4 } from "uuid";
import { ChatMessagesView } from "@/components/ChatMessagesView";
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,17 @@
// 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
//
// https://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.

import {
Card,
CardContent,
Expand All @@ -22,6 +36,7 @@ import ReactMarkdown from "react-markdown";

export interface ProcessedEvent {
title: string;
// biome-ignore lint/suspicious/noExplicitAny: event payload is dynamic
data: any;
}

Expand All @@ -39,28 +54,37 @@ export function ActivityTimeline({
const [isTimelineCollapsed, setIsTimelineCollapsed] =
useState<boolean>(false);

// biome-ignore lint/suspicious/noExplicitAny: event payload is dynamic
const formatEventData = (data: any): string => {
// Handle new structured data types
if (typeof data === "object" && data !== null && data.type) {
switch (data.type) {
case 'functionCall':
case "functionCall":
return `Calling function: ${data.name}\nArguments: ${JSON.stringify(data.args, null, 2)}`;
case 'functionResponse':
case "functionResponse":
return `Function ${data.name} response:\n${JSON.stringify(data.response, null, 2)}`;
case 'text':
case "text":
return data.content;
case 'sources':
const sources = data.content as Record<string, { title: string; url: string }>;
case "sources": {
const sources = data.content as Record<
string,
{ title: string; url: string }
>;
if (Object.keys(sources).length === 0) {
return "No sources found.";
}
return Object.values(sources)
.map(source => `[${source.title || 'Untitled Source'}](${source.url})`).join(', ');
.map(
(source) =>
`[${source.title || "Untitled Source"}](${source.url})`,
)
.join(", ");
}
default:
return JSON.stringify(data, null, 2);
}
}

// Existing logic for backward compatibility
if (typeof data === "string") {
// Try to parse as JSON first
Expand All @@ -79,15 +103,16 @@ export function ActivityTimeline({
return String(data);
};

// biome-ignore lint/suspicious/noExplicitAny: event payload is dynamic
const isJsonData = (data: any): boolean => {
// Handle new structured data types
if (typeof data === "object" && data !== null && data.type) {
if (data.type === 'sources') {
if (data.type === "sources") {
return false; // Let ReactMarkdown handle this
}
return data.type === 'functionCall' || data.type === 'functionResponse';
return data.type === "functionCall" || data.type === "functionResponse";
}

// Existing logic
if (typeof data === "string") {
try {
Expand Down Expand Up @@ -129,11 +154,14 @@ export function ActivityTimeline({
}
}, [isLoading, processedEvents]);
return (
<Card className={`border-none rounded-lg bg-neutral-700 ${isTimelineCollapsed ? "h-10 py-2" : "max-h-96 py-2"}`}>
<Card
className={`border-none rounded-lg bg-neutral-700 ${isTimelineCollapsed ? "h-10 py-2" : "max-h-96 py-2"}`}
>
<CardHeader className="py-0">
<CardDescription className="flex items-center justify-between">
<div
className="flex items-center justify-start text-sm w-full cursor-pointer gap-2 text-neutral-100"
<button
type="button"
className="flex items-center justify-start text-sm w-full cursor-pointer gap-2 text-neutral-100 bg-transparent border-0 p-0 text-left"
onClick={() => setIsTimelineCollapsed(!isTimelineCollapsed)}
>
<span>Research</span>
Expand All @@ -147,7 +175,7 @@ export function ActivityTimeline({
) : (
<ChevronUp className="h-4 w-4 mr-2" />
)}
</div>
</button>
</CardDescription>
</CardHeader>
{!isTimelineCollapsed && (
Expand All @@ -169,6 +197,7 @@ export function ActivityTimeline({
{processedEvents.length > 0 ? (
<div className="space-y-0">
{processedEvents.map((eventItem, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: events are append-only without unique IDs
<div key={index} className="relative pl-8 pb-4">
{index < processedEvents.length - 1 ||
(isLoading && index === processedEvents.length - 1) ? (
Expand Down
Loading
Loading