Compare commits

...

17 Commits

Author SHA1 Message Date
Toran Bruce Richards
a42ef76345 Merge branch 'toran/open-1531-add-google-sheets-block' of https://github.com/Significant-Gravitas/AutoGPT into toran/open-1531-add-google-sheets-block 2024-07-21 21:16:28 +01:00
Toran Bruce Richards
7e6c808ed6 refactor: Update Google Sheets block IDs to real UUIDs 2024-07-21 21:16:18 +01:00
Toran Bruce Richards
5e3ecf547c Merge branch 'master' into toran/open-1531-add-google-sheets-block 2024-07-21 20:23:46 +01:00
Toran Bruce Richards
af534c6441 feat: Add @react-oauth/google dependency for Google OAuth integration 2024-07-21 20:17:49 +01:00
Toran Bruce Richards
ad1699eccf Extracts methods for code reuse. 2024-07-21 14:46:39 +01:00
Toran Bruce Richards
3e1f59814d run format 2024-07-21 11:58:29 +01:00
Toran Bruce Richards
4256ffcc78 Add doccumentation to regex 2024-07-21 11:57:37 +01:00
Toran Bruce Richards
8e201b5dc6 feat: Add function to extract spreadsheet ID from Google Sheets URL
Previously, the user would have to manually extract and input their speadsheet ID from the URL. This change makes it so that the user just has to input the URL.
2024-07-21 11:56:23 +01:00
Toran Bruce Richards
2bb37cc9c4 feat: Add single row read option for Google Sheets
This change allows the user to specify a single row to be read from a spreadsheet, rather than reading the entire thing or a specified range.
2024-07-21 11:15:33 +01:00
Toran Bruce Richards
c16b8763e7 refactor: Update GoogleSignInButton to use correct Google Sheets scope
The GoogleSignInButton component in GoogleSignInButton.tsx has been updated to use the correct Google Sheets scope for authentication. Previously, it was using 'https://www.googleapis.com/auth/spreadsheets.readonly', but now it uses 'https://www.googleapis.com/auth/spreadsheets'. This change ensures that the component has the necessary permissions to interact with Google Sheets.
2024-07-20 22:56:25 +01:00
Toran Bruce Richards
7d9976689e refactor: Update CustomNode component to conditionally show Google Sign-In button
The CustomNode component in CustomNode.tsx has been updated to conditionally show the Google Sign-In button based on the block type. Previously, the button was only shown for the 'GoogleSheetsBlock' type, but now it will be shown for any block type that includes 'Google' in its name. This change improves the flexibility and usability of the component.
2024-07-20 22:55:52 +01:00
Toran Bruce Richards
dda3402c1d feat: Add GoogleSheetsWriter block for writing data to Google Sheets
This commit adds the GoogleSheetsWriter block to the google-sheets-block.py file. The purpose of this change is to provide a block that allows writing data to a specified Google Sheet. The block takes inputs such as the spreadsheet ID, sheet name, row data, and access token, and appends the data as a single row to the specified sheet. The result of the append operation is returned as the output of the block.
2024-07-20 22:55:10 +01:00
Toran Bruce Richards
82cfafc057 chore: Add NEXT_PUBLIC_GOOGLE_CLIENT_ID to .env.example
This commit adds the `NEXT_PUBLIC_GOOGLE_CLIENT_ID` variable to the `.env.example` file in the `autogpt_builder` directory. The purpose of this change is to provide a placeholder for the Google client ID that will be used for authentication in the application.
2024-07-20 22:54:11 +01:00
Toran Bruce Richards
e7be6aec5a Simplify Google GoogleSheetsReader to return all data including headers 2024-07-20 22:52:24 +01:00
Toran Bruce Richards
8f0f727d48 chore: Rename GoogleSheetsBlock to GoogleSheetsReader
This commit renames the `GoogleSheetsBlock` class to `GoogleSheetsReader` in the `google-sheets-block.py` file. The purpose of this change is to provide a more accurate and descriptive name for the block, reflecting its role as a reader for Google Sheets data.
2024-07-20 21:37:15 +01:00
Toran Bruce Richards
f50307b607 Show Google Sign-In button conditionally based on the block type. 2024-07-20 21:30:20 +01:00
Toran Bruce Richards
401c92fe75 feat(block): Add Google Sheets block
Update customnode.css and layout.tsx for Google Sign-In integration

