mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-01-09 15:17:59 -05:00
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:
@@ -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=
|
||||
|
||||
@@ -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";
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
77
autogpt_platform/frontend/src/services/scripts/scripts.tsx
Normal file
77
autogpt_platform/frontend/src/services/scripts/scripts.tsx
Normal 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;
|
||||
}
|
||||
68
autogpt_platform/frontend/src/types/types.ts
Normal file
68
autogpt_platform/frontend/src/types/types.ts
Normal 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 {};
|
||||
Reference in New Issue
Block a user