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 {};