This commit adds styles to customnode.css to accommodate the Google Sign-In button. It also updates layout.tsx to wrap the entire application in the GoogleOAuthProvider component, enabling Google Sign-In functionality.
2024-07-20 21:21:59 +01:00
8 changed files with 289 additions and 27 deletions

View File

@@ -1 +1,2 @@
AGPT_SERVER_URL=http://localhost:8000/api
NEXT_PUBLIC_GOOGLE_CLIENT_ID=

View File

@@ -19,6 +19,7 @@
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.2",
"@react-oauth/google": "^0.12.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"date-fns": "^3.6.0",

View File

@@ -14,6 +14,8 @@ import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger
} from "@/components/ui/dropdown-menu";
import { GoogleOAuthProvider } from '@react-oauth/google';
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
@@ -67,14 +69,16 @@ export default function RootLayout({
defaultTheme="light"
disableTransitionOnChange
>
<div className="min-h-screen bg-gray-200 text-gray-900">
<NavBar />
<main className="mx-auto p-4">
{children}
</main>
</div>
<GoogleOAuthProvider clientId={process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID!}>
<div className="min-h-screen bg-gray-200 text-gray-900">
<NavBar />
<main className="mx-auto p-4">
{children}
</main>
</div>
</GoogleOAuthProvider>
</ThemeProvider>
</body>
</html>
);
}
}

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, FC, memo } from 'react';
import React, { useState, useEffect, FC, memo, useCallback } from 'react';
import { Handle, Position, NodeProps } from 'reactflow';
import 'reactflow/dist/style.css';
import './customnode.css';
@@ -8,6 +8,8 @@ import { Input } from './ui/input';
import { BlockSchema } from '@/lib/types';
import SchemaTooltip from './SchemaTooltip';
import { beautifyString } from '@/lib/utils';
import GoogleSignInButton from './GoogleSignInButton';
import { GoogleOAuthProvider } from '@react-oauth/google';
type CustomNodeData = {
blockType: string;
@@ -32,6 +34,7 @@ const CustomNode: FC<NodeProps<CustomNodeData>> = ({ data, id }) => {
const [activeKey, setActiveKey] = useState<string | null>(null);
const [modalValue, setModalValue] = useState<string>('');
const [errors, setErrors] = useState<{ [key: string]: string | null }>({});
const [accessToken, setAccessToken] = useState<string | null>(null);
useEffect(() => {
if (data.output_data || data.status) {
@@ -88,6 +91,15 @@ const CustomNode: FC<NodeProps<CustomNodeData>> = ({ data, id }) => {
));
};
const handleTokenChange = useCallback((token: string | null) => {
if (token !== data.hardcodedValues['access_token']) {
data.setHardcodedValues({
...data.hardcodedValues,
access_token: token
});
}
}, [data.hardcodedValues, data.setHardcodedValues]);
const handleInputChange = (key: string, value: any) => {
const keys = key.split('.');
const newValues = JSON.parse(JSON.stringify(data.hardcodedValues));
@@ -386,6 +398,13 @@ const CustomNode: FC<NodeProps<CustomNodeData>> = ({ data, id }) => {
);
}
return null;
case 'oauth2':
return (
<div key={fullKey} className="input-container">
<GoogleSignInButton onTokenChange={handleTokenChange} />
{error && <span className="error-message">{error}</span>}
</div>
);
default:
return (
<div key={fullKey} className="input-container">
@@ -425,30 +444,35 @@ const CustomNode: FC<NodeProps<CustomNodeData>> = ({ data, id }) => {
</div>
<div className="node-content">
<div className="input-section">
{data.inputSchema &&
Object.entries(data.inputSchema.properties).map(([key, schema]) => {
const isRequired = data.inputSchema.required?.includes(key);
return (isRequired || isAdvancedOpen) && (
<div key={key}>
<div className="handle-container">
<Handle
type="target"
position={Position.Left}
id={key}
style={{ background: '#555', borderRadius: '50%', width: '10px', height: '10px' }}
/>
<span className="handle-label">{schema.title || beautifyString(key)}</span>
<SchemaTooltip schema={schema} />
{data.inputSchema &&
Object.entries(data.inputSchema.properties).map(([key, schema]) => {
const isRequired = data.inputSchema.required?.includes(key);
return (isRequired || isAdvancedOpen) && (
<div key={key}>
<div className="handle-container">
<Handle
type="target"
position={Position.Left}
id={key}
style={{ background: '#555', borderRadius: '50%', width: '10px', height: '10px' }}
/>
<span className="handle-label">{schema.title || beautifyString(key)}</span>
<SchemaTooltip schema={schema} />
</div>
{renderInputField(key, schema, '', schema.title || beautifyString(key))}
</div>
{renderInputField(key, schema, '', schema.title || beautifyString(key))}
</div>
);
})}
);
})}
</div>
<div className="output-section">
{data.outputSchema && generateHandles(data.outputSchema, 'source')}
{data.outputSchema && generateHandles(data.outputSchema, 'source')}
</div>
</div>
{data.blockType.includes('Google') && (
<div className="google-signin-container">
<GoogleSignInButton onTokenChange={handleTokenChange} />
</div>
)}
{isOutputOpen && (
<div className="node-output">
<p>

View File

@@ -0,0 +1,33 @@
import React, { useEffect, useCallback } from 'react';
import { Button } from './ui/button';
import { useGoogleAuth } from '@/hooks/useGoogleAuth';
interface GoogleSignInButtonProps {
onTokenChange: (token: string | null) => void;
}
const GoogleSignInButton: React.FC<GoogleSignInButtonProps> = React.memo(({ onTokenChange }) => {
const { token, error, login, logout } = useGoogleAuth('https://www.googleapis.com/auth/spreadsheets');
const handleTokenChange = useCallback((newToken: string | null) => {
onTokenChange(newToken);
}, [onTokenChange]);
useEffect(() => {
handleTokenChange(token);
}, [token, handleTokenChange]);
if (error) {
return <div>Error: {error}</div>;
}
return token ? (
<Button onClick={logout}>Sign Out of Google Sheets</Button>
) : (
<Button onClick={() => login()}>Sign in with Google Sheets</Button>
);
});
GoogleSignInButton.displayName = 'GoogleSignInButton';
export default GoogleSignInButton;

View File

@@ -146,6 +146,11 @@
padding: 5px 10px;
}
.custom-node .google-signin-container {
margin-top: 10px;
padding: 0 10px;
}
.array-item-add {
background: #5bc0de;
border: none;

View File

@@ -0,0 +1,24 @@
import { useGoogleLogin } from '@react-oauth/google';
import { useState, useCallback } from 'react';
export const useGoogleAuth = (scope: string) => {
const [token, setToken] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const login = useGoogleLogin({
onSuccess: (tokenResponse) => {
setToken(tokenResponse.access_token);
setError(null);
},
onError: (errorResponse) => {
setError(errorResponse.error_description || 'An error occurred during login');
},
scope: scope,
});
const logout = useCallback(() => {
setToken(null);
}, []);
return { token, error, login, logout };
};

View File

@@ -0,0 +1,170 @@
import re
from typing import List, Union
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
from autogpt_server.data.block import Block, BlockOutput, BlockSchema
from autogpt_server.data.model import SchemaField
def extract_spreadsheet_id(url: str) -> str:
"""Extract the spreadsheet ID from a Google Sheets URL."""
match = re.search(
r"/d/([a-zA-Z0-9-_]+)", url
) # This works for clean urls and those with extra parameters at the end
if match:
return match.group(1)
raise ValueError("Invalid Google Sheets URL")
def get_google_sheets_service(access_token: str):
"""Create and return a Google Sheets service object."""
credentials = Credentials(access_token)
return build("sheets", "v4", credentials=credentials)
def get_spreadsheet_and_id(spreadsheet_url: str, access_token: str):
"""Extract spreadsheet ID and create a Google Sheets service object."""
spreadsheet_id = extract_spreadsheet_id(spreadsheet_url)
service = get_google_sheets_service(access_token)
return service.spreadsheets(), spreadsheet_id
class GoogleSheetsWriter(Block):
class Input(BlockSchema):
spreadsheet_url: str = SchemaField(
description="The link to the Google Sheet to write to.",
placeholder="https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
)
sheet_name: str = SchemaField(
description="The name of the sheet to append data to",
placeholder="Sheet1",
default="Sheet1",
)
row_data: list = SchemaField(
description="The data to append as a single row",
placeholder="['John Doe', 'johndoe@example.com', '30']",
)
access_token: str = SchemaField(
description="Google OAuth2 access token. Click 'Sign in with Google' below to automatically generate this token. Keep this secret!",
placeholder="Sign in with Google below",
secret=True,
)
class Output(BlockSchema):
result: dict = SchemaField(description="The result of the append operation")
def __init__(self):
super().__init__(
id="919e684b-1c2a-4a9a-ac8b-d073de3c15b2",
input_schema=GoogleSheetsWriter.Input,
output_schema=GoogleSheetsWriter.Output,
)
def run(self, input_data: Input) -> BlockOutput:
try:
sheet, spreadsheet_id = get_spreadsheet_and_id(input_data.spreadsheet_url, input_data.access_token)
result = (
sheet.values()
.get(spreadsheetId=spreadsheet_id, range=f"{input_data.sheet_name}!A:A")
.execute()
)
values = result.get("values", [])
next_row = len(values) + 1
# Prepare the range for appending
append_range = f"{input_data.sheet_name}!A{next_row}"
body = {"values": [input_data.row_data]}
result = (
sheet.values()
.append(
spreadsheetId=spreadsheet_id,
range=append_range,
valueInputOption="USER_ENTERED",
insertDataOption="INSERT_ROWS",
body=body,
)
.execute()
)
yield "result", {
"success": True,
"updated_range": result.get("updates", {}).get("updatedRange"),
"updated_rows": result.get("updates", {}).get("updatedRows"),
"updated_cells": result.get("updates", {}).get("updatedCells"),
}
except Exception as e:
yield "result", {"success": False, "error": str(e)}
class GoogleSheetsReader(Block):
class Input(BlockSchema):
spreadsheet_url: str = SchemaField(
description="The link to the Google Sheet to read from.",
placeholder="https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms",
)
read_single_row: bool = SchemaField(
description="Do you want to read just one row? Select 'True' for a single row, 'False' for a range.",
default=False,
)
sheet_name: str = SchemaField(
description="The name of the sheet you want to read from (e.g., 'Sheet1')",
placeholder="Sheet1",
default="Sheet1",
)
row_number: int = SchemaField(
description="If reading a single row, which row number do you want? (e.g., 3 for the third row)",
placeholder="3",
default=1,
)
cell_range: str = SchemaField(
description="If reading a range, what range of cells do you want? (e.g., 'A1:E10' for the first 10 rows of columns A to E)",
placeholder="A1:E10",
default="A1:Z1000",
)
access_token: str = SchemaField(
placeholder="Sign in with Google below",
description="Google OAuth2 access token. Click 'Sign in with Google' below to automatically generate this token. Keep this secret!",
secret=True,
)
class Output(BlockSchema):
data: Union[List[str], List[List[str]]] = SchemaField(
description="The information read from your Google Sheet. For a single row, it's a simple list. For multiple rows, it's a list of lists."
)
def __init__(self):
super().__init__(
id="460e5d53-7038-4a8c-9f75-ec0b593fb337",
input_schema=GoogleSheetsReader.Input,
output_schema=GoogleSheetsReader.Output,
)
def run(self, input_data: Input) -> BlockOutput:
try:
sheet, spreadsheet_id = get_spreadsheet_and_id(input_data.spreadsheet_url, input_data.access_token)
if input_data.read_single_row:
range_to_read = f"{input_data.sheet_name}!A{input_data.row_number}:ZZ{input_data.row_number}"
else:
range_to_read = f"{input_data.sheet_name}!{input_data.cell_range}"
result = sheet.values().get(spreadsheetId=spreadsheet_id, range=range_to_read).execute()
values = result.get("values", [])
if not values:
yield "data", {
"message": "No data found in the specified range or row."
}
else:
if input_data.read_single_row:
# Return a single list for a single row
yield "data", values[0] if values else []
else:
# Return a list of lists for multiple rows
yield "data", values
except Exception as e:
yield "data", {"error": f"Oops! Something went wrong: {str(e)}"}