Merge remote-tracking branch 'origin/dev' into swiftyos/open-1920-marketplace-home-components

This commit is contained in:
SwiftyOS
2024-10-24 11:55:40 +02:00
11 changed files with 487 additions and 73 deletions

View File

@@ -166,7 +166,8 @@ class AppService(AppProcess, ABC):
@conn_retry("Pyro", "Starting Pyro Service")
def __start_pyro(self):
daemon = Pyro5.api.Daemon(host=self.get_host(), port=self.get_port())
host = Config().pyro_host
daemon = Pyro5.api.Daemon(host=host, port=self.get_port())
self.uri = daemon.register(self, objectId=self.service_name)
logger.info(f"[{self.service_name}] Connected to Pyro; URI = {self.uri}")
daemon.requestLoop()
@@ -187,9 +188,8 @@ def get_service_client(service_type: Type[AS]) -> AS:
class DynamicClient:
@conn_retry("Pyro", f"Connecting to [{service_name}]")
def __init__(self):
host = service_type.get_host()
port = service_type.get_port()
uri = f"PYRO:{service_type.service_name}@{host}:{port}"
host = os.environ.get(f"{service_name.upper()}_HOST", "localhost")
uri = f"PYRO:{service_type.service_name}@{host}:{service_type.get_port()}"
logger.debug(f"Connecting to service [{service_name}]. URI = {uri}")
self.proxy = Pyro5.api.Proxy(uri)
# Attempt to bind to ensure the connection is established

View File

@@ -66,6 +66,7 @@ services:
- ENABLE_AUTH=true
- PYRO_HOST=0.0.0.0
- EXECUTIONMANAGER_HOST=executor
- DATABASEMANAGER_HOST=executor
- FRONTEND_BASE_URL=http://localhost:3000
- BACKEND_CORS_ALLOW_ORIGINS=["http://localhost:3000"]
ports:

View File

@@ -22,6 +22,11 @@ export async function login(values: z.infer<typeof loginFormSchema>) {
const { data, error } = await supabase.auth.signInWithPassword(values);
if (error) {
if (error.status == 400) {
// Hence User is not present
redirect("/signup");
}
return error.message;
}
@@ -33,35 +38,3 @@ export async function login(values: z.infer<typeof loginFormSchema>) {
redirect("/");
});
}
export async function signup(values: z.infer<typeof loginFormSchema>) {
"use server";
return await Sentry.withServerActionInstrumentation(
"signup",
{},
async () => {
const supabase = createServerClient();
if (!supabase) {
redirect("/error");
}
// We are sure that the values are of the correct type because zod validates the form
const { data, error } = await supabase.auth.signUp(values);
if (error) {
if (error.message.includes("P0001")) {
return "Please join our waitlist for your turn: https://agpt.co/waitlist";
}
return error.message;
}
if (data.session) {
await supabase.auth.setSession(data.session);
}
revalidatePath("/", "layout");
redirect("/");
},
);
}

View File

