diff --git a/autogpt_platform/frontend/.env.default b/autogpt_platform/frontend/.env.default
index acc9946a9f..1a13696c35 100644
--- a/autogpt_platform/frontend/.env.default
+++ b/autogpt_platform/frontend/.env.default
@@ -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=
\ No newline at end of file
diff --git a/autogpt_platform/frontend/src/app/(platform)/library/page.tsx b/autogpt_platform/frontend/src/app/(platform)/library/page.tsx
index 883da2c917..70f0f4c3b6 100644
--- a/autogpt_platform/frontend/src/app/(platform)/library/page.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/library/page.tsx
@@ -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";
/**
diff --git a/autogpt_platform/frontend/src/components/contextual/GoogleDrivePicker/GoogleDrivePicker.tsx b/autogpt_platform/frontend/src/components/contextual/GoogleDrivePicker/GoogleDrivePicker.tsx
new file mode 100644
index 0000000000..2554a1c4ab
--- /dev/null
+++ b/autogpt_platform/frontend/src/components/contextual/GoogleDrivePicker/GoogleDrivePicker.tsx
@@ -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 ;
+ }
+
+ if (!hasGoogleOAuth)
+ return (
+ {}}
+ hideIfSingleCredentialAvailable
+ />
+ );
+
+ return (
+
+ );
+}
diff --git a/autogpt_platform/frontend/src/components/contextual/GoogleDrivePicker/helpers.ts b/autogpt_platform/frontend/src/components/contextual/GoogleDrivePicker/helpers.ts
new file mode 100644
index 0000000000..0525a91f6a
--- /dev/null
+++ b/autogpt_platform/frontend/src/components/contextual/GoogleDrivePicker/helpers.ts
@@ -0,0 +1,121 @@
+import { BlockIOCredentialsSubSchema } from "@/lib/autogpt-server-api";
+import { loadScript } from "@/services/scripts/scripts";
+
+export async function loadGoogleAPIPicker(): Promise {
+ 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((resolve, reject) => {
+ try {
+ googleAPI.load("picker", { callback: resolve });
+ } catch (e) {
+ reject(e);
+ }
+ });
+}
+
+export async function loadGoogleIdentityServices(): Promise {
+ 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;
+}
diff --git a/autogpt_platform/frontend/src/components/contextual/GoogleDrivePicker/types.ts b/autogpt_platform/frontend/src/components/contextual/GoogleDrivePicker/types.ts
new file mode 100644
index 0000000000..b317dc6879
--- /dev/null
+++ b/autogpt_platform/frontend/src/components/contextual/GoogleDrivePicker/types.ts
@@ -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,
+ };
+}
diff --git a/autogpt_platform/frontend/src/components/contextual/GoogleDrivePicker/useGoogleDrivePicker.ts b/autogpt_platform/frontend/src/components/contextual/GoogleDrivePicker/useGoogleDrivePicker.ts
new file mode 100644
index 0000000000..359d7e4596
--- /dev/null
+++ b/autogpt_platform/frontend/src/components/contextual/GoogleDrivePicker/useGoogleDrivePicker.ts
@@ -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(null);
+ const tokenClientRef = useRef(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,
+ };
+}
diff --git a/autogpt_platform/frontend/src/services/scripts/scripts.tsx b/autogpt_platform/frontend/src/services/scripts/scripts.tsx
new file mode 100644
index 0000000000..4807818e9f
--- /dev/null
+++ b/autogpt_platform/frontend/src/services/scripts/scripts.tsx
@@ -0,0 +1,77 @@
+// Generic client-side script loader service
+
+declare global {
+ interface Window {
+ __scriptLoaderCache?: Map>;
+ }
+}
+
+export type LoadScriptOptions = {
+ async?: boolean;
+ defer?: boolean;
+ attrs?: Record;
+ 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((resolve, reject) => {
+ const existing = document.querySelector(
+ `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;
+}
diff --git a/autogpt_platform/frontend/src/types/types.ts b/autogpt_platform/frontend/src/types/types.ts
new file mode 100644
index 0000000000..1f1cd7a5ae
--- /dev/null
+++ b/autogpt_platform/frontend/src/types/types.ts
@@ -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 {};