feat(platform): add Google Drive Picker field type for enhanced file selection (#11311)

### 🏗️ Changes 

This PR adds a Google Drive Picker field type to enhance the user
experience of existing Google blocks, replacing manual file ID entry
with a visual file picker.

#### Backend Changes
- **Added  and  types** in :
  - Configurable picker field with OAuth scope management
  - Support for multiselect, folder selection, and MIME type filtering
  - Proper access token handling for file downloads
- **Enhanced Gmail blocks**: Updated attachment fields to use Google
Drive Picker for better UX
- **Enhanced Google Sheets blocks**: Updated spreadsheet selection to
use picker instead of manual ID entry
- **Added utility**: Async file download with virus scanning and 100MB
size limit

#### Frontend Changes  
- **Enhanced GoogleDrivePicker component**: Improved UI with folder icon
and multiselect messaging
- **Integrated picker in form renderers**: Auto-renders for fields with
format
- **Added shared GoogleDrivePickerInput component**: Eliminates code
duplication between NodeInputs and RunAgentInputs
- **Added type definitions**: Complete TypeScript support for picker
schemas and responses

#### Key Features
- 🎯 **Visual file selection**: Replace manual Google Drive file ID entry
with intuitive picker
- 📁 **Flexible configuration**: Support for documents, spreadsheets,
folders, and custom MIME types
- 🔒 **Minimal OAuth scopes**: Uses scope for security (only access to
user-selected files)
-  **Enhanced UX**: Seamless integration in both block configuration
and agent run modals
- 🛡️ **Security**: Virus scanning and file size limits for downloaded
attachments

#### Migration Impact
- **Backward compatible**: Existing blocks continue to work with manual
ID entry
- **Progressive enhancement**: New picker fields provide better UX for
the same functionality
- **No breaking changes**: all existing blocks should be unaffected

This enhancement improves the user experience of Google blocks without
introducing new systems or breaking existing functionality.


### 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:
  <!-- Put your test plan here: -->
- [x]Test multiple of the new blocks [of note is that the create
spreadsheet block should be not used for now as it uses api not drive
picker]
  - [x] chain the blocks together and pass values between them

---------

Co-authored-by: Lluis Agusti <hi@llu.lu>
Co-authored-by: Zamil Majdy <zamil.majdy@agpt.co>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Nicholas Tindle
2025-11-26 21:01:29 -06:00
committed by GitHub
parent e983d5c49a
commit 02f8a69c6a
9 changed files with 1489 additions and 287 deletions

View File

@@ -0,0 +1,198 @@
import asyncio
import mimetypes
import uuid
from pathlib import Path
from typing import Any, Literal, Optional
from pydantic import BaseModel, ConfigDict, Field
from backend.data.model import SchemaField
from backend.util.file import get_exec_file_path
from backend.util.request import Requests
from backend.util.type import MediaFileType
from backend.util.virus_scanner import scan_content_safe
AttachmentView = Literal[
"DOCS",
"DOCUMENTS",
"SPREADSHEETS",
"PRESENTATIONS",
"DOCS_IMAGES",
"FOLDERS",
]
ATTACHMENT_VIEWS: tuple[AttachmentView, ...] = (
"DOCS",
"DOCUMENTS",
"SPREADSHEETS",
"PRESENTATIONS",
"DOCS_IMAGES",
"FOLDERS",
)
class GoogleDriveFile(BaseModel):
"""Represents a single file/folder picked from Google Drive"""
model_config = ConfigDict(populate_by_name=True)
id: str = Field(description="Google Drive file/folder ID")
name: Optional[str] = Field(None, description="File/folder name")
mime_type: Optional[str] = Field(
None,
alias="mimeType",
description="MIME type (e.g., application/vnd.google-apps.document)",
)
url: Optional[str] = Field(None, description="URL to open the file")
icon_url: Optional[str] = Field(None, alias="iconUrl", description="Icon URL")
is_folder: Optional[bool] = Field(
None, alias="isFolder", description="Whether this is a folder"
)
def GoogleDrivePickerField(
multiselect: bool = False,
allow_folder_selection: bool = False,
allowed_views: Optional[list[AttachmentView]] = None,
allowed_mime_types: Optional[list[str]] = None,
scopes: Optional[list[str]] = None,
title: Optional[str] = None,
description: Optional[str] = None,
placeholder: Optional[str] = None,
**kwargs,
) -> Any:
"""
Creates a Google Drive Picker input field.
Args:
multiselect: Allow selecting multiple files/folders (default: False)
allow_folder_selection: Allow selecting folders (default: False)
allowed_views: List of view types to show in picker (default: ["DOCS"])
allowed_mime_types: Filter by MIME types (e.g., ["application/pdf"])
title: Field title shown in UI
description: Field description/help text
placeholder: Placeholder text for the button
**kwargs: Additional SchemaField arguments (advanced, hidden, etc.)
Returns:
Field definition that produces:
- Single GoogleDriveFile when multiselect=False
- list[GoogleDriveFile] when multiselect=True
Example:
>>> class MyBlock(Block):
... class Input(BlockSchema):
... document: GoogleDriveFile = GoogleDrivePickerField(
... title="Select Document",
... allowed_views=["DOCUMENTS"],
... )
...
... files: list[GoogleDriveFile] = GoogleDrivePickerField(
... title="Select Multiple Files",
... multiselect=True,
... allow_folder_selection=True,
... )
"""
# Build configuration that will be sent to frontend
picker_config = {
"multiselect": multiselect,
"allow_folder_selection": allow_folder_selection,
"allowed_views": list(allowed_views) if allowed_views else ["DOCS"],
}
# Add optional configurations
if allowed_mime_types:
picker_config["allowed_mime_types"] = list(allowed_mime_types)
# Determine required scopes based on config
base_scopes = scopes if scopes is not None else []
picker_scopes: set[str] = set(base_scopes)
if allow_folder_selection:
picker_scopes.add("https://www.googleapis.com/auth/drive")
else:
# Use drive.file for minimal scope - only access files selected by user in picker
picker_scopes.add("https://www.googleapis.com/auth/drive.file")
views = set(allowed_views or [])
if "SPREADSHEETS" in views:
picker_scopes.add("https://www.googleapis.com/auth/spreadsheets.readonly")
if "DOCUMENTS" in views or "DOCS" in views:
picker_scopes.add("https://www.googleapis.com/auth/documents.readonly")
picker_config["scopes"] = sorted(picker_scopes)
# Set appropriate default value
default_value = [] if multiselect else None
# Use SchemaField to handle format properly
return SchemaField(
default=default_value,
title=title,
description=description,
placeholder=placeholder or "Choose from Google Drive",
format="google-drive-picker",
advanced=False,
json_schema_extra={
"google_drive_picker_config": picker_config,
**kwargs,
},
)
DRIVE_API_URL = "https://www.googleapis.com/drive/v3/files"
_requests = Requests(trusted_origins=["https://www.googleapis.com"])
def GoogleDriveAttachmentField(
*,
title: str,
description: str | None = None,
placeholder: str | None = None,
multiselect: bool = True,
allowed_mime_types: list[str] | None = None,
**extra: Any,
) -> Any:
return GoogleDrivePickerField(
multiselect=multiselect,
allowed_views=list(ATTACHMENT_VIEWS),
allowed_mime_types=allowed_mime_types,
title=title,
description=description,
placeholder=placeholder or "Choose files from Google Drive",
**extra,
)
async def drive_file_to_media_file(
drive_file: GoogleDriveFile, *, graph_exec_id: str, access_token: str
) -> MediaFileType:
if drive_file.is_folder:
raise ValueError("Google Drive selection must be a file.")
if not access_token:
raise ValueError("Google Drive access token is required for file download.")
url = f"{DRIVE_API_URL}/{drive_file.id}?alt=media"
response = await _requests.get(
url, headers={"Authorization": f"Bearer {access_token}"}
)
mime_type = drive_file.mime_type or response.headers.get(
"content-type", "application/octet-stream"
)
MAX_FILE_SIZE = 100 * 1024 * 1024
if len(response.content) > MAX_FILE_SIZE:
raise ValueError(
f"File too large: {len(response.content)} bytes > {MAX_FILE_SIZE} bytes"
)
base_path = Path(get_exec_file_path(graph_exec_id, ""))
base_path.mkdir(parents=True, exist_ok=True)
extension = mimetypes.guess_extension(mime_type, strict=False) or ".bin"
filename = f"{uuid.uuid4()}{extension}"
target_path = base_path / filename
await scan_content_safe(response.content, filename=filename)
await asyncio.to_thread(target_path.write_bytes, response.content)
return MediaFileType(str(target_path.relative_to(base_path)))

File diff suppressed because it is too large Load Diff

View File

@@ -1,70 +1,67 @@
import React, {
useState,
useEffect,
useCallback,
useRef,
useContext,
} from "react";
import Link from "next/link";
import { NodeProps, useReactFlow, Node as XYNode, Edge } from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import "./customnode.css";
import InputModalComponent from "../InputModalComponent";
import OutputModalComponent from "../OutputModalComponent";
import {
BlockIORootSchema,
BlockIOSubSchema,
BlockIOStringSubSchema,
Category,
NodeExecutionResult,
BlockUIType,
BlockCost,
} from "@/lib/autogpt-server-api";
import {
beautifyString,
cn,
fillObjectDefaultsFromSchema,
getValue,
hasNonNullNonObjectValue,
isObject,
parseKeys,
setNestedProperty,
} from "@/lib/utils";
import { Button } from "@/components/atoms/Button/Button";
import { TextRenderer } from "@/components/__legacy__/ui/render";
import { history } from "../history";
import NodeHandle from "../NodeHandle";
import { NodeGenericInputField, NodeTextBoxInput } from "../NodeInputs";
import { getPrimaryCategoryColor } from "@/lib/utils";
import { BuilderContext } from "../Flow/Flow";
import { Badge } from "../../../../../../components/__legacy__/ui/badge";
import NodeOutputs from "../NodeOutputs";
import { IconCoin } from "../../../../../../components/__legacy__/ui/icons";
import * as Separator from "@radix-ui/react-separator";
import * as ContextMenu from "@radix-ui/react-context-menu";
import {
Alert,
AlertDescription,
} from "../../../../../../components/molecules/Alert/Alert";
import {
DotsVerticalIcon,
TrashIcon,
CopyIcon,
ExitIcon,
Pencil1Icon,
} from "@radix-ui/react-icons";
import { InfoIcon, Key } from "@phosphor-icons/react";
import useCredits from "@/hooks/useCredits";
import { getV1GetAyrshareSsoUrl } from "@/app/api/__generated__/endpoints/integrations/integrations";
import { toast } from "@/components/molecules/Toast/use-toast";
import { Input } from "@/components/__legacy__/ui/input";
import { TextRenderer } from "@/components/__legacy__/ui/render";
import { Button } from "@/components/atoms/Button/Button";
import { Switch } from "@/components/atoms/Switch/Switch";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
import { InformationTooltip } from "@/components/molecules/InformationTooltip/InformationTooltip";
import { Switch } from "@/components/atoms/Switch/Switch";
import { toast } from "@/components/molecules/Toast/use-toast";
import useCredits from "@/hooks/useCredits";
import {
BlockCost,
BlockIORootSchema,
BlockIOStringSubSchema,
BlockIOSubSchema,
BlockUIType,
Category,
NodeExecutionResult,
} from "@/lib/autogpt-server-api";
import {
beautifyString,
cn,
fillObjectDefaultsFromSchema,
getPrimaryCategoryColor,
getValue,
hasNonNullNonObjectValue,
isObject,
parseKeys,
setNestedProperty,
} from "@/lib/utils";
import { InfoIcon, Key } from "@phosphor-icons/react";
import * as ContextMenu from "@radix-ui/react-context-menu";
import {
CopyIcon,
DotsVerticalIcon,
ExitIcon,
Pencil1Icon,
TrashIcon,
} from "@radix-ui/react-icons";
import * as Separator from "@radix-ui/react-separator";
import { Edge, NodeProps, useReactFlow, Node as XYNode } from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import Link from "next/link";
import React, {
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { Badge } from "@/components/__legacy__/ui/badge";
import { IconCoin } from "@/components/__legacy__/ui/icons";
import { Alert, AlertDescription } from "@/components/molecules/Alert/Alert";
import { BuilderContext } from "../Flow/Flow";
import { history } from "../history";
import InputModalComponent from "../InputModalComponent";
import NodeHandle from "../NodeHandle";
import { NodeGenericInputField, NodeTextBoxInput } from "../NodeInputs";
import NodeOutputs from "../NodeOutputs";
import OutputModalComponent from "../OutputModalComponent";
import "./customnode.css";
export type ConnectionData = Array<{
edge_id: string;
@@ -366,6 +363,7 @@ export const CustomNode = React.memo(
// For OUTPUT blocks, only show the 'value' (hides 'name') input connection handle
!(nodeType == BlockUIType.OUTPUT && propKey == "name");
const isConnected = isInputHandleConnected(propKey);
return (
!isHidden &&
(isRequired || isAdvancedOpen || isConnected || !isAdvanced) && (

View File

@@ -1,18 +1,15 @@
import {
ConnectionData,
CustomNodeData,
} from "@/app/(platform)/build/components/legacy-builder/CustomNode/CustomNode";
import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/AgentRunsView/components/CredentialsInputs/CredentialsInputs";
import { Calendar } from "@/components/__legacy__/ui/calendar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/__legacy__/ui/popover";
import { format } from "date-fns";
import { CalendarIcon } from "lucide-react";
import { beautifyString, cn } from "@/lib/utils";
import { Node, useNodeId, useNodesData } from "@xyflow/react";
import {
ConnectionData,
CustomNodeData,
} from "@/app/(platform)/build/components/legacy-builder/CustomNode/CustomNode";
import { Cross2Icon, Pencil2Icon, PlusIcon } from "@radix-ui/react-icons";
import { GoogleDrivePickerInput } from "@/components/contextual/GoogleDrivePicker/GoogleDrivePickerInput";
import {
BlockIOArraySubSchema,
BlockIOBooleanSubSchema,
@@ -29,22 +26,21 @@ import {
DataType,
determineDataType,
} from "@/lib/autogpt-server-api/types";
import { beautifyString, cn } from "@/lib/utils";
import { Cross2Icon, Pencil2Icon, PlusIcon } from "@radix-ui/react-icons";
import { Node, useNodeId, useNodesData } from "@xyflow/react";
import { format } from "date-fns";
import { CalendarIcon } from "lucide-react";
import React, {
FC,
useCallback,
useEffect,
useMemo,
useState,
useRef,
useState,
} from "react";
import { Button } from "../../../../../components/__legacy__/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../../../../../components/__legacy__/ui/select";
import { Button } from "@/components/__legacy__/ui/button";
import { LocalValuedInput } from "@/components/__legacy__/ui/input";
import {
MultiSelector,
MultiSelectorContent,
@@ -52,12 +48,17 @@ import {
MultiSelectorItem,
MultiSelectorList,
MultiSelectorTrigger,
} from "../../../../../components/__legacy__/ui/multiselect";
import { LocalValuedInput } from "../../../../../components/__legacy__/ui/input";
} from "@/components/__legacy__/ui/multiselect";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/__legacy__/ui/select";
import { Switch } from "@/components/atoms/Switch/Switch";
import { NodeTableInput } from "@/components/node-table-input";
import NodeHandle from "./NodeHandle";
import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/AgentRunsView/components/CredentialsInputs/CredentialsInputs";
import { Switch } from "../../../../../components/atoms/Switch/Switch";
import { NodeTableInput } from "../../../../../components/node-table-input";
type NodeObjectInputTreeProps = {
nodeId: string;
@@ -370,6 +371,22 @@ export const NodeGenericInputField: FC<{
handleInputChange={handleInputChange}
/>
);
case DataType.GOOGLE_DRIVE_PICKER: {
const pickerSchema = propSchema as any;
const config: import("@/lib/autogpt-server-api/types").GoogleDrivePickerConfig =
pickerSchema.google_drive_picker_config || {};
return (
<GoogleDrivePickerInput
config={config}
value={currentValue}
onChange={(value) => handleInputChange(propKey, value)}
error={errors[propKey]}
className={className}
showRemoveButton={true}
/>
);
}
case DataType.DATE:
case DataType.TIME:

View File

@@ -1,10 +1,15 @@
import React from "react";
import { format } from "date-fns";
import React from "react";
import { Input as DSInput } from "@/components/atoms/Input/Input";
import { Select as DSSelect } from "@/components/atoms/Select/Select";
import { MultiToggle } from "@/components/molecules/MultiToggle/MultiToggle";
// Removed shadcn Select usage in favor of DS Select for time picker
import { Button } from "@/components/atoms/Button/Button";
import { FileInput } from "@/components/atoms/FileInput/FileInput";
import { Switch } from "@/components/atoms/Switch/Switch";
import { GoogleDrivePickerInput } from "@/components/contextual/GoogleDrivePicker/GoogleDrivePickerInput";
import { TimePicker } from "@/components/molecules/TimePicker/TimePicker";
import {
BlockIOObjectSubSchema,
BlockIOSubSchema,
@@ -13,12 +18,8 @@ import {
determineDataType,
TableRow,
} from "@/lib/autogpt-server-api/types";
import { TimePicker } from "@/components/molecules/TimePicker/TimePicker";
import { FileInput } from "@/components/atoms/FileInput/FileInput";
import { useRunAgentInputs } from "./useRunAgentInputs";
import { Switch } from "@/components/atoms/Switch/Switch";
import { PlusIcon, XIcon } from "@phosphor-icons/react";
import { Button } from "@/components/atoms/Button/Button";
import { useRunAgentInputs } from "./useRunAgentInputs";
/**
* A generic prop structure for the TypeBasedInput.
@@ -90,6 +91,23 @@ export function RunAgentInputs({
);
break;
case DataType.GOOGLE_DRIVE_PICKER: {
const pickerSchema = schema as any;
const config: import("@/lib/autogpt-server-api/types").GoogleDrivePickerConfig =
pickerSchema.google_drive_picker_config || {};
innerInputElement = (
<GoogleDrivePickerInput
config={config}
value={value}
onChange={onChange}
className="w-full"
showRemoveButton={false}
/>
);
break;
}
case DataType.BOOLEAN:
innerInputElement = (
<>

View File

@@ -2,7 +2,7 @@
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 { CircleNotchIcon, FolderOpenIcon } from "@phosphor-icons/react";
import { Props, useGoogleDrivePicker } from "./useGoogleDrivePicker";
export function GoogleDrivePicker(props: Props) {
@@ -12,28 +12,46 @@ export function GoogleDrivePicker(props: Props) {
isAuthInProgress,
isLoading,
handleOpenPicker,
selectedCredential,
setSelectedCredential,
} = useGoogleDrivePicker(props);
if (!credentials || credentials.isLoading) {
return <CircleNotchIcon className="size-6 animate-spin" />;
}
if (!hasGoogleOAuth)
if (!hasGoogleOAuth) {
return (
<CredentialsInput
schema={credentials.schema}
onSelectCredentials={() => {}}
selectedCredentials={selectedCredential}
onSelectCredentials={setSelectedCredential}
hideIfSingleCredentialAvailable
/>
);
}
const hasMultipleCredentials =
credentials.savedCredentials && credentials.savedCredentials.length > 1;
return (
<Button
size="small"
onClick={handleOpenPicker}
disabled={props.disabled || isLoading || isAuthInProgress}
>
{props.buttonText || "Choose file from Google Drive"}
</Button>
<div className="flex flex-col gap-2">
{hasMultipleCredentials && (
<CredentialsInput
schema={credentials.schema}
selectedCredentials={selectedCredential}
onSelectCredentials={setSelectedCredential}
hideIfSingleCredentialAvailable={false}
/>
)}
<Button
size="small"
onClick={handleOpenPicker}
disabled={props.disabled || isLoading || isAuthInProgress}
>
<FolderOpenIcon className="size-4" />
{props.buttonText || "Choose file(s) from Google Drive"}
</Button>
</div>
);
}

View File

@@ -0,0 +1,137 @@
import { Button } from "@/components/atoms/Button/Button";
import { cn } from "@/lib/utils";
import { Cross2Icon } from "@radix-ui/react-icons";
import React, { useCallback } from "react";
import { GoogleDrivePicker } from "./GoogleDrivePicker";
import type { GoogleDrivePickerConfig } from "@/lib/autogpt-server-api/types";
export interface GoogleDrivePickerInputProps {
config: GoogleDrivePickerConfig;
value: any;
onChange: (value: any) => void;
error?: string;
className?: string;
showRemoveButton?: boolean;
}
export function GoogleDrivePickerInput({
config,
value,
onChange,
error,
className,
showRemoveButton = true,
}: GoogleDrivePickerInputProps) {
const [pickerError, setPickerError] = React.useState<string | null>(null);
const isMultiSelect = config.multiselect || false;
const currentFiles = isMultiSelect
? Array.isArray(value)
? value
: []
: value
? [value]
: [];
const handlePicked = useCallback(
(files: any[]) => {
// Clear any previous picker errors
setPickerError(null);
// Convert to GoogleDriveFile format
const convertedFiles = files.map((f) => ({
id: f.id,
name: f.name,
mimeType: f.mimeType,
url: f.url,
iconUrl: f.iconUrl,
isFolder: f.mimeType === "application/vnd.google-apps.folder",
}));
// Store based on multiselect mode
const newValue = isMultiSelect ? convertedFiles : convertedFiles[0];
onChange(newValue);
},
[isMultiSelect, onChange],
);
const handleRemoveFile = useCallback(
(idx: number) => {
if (isMultiSelect) {
const newFiles = currentFiles.filter((_: any, i: number) => i !== idx);
onChange(newFiles);
} else {
onChange(null);
}
},
[isMultiSelect, currentFiles, onChange],
);
const handleError = useCallback((error: any) => {
console.error("Google Drive Picker error:", error);
setPickerError(error instanceof Error ? error.message : String(error));
}, []);
return (
<div className={cn("flex flex-col gap-2", className)}>
{/* Picker Button */}
<GoogleDrivePicker
multiselect={config.multiselect || false}
views={config.allowed_views || ["DOCS"]}
scopes={config.scopes || ["https://www.googleapis.com/auth/drive.file"]}
disabled={false}
onPicked={handlePicked}
onCanceled={() => {
// User canceled - no action needed
}}
onError={handleError}
/>
{/* Display Selected Files */}
{currentFiles.length > 0 && (
<div className="space-y-1">
{currentFiles.map((file: any, idx: number) => (
<div
key={file.id || idx}
className={cn(
"flex items-center gap-2",
showRemoveButton
? "justify-between rounded-md border border-gray-300 bg-gray-50 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-800"
: "text-sm text-gray-600 dark:text-gray-400",
)}
>
<div className="flex items-center gap-2 overflow-hidden">
{file.iconUrl && (
<img
src={file.iconUrl}
alt=""
className="h-4 w-4 flex-shrink-0"
/>
)}
<span className="truncate" title={file.name}>
{file.name || file.id}
</span>
</div>
{showRemoveButton && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 flex-shrink-0"
onClick={() => handleRemoveFile(idx)}
>
<Cross2Icon className="h-3 w-3" />
</Button>
)}
</div>
))}
</div>
)}
{/* Error Messages */}
{error && <span className="text-sm text-red-500">{error}</span>}
{pickerError && (
<span className="text-sm text-red-500">{pickerError}</span>
)}
</div>
);
}

View File

@@ -1,6 +1,10 @@
import { getGetV1GetSpecificCredentialByIdQueryOptions } from "@/app/api/__generated__/endpoints/integrations/integrations";
import type { OAuth2Credentials } from "@/app/api/__generated__/models/oAuth2Credentials";
import { useToast } from "@/components/molecules/Toast/use-toast";
import useCredentials from "@/hooks/useCredentials";
import { useMemo, useRef, useState } from "react";
import type { CredentialsMetaInput } from "@/lib/autogpt-server-api/types";
import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo, useRef, useState } from "react";
import {
getCredentialsSchema,
GooglePickerView,
@@ -54,10 +58,15 @@ export function useGoogleDrivePicker(options: Props) {
const requestedScopes = options?.scopes || defaultScopes;
const [isLoading, setIsLoading] = useState(false);
const [isAuthInProgress, setIsAuthInProgress] = useState(false);
const [hasInsufficientScopes, setHasInsufficientScopes] = useState(false);
const [selectedCredential, setSelectedCredential] = useState<
CredentialsMetaInput | undefined
>();
const accessTokenRef = useRef<string | null>(null);
const tokenClientRef = useRef<TokenClient | null>(null);
const pickerReadyRef = useRef(false);
const credentials = useCredentials(getCredentialsSchema(requestedScopes));
const queryClient = useQueryClient();
const isReady = pickerReadyRef.current && !!tokenClientRef.current;
const { toast } = useToast();
@@ -66,10 +75,109 @@ export function useGoogleDrivePicker(options: Props) {
return credentials.savedCredentials?.length > 0;
}, [credentials]);
useEffect(() => {
if (
hasGoogleOAuth &&
credentials &&
!credentials.isLoading &&
credentials.savedCredentials?.length > 0
) {
setHasInsufficientScopes(false);
}
}, [hasGoogleOAuth, credentials]);
useEffect(() => {
if (
credentials &&
!credentials.isLoading &&
credentials.savedCredentials?.length === 1 &&
!selectedCredential
) {
setSelectedCredential({
id: credentials.savedCredentials[0].id,
type: credentials.savedCredentials[0].type,
provider: credentials.savedCredentials[0].provider,
title: credentials.savedCredentials[0].title,
});
}
}, [credentials, selectedCredential]);
async function openPicker() {
try {
await ensureLoaded();
console.log(accessTokenRef.current);
if (
hasGoogleOAuth &&
credentials &&
!credentials.isLoading &&
credentials.savedCredentials?.length > 0
) {
const credentialId =
selectedCredential?.id || credentials.savedCredentials[0].id;
try {
const queryOptions = getGetV1GetSpecificCredentialByIdQueryOptions(
"google",
credentialId,
);
const response = await queryClient.fetchQuery(queryOptions);
if (response.status === 200 && response.data) {
const cred = response.data;
if (cred.type === "oauth2") {
const oauthCred = cred as OAuth2Credentials;
if (oauthCred.access_token) {
const credentialScopes = new Set(oauthCred.scopes || []);
const requiredScopesSet = new Set(requestedScopes);
const hasRequiredScopes = Array.from(requiredScopesSet).every(
(scope) => credentialScopes.has(scope),
);
if (!hasRequiredScopes) {
const error = new Error(
"The saved Google OAuth credentials do not have the required permissions. Please sign in again with the correct permissions.",
);
toast({
title: "Insufficient Permissions",
description: error.message,
variant: "destructive",
});
setHasInsufficientScopes(true);
if (onError) onError(error);
return;
}
accessTokenRef.current = oauthCred.access_token;
buildAndShowPicker(oauthCred.access_token);
return;
}
}
}
const error = new Error(
"Failed to retrieve Google OAuth credentials. Please try signing in again.",
);
if (onError) onError(error);
return;
} catch (err) {
const error =
err instanceof Error
? err
: new Error("Failed to fetch Google OAuth credentials");
toast({
title: "Authentication Error",
description: error.message,
variant: "destructive",
});
if (onError) onError(error);
return;
}
}
const token = accessTokenRef.current || (await requestAccessToken());
buildAndShowPicker(token);
} catch (e) {
@@ -195,6 +303,9 @@ export function useGoogleDrivePicker(options: Props) {
isAuthInProgress,
handleOpenPicker: openPicker,
credentials,
hasGoogleOAuth,
hasGoogleOAuth: hasInsufficientScopes ? false : hasGoogleOAuth,
accessToken: accessTokenRef.current,
selectedCredential,
setSelectedCredential,
};
}

View File

@@ -80,6 +80,7 @@ export enum DataType {
KEY_VALUE = "key-value",
ARRAY = "array",
TABLE = "table",
GOOGLE_DRIVE_PICKER = "google-drive-picker",
}
export type BlockIOSubSchemaMeta = {
@@ -116,6 +117,43 @@ export type BlockIOArraySubSchema = BlockIOSubSchemaMeta & {
secret?: boolean;
};
export type GoogleDriveFile = {
id: string;
name?: string;
mimeType?: string;
url?: string;
iconUrl?: string;
isFolder?: boolean;
};
/** Valid view types for Google Drive Picker - matches backend AttachmentView */
export type AttachmentView =
| "DOCS"
| "DOCUMENTS"
| "SPREADSHEETS"
| "PRESENTATIONS"
| "DOCS_IMAGES"
| "FOLDERS";
export type GoogleDrivePickerConfig = {
multiselect?: boolean;
allow_folder_selection?: boolean;
allowed_views?: AttachmentView[];
allowed_mime_types?: string[];
scopes?: string[];
};
/**
* Schema for Google Drive Picker input fields.
* When multiselect=false: type="object" (single GoogleDriveFile)
* When multiselect=true: type="array" with items={ type="object" } (array of GoogleDriveFile)
*/
export type GoogleDrivePickerSchema = BlockIOSubSchemaMeta & {
type: "object" | "array";
format: "google-drive-picker";
google_drive_picker_config?: GoogleDrivePickerConfig;
};
// Table cell values are typically primitives
export type TableCellValue = string | number | boolean | null;
@@ -1151,6 +1189,13 @@ export function determineDataType(schema: BlockIOSubSchema): DataType {
return DataType.CREDENTIALS;
}
if (
"google_drive_picker_config" in schema ||
("format" in schema && schema.format === "google-drive-picker")
) {
return DataType.GOOGLE_DRIVE_PICKER;
}
// enum == SELECT
if ("enum" in schema) {
return DataType.SELECT;