@@ -98,22 +98,10 @@ export default function LoginPage() {
setFeedback(null);
};
const onSignup = async (data: z.infer<typeof loginFormSchema>) => {
if (await form.trigger()) {
setIsLoading(true);
const error = await signup(data);
setIsLoading(false);
if (error) {
setFeedback(error);
return;
}
setFeedback(null);
}
};
return (
<div className="flex h-[80vh] items-center justify-center">
<div className="w-full max-w-md space-y-6 rounded-lg p-8 shadow-md">
<h1 className="text-lg font-medium">Log in to your Account </h1>
{/* <div className="mb-6 space-y-2">
<Button
className="w-full"
@@ -210,23 +198,19 @@ export default function LoginPage() {
</FormItem>
)}
/>
<div className="mb-6 mt-6 flex w-full space-x-4">
<div className="mb-6 mt-8 flex w-full space-x-4">
<Button
className="flex w-1/2 justify-center"
className="flex w-full justify-center"
type="submit"
disabled={isLoading}
>
Log in
</Button>
<Button
className="flex w-1/2 justify-center"
variant="outline"
type="button"
onClick={form.handleSubmit(onSignup)}
disabled={isLoading}
>
Sign up
</Button>
</div>
<div className="w-full text-center">
<Link href={"/signup"} className="w-fit text-xs hover:underline">
Create a new Account
</Link>
</div>
</form>
<p className="text-sm text-red-500">{feedback}</p>

View File

@@ -0,0 +1,46 @@
"use server";
import { createServerClient } from "@/lib/supabase/server";
import * as Sentry from "@sentry/nextjs";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
const SignupFormSchema = z.object({
email: z.string().email().min(2).max(64),
password: z.string().min(6).max(64),
});
export async function signup(values: z.infer<typeof SignupFormSchema>) {
"use server";
return await Sentry.withServerActionInstrumentation(
"signup",
{},
async () => {
const supabase = createServerClient();
if (!supabase) {
redirect("/error");
}
// We are sure that the values are of the correct type because zod validates the form
const { data, error } = await supabase.auth.signUp(values);
if (error) {
if (error.message.includes("P0001")) {
return "Please join our waitlist for your turn: https://agpt.co/waitlist";
}
if (error.code?.includes("user_already_exists")) {
redirect("/login");
}
return error.message;
}
if (data.session) {
await supabase.auth.setSession(data.session);
}
revalidatePath("/", "layout");
redirect("/");
},
);
}

View File

@@ -0,0 +1,225 @@
"use client";
import useUser from "@/hooks/useUser";
import { signup } from "./actions";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { useForm } from "react-hook-form";
import { Input } from "@/components/ui/input";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { PasswordInput } from "@/components/PasswordInput";
import { FaGoogle, FaGithub, FaDiscord, FaSpinner } from "react-icons/fa";
import { useState } from "react";
import { useSupabase } from "@/components/SupabaseProvider";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Checkbox } from "@/components/ui/checkbox";
const signupFormSchema = z.object({
email: z.string().email().min(2).max(64),
password: z.string().min(6).max(64),
agreeToTerms: z.boolean().refine((value) => value === true, {
message: "You must agree to the Terms of Use and Privacy Policy",
}),
});
export default function LoginPage() {
const { supabase, isLoading: isSupabaseLoading } = useSupabase();
const { user, isLoading: isUserLoading } = useUser();
const [feedback, setFeedback] = useState<string | null>(null);
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const form = useForm<z.infer<typeof signupFormSchema>>({
resolver: zodResolver(signupFormSchema),
defaultValues: {
email: "",
password: "",
agreeToTerms: false,
},
});
if (user) {
console.log("User exists, redirecting to home");
router.push("/");
}
if (isUserLoading || isSupabaseLoading || user) {
return (
<div className="flex h-[80vh] items-center justify-center">
<FaSpinner className="mr-2 h-16 w-16 animate-spin" />
</div>
);
}
if (!supabase) {
return (
<div>
User accounts are disabled because Supabase client is unavailable
</div>
);
}
async function handleSignInWithProvider(
provider: "google" | "github" | "discord",
) {
const { data, error } = await supabase!.auth.signInWithOAuth({
provider: provider,
options: {
redirectTo:
process.env.AUTH_CALLBACK_URL ??
`http://localhost:3000/auth/callback`,
},
});
if (!error) {
setFeedback(null);
return;
}
setFeedback(error.message);
}
const onSignup = async (data: z.infer<typeof signupFormSchema>) => {
if (await form.trigger()) {
setIsLoading(true);
const error = await signup(data);
setIsLoading(false);
if (error) {
setFeedback(error);
return;
}
setFeedback(null);
}
};
return (
<div className="flex h-[80vh] items-center justify-center">
<div className="w-full max-w-md space-y-6 rounded-lg p-8 shadow-md">
<h1 className="text-lg font-medium">Create a New Account</h1>
{/* <div className="mb-6 space-y-2">
<Button
className="w-full"
onClick={() => handleSignInWithProvider("google")}
variant="outline"
type="button"
disabled={isLoading}
>
<FaGoogle className="mr-2 h-4 w-4" />
Sign in with Google
</Button>
<Button
className="w-full"
onClick={() => handleSignInWithProvider("github")}
variant="outline"
type="button"
disabled={isLoading}
>
<FaGithub className="mr-2 h-4 w-4" />
Sign in with GitHub
</Button>
<Button
className="w-full"
onClick={() => handleSignInWithProvider("discord")}
variant="outline"
type="button"
disabled={isLoading}
>
<FaDiscord className="mr-2 h-4 w-4" />
Sign in with Discord
</Button>
</div> */}
<Form {...form}>
<form onSubmit={form.handleSubmit(onSignup)}>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="mb-4">
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="user@email.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<PasswordInput placeholder="password" {...field} />
</FormControl>
<FormDescription>
Password needs to be at least 6 characters long
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="agreeToTerms"
render={({ field }) => (
<FormItem className="mt-4 flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>
I agree to the{" "}
<Link
href="https://auto-gpt.notion.site/Terms-of-Use-11400ef5bece80d0b087d7831c5fd6bf"
className="underline"
>
Terms of Use
</Link>{" "}
and{" "}
<Link
href="https://www.notion.so/auto-gpt/Privacy-Policy-ab11c9c20dbd4de1a15dcffe84d77984"
className="underline"
>
Privacy Policy
</Link>
</FormLabel>
<FormMessage />
</div>
</FormItem>
)}
/>
<div className="mb-6 mt-8 flex w-full space-x-4">
<Button
className="flex w-full justify-center"
variant="outline"
type="button"
onClick={form.handleSubmit(onSignup)}
disabled={isLoading}
>
Sign up
</Button>
</div>
<div className="w-full text-center">
<Link href={"/login"} className="w-fit text-xs hover:underline">
Already a member? Log In here
</Link>
</div>
</form>
<p className="text-sm text-red-500">{feedback}</p>
</Form>
</div>
</div>
);
}

