feat(frontend): Google Drive Picker component (#11286)

## Changes 🏗️

<img width="800" height="876" alt="Screenshot_2025-10-29_at_22 56 43"
src="https://github.com/user-attachments/assets/e1d9cf62-0a81-4658-82c2-6e673d636479"
/>

New `<GoogleDrivePicker />` component that, when rendered:
- re-uses existing Google credentials OR asks the user to SSO
- uses the Google Drive Picker script to launch a modal for the user to
select files

We will need this 3 new environment variables on the Front-end for it to
work:
```
# Google Drive Picker
NEXT_PUBLIC_GOOGLE_CLIENT_ID=
NEXT_PUBLIC_GOOGLE_API_KEY=
NEXT_PUBLIC_GOOGLE_APP_ID=
```
Updated `.env.default` with them.

### Next

We need to figure out how to map this to an agent input type and update
the Back-end to accept the files as input.

## Checklist 📋

### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  - [x] I tried the whole flow

### For configuration changes:

- [x] `.env.default` is updated or already compatible with my changes
- [x] I have included a list of my configuration changes in the PR
description (under **Changes**)
This commit is contained in:
Ubbe
2025-11-03 20:48:28 +07:00
committed by GitHub
parent 427c7eb1d4
commit 5359f20070
8 changed files with 524 additions and 1 deletions

View File

@@ -15,4 +15,9 @@
NEXT_PUBLIC_REACT_QUERY_DEVTOOL=true
NEXT_PUBLIC_GA_MEASUREMENT_ID=G-FH2XK2W4GN
# Google Drive Picker
NEXT_PUBLIC_GOOGLE_CLIENT_ID=
NEXT_PUBLIC_GOOGLE_API_KEY=
NEXT_PUBLIC_GOOGLE_APP_ID=

View File

@@ -1,8 +1,8 @@
"use client";
import FavoritesSection from "./components/FavoritesSection/FavoritesSection";
import LibraryActionHeader from "./components/LibraryActionHeader/LibraryActionHeader";
import LibraryAgentList from "./components/LibraryAgentList/LibraryAgentList";
import FavoritesSection from "./components/FavoritesSection/FavoritesSection";
import { LibraryPageStateProvider } from "./components/state-provider";
/**

View File

@@ -0,0 +1,39 @@
"use client";
import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/AgentRunsView/components/CredentialsInputs/CredentialsInputs";
import { Button } from "@/components/atoms/Button/Button";
import { CircleNotchIcon } from "@phosphor-icons/react";
import { Props, useGoogleDrivePicker } from "./useGoogleDrivePicker";
export function GoogleDrivePicker(props: Props) {
const {
credentials,
hasGoogleOAuth,
isAuthInProgress,
isLoading,
handleOpenPicker,
} = useGoogleDrivePicker(props);
if (!credentials || credentials.isLoading) {
return <CircleNotchIcon className="size-6 animate-spin" />;
}
if (!hasGoogleOAuth)
return (
<CredentialsInput
schema={credentials.schema}
onSelectCredentials={() => {}}
hideIfSingleCredentialAvailable
/>
);
return (
<Button
size="small"
onClick={handleOpenPicker}
disabled={props.disabled || isLoading || isAuthInProgress}
>
{props.buttonText || "Choose file from Google Drive"}
</Button>
);
}

View File

@@ -0,0 +1,121 @@
import { BlockIOCredentialsSubSchema } from "@/lib/autogpt-server-api";
import { loadScript } from "@/services/scripts/scripts";
export async function loadGoogleAPIPicker(): Promise<void> {
validateWindow();
await loadScript("https://apis.google.com/js/api.js");
const googleAPI = window.gapi;
if (!googleAPI) {
throw new Error(
"Google AIP not available after loading https://apis.google.com/js/api.js",
);
}
await new Promise<void>((resolve, reject) => {
try {
googleAPI.load("picker", { callback: resolve });
} catch (e) {
reject(e);
}
});
}
export async function loadGoogleIdentityServices(): Promise<void> {
if (typeof window === "undefined") {
throw new Error("Google Identity Services cannot load on server");
}
await loadScript("https://accounts.google.com/gsi/client");
const google = window.google;
if (!google?.accounts?.oauth2) {
throw new Error("Google Identity Services not available");
}
}
export type GooglePickerView =
| "DOCS"
| "DOCUMENTS"
| "SPREADSHEETS"
| "PRESENTATIONS"
| "DOCS_IMAGES"
| "FOLDERS";
export function mapViewId(view: GooglePickerView): any {
validateWindow();
const gp = window.google?.picker;
if (!gp) {
throw new Error("google.picker is not available");
}
switch (view) {
case "DOCS":
return gp.ViewId.DOCS;
case "DOCUMENTS":
return gp.ViewId.DOCUMENTS;
case "SPREADSHEETS":
return gp.ViewId.SPREADSHEETS;
case "PRESENTATIONS":
return gp.ViewId.PRESENTATIONS;
case "DOCS_IMAGES":
return gp.ViewId.DOCS_IMAGES;
case "FOLDERS":
return gp.ViewId.FOLDERS;
default:
return gp.ViewId.DOCS;
}
}
export function scopesIncludeDrive(scopes: string[]): boolean {
const set = new Set(scopes);
if (set.has("https://www.googleapis.com/auth/drive")) return true;
if (set.has("https://www.googleapis.com/auth/drive.readonly")) return true;
return false;
}
export type NormalizedPickedFile = {
id: string;
name?: string;
mimeType?: string;
url?: string;
iconUrl?: string;
};
export function normalizePickerResponse(data: any): NormalizedPickedFile[] {
validateWindow();
const gp = window.google?.picker;
if (!gp) return [];
if (!data || data[gp.Response.ACTION] !== gp.Action.PICKED) return [];
const docs = data[gp.Response.DOCUMENTS] || [];
return docs.map((doc: any) => ({
id: doc[gp.Document.ID],
name: doc[gp.Document.NAME],
mimeType: doc[gp.Document.MIME_TYPE],
url: doc[gp.Document.URL],
iconUrl: doc[gp.Document.ICON_URL],
}));
}
function validateWindow() {
if (typeof window === "undefined") {
throw new Error("Google Picker cannot load on server");
}
}
export function getCredentialsSchema(scopes: string[]) {
return {
type: "object",
title: "Google Drive",
description: "Google OAuth needed to access Google Drive",
properties: {},
required: [],
credentials_provider: ["google"],
credentials_types: ["oauth2"],
credentials_scopes: scopes,
secret: true,
} satisfies BlockIOCredentialsSubSchema;
}

View File

@@ -0,0 +1,13 @@
export type EnvironmentDrivenGoogleConfig = {
clientId?: string | undefined;
developerKey?: string | undefined;
appId?: string | undefined; // Cloud project number
};
export function readEnvGoogleConfig(): EnvironmentDrivenGoogleConfig {
return {
clientId: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID,
developerKey: process.env.NEXT_PUBLIC_GOOGLE_API_KEY,
appId: process.env.NEXT_PUBLIC_GOOGLE_APP_ID,
};
}

View File

@@ -0,0 +1,200 @@
import { useToast } from "@/components/molecules/Toast/use-toast";
import useCredentials from "@/hooks/useCredentials";
import { useMemo, useRef, useState } from "react";
import {
getCredentialsSchema,
GooglePickerView,
loadGoogleAPIPicker,
loadGoogleIdentityServices,
mapViewId,
NormalizedPickedFile,
normalizePickerResponse,
scopesIncludeDrive,
} from "./helpers";
const defaultScopes = ["https://www.googleapis.com/auth/drive.file"];
type TokenClient = {
requestAccessToken: (opts: { prompt: string }) => void;
};
export type Props = {
scopes?: string[];
developerKey?: string;
clientId?: string;
appId?: string; // Cloud project number
multiselect?: boolean;
views?: GooglePickerView[];
navHidden?: boolean;
listModeIfNoDriveScope?: boolean;
disableThumbnails?: boolean;
buttonText?: string;
disabled?: boolean;
onPicked: (files: NormalizedPickedFile[]) => void;
onCanceled: () => void;
onError: (err: unknown) => void;
};
export function useGoogleDrivePicker(options: Props) {
const {
scopes = ["https://www.googleapis.com/auth/drive.file"],
developerKey = process.env.NEXT_PUBLIC_GOOGLE_API_KEY,
clientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID,
appId = process.env.NEXT_PUBLIC_GOOGLE_APP_ID,
multiselect = false,
views = ["DOCS"],
navHidden = false,
listModeIfNoDriveScope = true,
disableThumbnails = false,
onPicked,
onCanceled,
onError,
} = options || {};
const requestedScopes = options?.scopes || defaultScopes;
const [isLoading, setIsLoading] = useState(false);
const [isAuthInProgress, setIsAuthInProgress] = useState(false);
const accessTokenRef = useRef<string | null>(null);
const tokenClientRef = useRef<TokenClient | null>(null);
const pickerReadyRef = useRef(false);
const credentials = useCredentials(getCredentialsSchema(requestedScopes));
const isReady = pickerReadyRef.current && !!tokenClientRef.current;
const { toast } = useToast();
const hasGoogleOAuth = useMemo(() => {
if (!credentials || credentials.isLoading) return false;
return credentials.savedCredentials?.length > 0;
}, [credentials]);
async function openPicker() {
try {
await ensureLoaded();
console.log(accessTokenRef.current);
const token = accessTokenRef.current || (await requestAccessToken());
buildAndShowPicker(token);
} catch (e) {
if (onError) onError(e);
}
}
function ensureLoaded() {
async function load() {
try {
setIsLoading(true);
await Promise.all([
loadGoogleAPIPicker(),
loadGoogleIdentityServices(),
]);
if (!clientId) throw new Error("Google OAuth client ID is not set");
tokenClientRef.current =
window.google!.accounts!.oauth2!.initTokenClient({
client_id: clientId,
scope: scopes.join(" "),
callback: () => {},
});
pickerReadyRef.current = true;
} catch (e) {
console.error(e);
toast({
title: "Error loading Google Drive Picker",
description: "Please try again later",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
}
return load();
}
async function requestAccessToken() {
function executor(
resolve: (value: string) => void,
reject: (reason?: unknown) => void,
) {
const tokenClient = tokenClientRef.current;
if (!tokenClient) {
return reject(new Error("Token client not initialized"));
}
setIsAuthInProgress(true);
// Update the callback on the already-initialized token client,
// then request an access token using the token flow (no redirects).
(tokenClient as any).callback = onTokenResponseFactory(resolve, reject);
tokenClient.requestAccessToken({
prompt: accessTokenRef.current ? "" : "consent",
});
}
return await new Promise(executor);
}
function buildAndShowPicker(accessToken: string): void {
const gp = window.google!.picker!;
const builder = new gp.PickerBuilder()
.setOAuthToken(accessToken)
.setDeveloperKey(developerKey)
.setAppId(appId)
.setCallback(handlePickerData);
if (navHidden) builder.enableFeature(gp.Feature.NAV_HIDDEN);
if (multiselect) builder.enableFeature(gp.Feature.MULTISELECT_ENABLED);
const allowThumbnails = disableThumbnails
? false
: scopesIncludeDrive(scopes);
views.forEach((v) => {
const vid = mapViewId(v);
const view = new gp.DocsView(vid);
if (!allowThumbnails && listModeIfNoDriveScope) {
view.setMode(gp.DocsViewMode.LIST);
}
builder.addView(view);
});
const picker = builder.build();
picker.setVisible(true);
}
function handlePickerData(data: any): void {
try {
const files = normalizePickerResponse(data);
if (files.length) {
onPicked(files);
} else {
onCanceled();
}
} catch (e) {
if (onError) onError(e);
}
}
function onTokenResponseFactory(
resolve: (value: string) => void,
reject: (reason?: unknown) => void,
) {
return function onTokenResponse(response: any) {
setIsAuthInProgress(false);
if (response?.error) return reject(response);
accessTokenRef.current = response.access_token;
resolve(response.access_token);
};
}
return {
isReady,
isLoading,
isAuthInProgress,
handleOpenPicker: openPicker,
credentials,
hasGoogleOAuth,
};
}

View File

@@ -0,0 +1,77 @@
// Generic client-side script loader service
declare global {
interface Window {
__scriptLoaderCache?: Map<string, Promise<void>>;
}
}
export type LoadScriptOptions = {
async?: boolean;
defer?: boolean;
attrs?: Record<string, string>;
crossOrigin?: string;
referrerPolicy?: string;
};
export function loadScript(src: string, options?: LoadScriptOptions) {
if (typeof window === "undefined") {
return Promise.reject(new Error("Cannot load scripts on server"));
}
if (!window.__scriptLoaderCache) {
window.__scriptLoaderCache = new Map();
}
const cache = window.__scriptLoaderCache;
const cached = cache.get(src);
if (cached) {
return cached;
}
const promise = new Promise<void>((resolve, reject) => {
const existing = document.querySelector<HTMLScriptElement>(
`script[src="${src}"]`,
);
if (existing && (existing as any).__loaded) {
return resolve();
}
const script = existing || document.createElement("script");
if (!existing) {
document.head.appendChild(script);
}
script.src = src;
script.async = options?.async ?? true;
script.defer = options?.defer ?? true;
if (options?.crossOrigin) {
script.crossOrigin = options.crossOrigin;
}
if (options?.referrerPolicy) {
script.referrerPolicy = options.referrerPolicy as any;
}
if (options?.attrs) {
Object.entries(options.attrs).forEach(([k, v]) =>
script.setAttribute(k, v),
);
}
script.onload = () => {
(script as any).__loaded = true;
resolve();
};
script.onerror = () => reject(new Error(`Failed to load script: ${src}`));
});
cache.set(src, promise);
return promise;
}

View File

@@ -0,0 +1,68 @@
declare global {
interface Window {
gapi?: {
load: (
name: "picker",
options: { callback?: () => void; onerror?: (error: unknown) => void },
) => void;
};
google?: {
accounts?: {
oauth2?: {
initTokenClient: (options: {
client_id: string;
scope: string;
callback: (response: any) => void;
}) => {
// Minimal surface we use
requestAccessToken: (opts: { prompt: string }) => void;
callback?: (response: any) => void;
};
};
};
picker?: {
ViewId: {
DOCS: string;
DOCUMENTS: string;
SPREADSHEETS: string;
PRESENTATIONS: string;
DOCS_IMAGES: string;
FOLDERS: string;
};
Response: {
ACTION: string;
DOCUMENTS: string;
};
Action: {
PICKED: string;
};
Document: {
ID: string;
NAME: string;
MIME_TYPE: string;
URL: string;
ICON_URL: string;
};
Feature: {
NAV_HIDDEN: string;
MULTISELECT_ENABLED: string;
};
DocsViewMode: {
LIST: string;
};
PickerBuilder: new () => {
setOAuthToken: (token: string) => any;
setDeveloperKey: (key: string) => any;
setAppId: (id: string) => any;
setCallback: (cb: (data: any) => void) => any;
enableFeature: (feature: string) => any;
addView: (view: any) => any;
build: () => { setVisible: (visible: boolean) => void };
};
DocsView: new (viewId: any) => { setMode: (mode: string) => any };
};
};
}
}
export {};