mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-01-09 15:17:59 -05:00
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:
198
autogpt_platform/backend/backend/blocks/google/_drive.py
Normal file
198
autogpt_platform/backend/backend/blocks/google/_drive.py
Normal 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
@@ -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) && (
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 = (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user