View File

@@ -26,8 +26,12 @@ import {
import "@xyflow/react/dist/style.css";
import { CustomNode } from "./CustomNode";
import "./flow.css";
import { Link } from "@/lib/autogpt-server-api";
import { getTypeColor, filterBlocksByType } from "@/lib/utils";
import { BlockUIType, Link } from "@/lib/autogpt-server-api";
import {
getTypeColor,
filterBlocksByType,
findNewlyAddedBlockCoordinates,
} from "@/lib/utils";
import { history } from "./history";
import { CustomEdge } from "./CustomEdge";
import ConnectionLine from "./ConnectionLine";
@@ -57,6 +61,15 @@ type FlowContextType = {
getNextNodeId: () => string;
};
export type NodeDimension = {
[nodeId: string]: {
x: number;
y: number;
width: number;
height: number;
};
};
export const FlowContext = createContext<FlowContextType | null>(null);
const FlowEditor: React.FC<{
@@ -64,8 +77,14 @@ const FlowEditor: React.FC<{
template?: boolean;
className?: string;
}> = ({ flowID, template, className }) => {
const { addNodes, addEdges, getNode, deleteElements, updateNode } =
useReactFlow<CustomNode, CustomEdge>();
const {
addNodes,
addEdges,
getNode,
deleteElements,
updateNode,
setViewport,
} = useReactFlow<CustomNode, CustomEdge>();
const [nodeId, setNodeId] = useState<number>(1);
const [copiedNodes, setCopiedNodes] = useState<CustomNode[]>([]);
const [copiedEdges, setCopiedEdges] = useState<CustomEdge[]>([]);
@@ -110,6 +129,9 @@ const FlowEditor: React.FC<{
const TUTORIAL_STORAGE_KEY = "shepherd-tour";
// It stores the dimension of all nodes with position as well
const [nodeDimensions, setNodeDimensions] = useState<NodeDimension>({});
useEffect(() => {
if (params.get("resetTutorial") === "true") {
localStorage.removeItem(TUTORIAL_STORAGE_KEY);
@@ -402,16 +424,36 @@ const FlowEditor: React.FC<{
return;
}
// Calculate the center of the viewport considering zoom
const viewportCenter = {
x: (window.innerWidth / 2 - x) / zoom,
y: (window.innerHeight / 2 - y) / zoom,
};
/*
Calculate a position to the right of the newly added block, allowing for some margin.
If adding to the right side causes the new block to collide with an existing block, attempt to place it at the bottom or left.
Why not the top? Because the height of the new block is unknown.
If it still collides, run a loop to find the best position where it does not collide.
Then, adjust the canvas to center on the newly added block.
Note: The width is known, e.g., w = 300px for a note and w = 500px for others, but the height is dynamic.
*/
// Alternative: We could also use D3 force, Intersection for this (React flow Pro examples)
const viewportCoordinates =
nodeDimensions && Object.keys(nodeDimensions).length > 0
? // we will get all the dimension of nodes, then store
findNewlyAddedBlockCoordinates(
nodeDimensions,
(nodeSchema.uiType == BlockUIType.NOTE ? 300 : 500) / zoom,
60 / zoom,
zoom,
)
: // we will get all the dimension of nodes, then store
{
x: (window.innerWidth / 2 - x) / zoom,
y: (window.innerHeight / 2 - y) / zoom,
};
const newNode: CustomNode = {
id: nodeId.toString(),
type: "custom",
position: viewportCenter, // Set the position to the calculated viewport center
position: viewportCoordinates, // Set the position to the calculated viewport center
data: {
blockType: nodeType,
blockCosts: nodeSchema.costs,
@@ -433,6 +475,15 @@ const FlowEditor: React.FC<{
setNodeId((prevId) => prevId + 1);
clearNodesStatusAndOutput(); // Clear status and output when a new node is added
setViewport(
{
x: -viewportCoordinates.x * zoom + window.innerWidth / 2,
y: -viewportCoordinates.y * zoom + window.innerHeight / 2 - 100,
zoom: 0.8,
},
{ duration: 500 },
);
history.push({
type: "ADD_NODE",
payload: { node: { ...newNode, ...newNode.data } },
@@ -442,8 +493,10 @@ const FlowEditor: React.FC<{
},
[
nodeId,
setViewport,
availableNodes,
addNodes,
nodeDimensions,
deleteElements,
clearNodesStatusAndOutput,
x,
@@ -452,6 +505,38 @@ const FlowEditor: React.FC<{
],
);
const findNodeDimensions = useCallback(() => {
const newNodeDimensions: NodeDimension = nodes.reduce((acc, node) => {
const nodeElement = document.querySelector(
`[data-id="custom-node-${node.id}"]`,
);
if (nodeElement) {
const rect = nodeElement.getBoundingClientRect();
const { left, top, width, height } = rect;
// Convert screen coordinates to flow coordinates
const flowX = (left - x) / zoom;
const flowY = (top - y) / zoom;
const flowWidth = width / zoom;
const flowHeight = height / zoom;
acc[node.id] = {
x: flowX,
y: flowY,
width: flowWidth,
height: flowHeight,
};
}
return acc;
}, {} as NodeDimension);
setNodeDimensions(newNodeDimensions);
}, [nodes, x, y, zoom]);
useEffect(() => {
findNodeDimensions();
}, [nodes, findNodeDimensions]);
const handleUndo = () => {
history.undo();
};

View File

@@ -1,4 +1,4 @@
import React from "react";
import React, { useCallback, useEffect } from "react";
import {
Popover,
PopoverContent,
@@ -15,6 +15,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useToast } from "@/components/ui/use-toast";
interface SaveControlProps {
agentMeta: GraphMeta | null;
@@ -52,14 +53,35 @@ export const SaveControl = ({
// Determines if we're saving a template or an agent
let isTemplate = agentMeta?.is_template ? true : undefined;
const handleSave = () => {
const handleSave = useCallback(() => {
onSave(isTemplate);
};
}, [onSave, isTemplate]);
const getType = () => {
return agentMeta?.is_template ? "template" : "agent";
};
const { toast } = useToast();
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key === "s") {
event.preventDefault(); // Stop the browser default action
handleSave(); // Call your save function
toast({
duration: 2000,
title: "All changes saved successfully!",
});
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [handleSave]);
return (
<Popover open={pinSavePopover ? true : undefined}>
<Tooltip delayDuration={500}>

View File

@@ -1,6 +1,7 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
import { Category } from "./autogpt-server-api/types";
import { NodeDimension } from "@/components/Flow";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
@@ -216,3 +217,78 @@ export function getBehaveAs(): BehaveAs {
export const LOCALES = process.env.NEXT_PUBLIC_LOCALES?.split(",") || ["en"];
export const DEFAULT_LOCALE = process.env.NEXT_PUBLIC_DEFAULT_LOCALE || "en";
function rectanglesOverlap(
rect1: { x: number; y: number; width: number; height?: number },
rect2: { x: number; y: number; width: number; height?: number },
): boolean {
const x1 = rect1.x,
y1 = rect1.y,
w1 = rect1.width,
h1 = rect1.height ?? 100;
const x2 = rect2.x,
y2 = rect2.y,
w2 = rect2.width,
h2 = rect2.height ?? 100;
// Check if the rectangles do not overlap
return !(x1 + w1 <= x2 || x1 >= x2 + w2 || y1 + h1 <= y2 || y1 >= y2 + h2);
}
export function findNewlyAddedBlockCoordinates(
nodeDimensions: NodeDimension,
newWidth: number,
margin: number,
zoom: number,
) {
const nodeDimensionArray = Object.values(nodeDimensions);
for (let i = nodeDimensionArray.length - 1; i >= 0; i--) {
const lastNode = nodeDimensionArray[i];
const lastNodeHeight = lastNode.height ?? 100;
// Right of the last node
let newX = lastNode.x + lastNode.width + margin;
let newY = lastNode.y;
let newRect = { x: newX, y: newY, width: newWidth, height: 100 / zoom };
const collisionRight = nodeDimensionArray.some((node) =>
rectanglesOverlap(newRect, node),
);
if (!collisionRight) {
return { x: newX, y: newY };
}
// Left of the last node
newX = lastNode.x - newWidth - margin;
newRect = { x: newX, y: newY, width: newWidth, height: 100 / zoom };
const collisionLeft = nodeDimensionArray.some((node) =>
rectanglesOverlap(newRect, node),
);
if (!collisionLeft) {
return { x: newX, y: newY };
}
// Below the last node
newX = lastNode.x;
newY = lastNode.y + lastNodeHeight + margin;
newRect = { x: newX, y: newY, width: newWidth, height: 100 / zoom };
const collisionBelow = nodeDimensionArray.some((node) =>
rectanglesOverlap(newRect, node),
);
if (!collisionBelow) {
return { x: newX, y: newY };
}
}
// Default position if no space is found
return {
x: 0,
y: 0,
};
}

View File

@@ -106,6 +106,7 @@ env:
SUPABASE_URL: "https://adfjtextkuilwuhzdjpf.supabase.co"
AGENTSERVER_HOST: "autogpt-server.dev-agpt.svc.cluster.local"
EXECUTIONMANAGER_HOST: "autogpt-server-executor.dev-agpt.svc.cluster.local"
DATABASEMANAGER_HOST: "autogpt-server-executor.dev-agpt.svc.cluster.local"
secrets:
ANTHROPIC_API_KEY: "AgBllA6KzTdyLs6Tc+HrwIeSjdsPQxdU/4qpqT64H4K3nTehS6kpCW1qtH6eBChs1v+m857sUgsrB9u8+P0aAa3DcgZ/gNg+G1GX6vAY2NJvP/2Q+Hiwi1cAn+R3ChHejG9P2C33hTa6+V9cpUI9xUWOwWLOIQZpLvAc7ltsi0ZJ06qFO0Zhj+H9K768h7U3XaivwywX7PT7BnUTiT6AQkAwD2misBkeSQZdsllOD0th3b2245yieqal9osZHlSlslI9c6EMpH0n+szSND7goyjgsik0Tb0xJU6kGggdcw9hl4x91rYDYNPs0hFES9HUxzfiAid6Y2rDUVBXoNg7K7pMR6/foIkl+gCg/1lqOS0FRlUVyAQGJEx6XphyX/SftgLaI7obaVnzjErrpLWY1ZRiD8VVZD40exf8FddGOXwPvxYHrrrPotlTDLONZMn4Fl46tJCTsoQfHCjco+sz7/nLMMnHx+l1D0eKBuGPVsKTtbWozhLCNuWEgcWb4kxJK5sd1g/GylD43g8hFW531Vbpk1J1rpf7Hurd/aTUjwSXmdxB2qXTT4HRG+Us6PnhMIuf/yxilTs4WNShY0zHhYgnQFSM3oCTL6XXG1dqdOwY2k6+k2wCQtpK45boVN5PpBrQuDuFdWb/jM5jH6L8ns0dMMlY3lHM459u7FEn8rum/xXdP/JvpFb+yct3Rgc54SOT5HuVUNAHzzmbWhY4RG4b3i21L2SlsVUwjKvu+PlN4MN5KPilvHe3yODXZu0Gp0ClzDNZQiKQU67H0uYr6eRccMDsHtMlPELqnjyQZ+OriydzB3qXidAkguKNmzPypz0LyTMnry7YpNRGyUw="

View File

@@ -103,6 +103,7 @@ env:
SUPABASE_URL: "https://bgwpwdsxblryihinutbx.supabase.co"
AGENTSERVER_HOST: "autogpt-server.prod-agpt.svc.cluster.local"
EXECUTIONMANAGER_HOST: "autogpt-server-executor.prod-agpt.svc.cluster.local"
DATABASEMANAGER_HOST: "autogpt-server-executor.prod-agpt.svc.cluster.local"
secrets:
ANTHROPIC_API_KEY: "AgCf0CUyhYluMW13zvdScIzF50c1u4P3sUkKZjwe2lJGil/WrxN1r+GQGoLzjMn8ODANV7FiJN2+Y+ilVgpf0tVA9uEWLCL/OguNshRYWfNfU0PCgciXvz+Cy8xILfJW5SIZvZgDV5zMbzXeBomJYq+qFpr+PRyiIzA6ciHK/ZuItcGBB0FMdJ6w2gvAlLTFmAK0ekyXTzYidPEkBp+DA4jJXuzjXGd4U8iC4IcrSs/o0eaqfMQSOBRc7w/6SK+YDUnWypc2awBX4qNwqKbQRYAT59lihy/B0D4BhjjiUb2bAlzNWP0STsJONrOPbnHzuvipm1xpk+1bdYFpkqJAf9rk9GOPAMfB5f/kOdmaoj9jdQN55NIomSzub+KnSGt+m4G6YlEnUf2ZBZTKTeWO1jzk0gnzrdFZclPq/9Dd0qUBsZ/30KjbBRJyL9SexwxpfMoaf6dKJHcsOdOevaCpMQZaQ/AjcFZRtntw8mLALJzTZbTq7Gb6h25blwe1Oi6DrOuTrWT+OMHeUJcDQA3q1rJERa4xV0wLjYraCTerezhZgjMvfRD1Ykm5S+1U9hzsZUZZQS6OEEIS0BaOfYugt3DiFSNLrIUwVcYbl5geLoiMW6oSukEeb4s2AukRqKkMYz8/stjCgJB2NiarVi2NIaDvgaXWLgJxNxxovgtHyS4RR8WpRPdWJdjAs6RH13ve42a35S2m65jvUNg875GSO8Eo1izYH6q2LvJgGmlTfMworP6O2ryZO9tBjNS58UYxM8EqvtXLVktA0TYlK7wlF2NzA/waIMmiOiKJrb8YnQF28ePxYnmQSqqe2ZpwSiDBsDNrzfZvvTk9Ai81qu8="