diff --git a/.eslintrc.js b/.eslintrc.js index 07eb52321..e0ed12a42 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,12 +1,10 @@ module.exports = { extends: ['next'], ignorePatterns: ['db/supabase.ts'], + plugins: ['unused-imports', 'tailwindcss'], rules: { '@next/next/no-html-link-for-pages': 'off', 'react/jsx-key': 'off', - }, - plugins: ['unused-imports', 'tailwindcss'], - rules: { 'tailwindcss/enforces-negative-arbitrary-values': 'warn', 'tailwindcss/enforces-shorthand': 'warn', 'tailwindcss/migration-from-tailwind-2': 'warn', @@ -35,4 +33,4 @@ module.exports = { }, ], }, -} \ No newline at end of file +} diff --git a/app/_lib/PostHogProvider.tsx b/app/_lib/PostHogProvider.tsx new file mode 100644 index 000000000..481b34da2 --- /dev/null +++ b/app/_lib/PostHogProvider.tsx @@ -0,0 +1,17 @@ +'use client' + +import { ReactNode } from 'react' +import { PostHogProvider as OriginalPostHogProvider } from 'posthog-js/react' +import posthog from 'posthog-js' +import * as posthogBrowser from '../../utils/posthogBrowser' + +posthogBrowser.maybeInit() + +// Based on https://posthog.com/tutorials/nextjs-app-directory-analytics +export function PostHogProvider({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} diff --git a/app/_lib/SupabaseProvider.tsx b/app/_lib/SupabaseProvider.tsx new file mode 100644 index 000000000..42894f800 --- /dev/null +++ b/app/_lib/SupabaseProvider.tsx @@ -0,0 +1,29 @@ +'use client' + +import { ReactNode, useState } from 'react' +import { SessionContextProvider } from '@supabase/auth-helpers-react' +import { Database } from 'db/supabase' +import { createBrowserSupabaseClient } from '@supabase/auth-helpers-nextjs' +import { clientCreds } from '../../db/credentials' + +// Based on https://github.com/supabase/auth-helpers/pull/397 +export function SupabaseProvider({ + children, + initialSession, +}: { + children: ReactNode; + initialSession: any; +}) { + const [supabaseClient] = useState(() => + createBrowserSupabaseClient(clientCreds) + ) + + return ( + + {children} + + ) +} diff --git a/app/_middleware.ts b/app/_middleware.ts new file mode 100644 index 000000000..45110acd6 --- /dev/null +++ b/app/_middleware.ts @@ -0,0 +1,17 @@ +import { createMiddlewareSupabaseClient } from '@supabase/auth-helpers-nextjs' +import { NextResponse } from 'next/server' + +import type { NextRequest } from 'next/server' +import { Database } from 'db/supabase' + +// From: https://supabase.com/docs/guides/auth/auth-helpers/nextjs#managing-session-with-middleware +// > When using the Supabase client on the server, you must perform extra steps to ensure the user's auth session remains active. +// > Since the user's session is tracked in a cookie, we need to read this cookie and update it if necessary. +// > Next.js Server Components allow you to read a cookie but not write back to it. +// > Middleware on the other hand allow you to both read and write to cookies. +export async function middleware(req: NextRequest) { + const res = NextResponse.next() + const supabase = createMiddlewareSupabaseClient({ req, res }) + await supabase.auth.getSession() + return res +} diff --git a/app/agent/smol-developer/layout.tsx b/app/agent/smol-developer/layout.tsx new file mode 100644 index 000000000..66631050d --- /dev/null +++ b/app/agent/smol-developer/layout.tsx @@ -0,0 +1,29 @@ +import clsx from 'clsx' +import { Inter } from 'next/font/google' +import { ReactNode } from 'react' + +const inter = Inter({ + subsets: ['latin'], + variable: '--font-inter', +}) + +// This layout is slightly overkill, but it matches the original Layout used in /pages, let's consider simplifying after full migration +// FIXME: Implement tracking from components/Layout or re-use it instead of this one +export default function Layout({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ) +} diff --git a/app/agent/smol-developer/page.tsx b/app/agent/smol-developer/page.tsx new file mode 100644 index 000000000..a7e314a37 --- /dev/null +++ b/app/agent/smol-developer/page.tsx @@ -0,0 +1,190 @@ +'use client' + +import { Github } from 'lucide-react' +import { useRouter } from 'next/navigation' +import { useEffect } from 'react' +import Link from 'next/link' +import Image from 'next/image' +import { + useSessionContext, + useSupabaseClient, + useUser, +} from '@supabase/auth-helpers-react' +import { usePostHog } from 'posthog-js/react' + +import SpinnerIcon from 'components/Spinner' +import StarUs from 'components/StarUs' + +function SmolDeveloper() { + const supabaseClient = useSupabaseClient() + const user = useUser() + const sessionCtx = useSessionContext() + const router = useRouter() + const posthog = usePostHog() + + useEffect(() => { + if (user) { + router.push('/agent/smol-developer/setup') + } + }, [user, router]) + + async function signInWithGitHub() { + await supabaseClient.auth.signInWithOAuth({ + provider: 'github', + options: { + redirectTo: window.location.href, + scopes: 'email', + }, + }) + } + + return ( +
+
+
+ +
+
+ + + posthog?.capture('clicked link to e2b homepage') + } + > + + Runs on e2b + + + + +

+ Your personal AI developer. +
+ With a single click. +

+
+

+ Get your own AI developer that's powered by the{' '} + + posthog?.capture('clicked link', { + url: 'https://github.com/smol-ai/developer', + }) + } + > + smol developer + {' '} + AI agent. You specify the instructions and then let smol developer + do the work for you. +

+
+ +
+ {/* Also show the spinner when session is loaded and the user is also loaded because we're just waiting for the redirect to /setup */} + {!sessionCtx.isLoading && user && ( +
+ +
+ )} +
+ {sessionCtx.isLoading && ( +
+ +
+ )} + {!sessionCtx.isLoading && !user && ( + + )} + + posthog?.capture('clicked link', { + url: 'https://github.com/smol-ai/developer', + }) + } + > + Learn about smol developer + +
+
+
+
+
+ App screenshot +
+
+
+
+ ) +} + +export default SmolDeveloper diff --git a/app/agent/smol-developer/setup/page.tsx b/app/agent/smol-developer/setup/page.tsx new file mode 100644 index 000000000..89f954e9d --- /dev/null +++ b/app/agent/smol-developer/setup/page.tsx @@ -0,0 +1,307 @@ +'use client' + +import { + useState, + useCallback, + useEffect, +} from 'react' +import useSWRMutation from 'swr/mutation' +import { + useUser, + useSupabaseClient, useSession, +} from '@supabase/auth-helpers-react' +import dynamic from 'next/dynamic' +import { usePostHog } from 'posthog-js/react' +import { nanoid } from 'nanoid' +import {redirect, useRouter} from 'next/navigation' + +import Steps from 'components/Steps' +import { useGitHubClient } from 'hooks/useGitHubClient' +import { useRepositories } from 'hooks/useRepositories' +import { useLocalStorage } from 'hooks/useLocalStorage' +import useListenOnMessage from 'hooks/useListenOnMessage' +import { GitHubAccount } from 'utils/github' +import { RepoSetup } from 'utils/repoSetup' +import StarUs from 'components/StarUs' +import { smolDeveloperTemplateID } from 'utils/smolTemplates' + +// Lot of hydration issues, so import dynamically to force client-side rendering +// See https://github.com/tailwindlabs/headlessui/issues?q=is%3Aissue+hydration +const SelectRepository = dynamic(() => import('components/SelectRepository'), { ssr: false}) +const AgentInstructions = dynamic(() => import('components/AgentInstructions'), { ssr: false}) +const DeployAgent = dynamic(() => import('components/DeployAgent'), { ssr: false}) +const ChooseOpenAIKey = dynamic(() => import('components/ChooseOpenAIKey'), { ssr: false}) + +export interface PostAgentBody { + // ID of the installation of the GitHub App + installationID: number + // ID of the repository + repositoryID: number + // Title of the PR + title: string + // Default branch against which to create the PR + defaultBranch: string + // Initial prompt used as a body text for the PR (can be markdown) + body: string + // Owner of the repo (user or org) + owner: string + // Name of the repo + repo: string + // Name of the branch created for the PR + branch: string + // Commit message for the PR first empty commit + commitMessage: string + templateID: typeof smolDeveloperTemplateID + openAIKey?: string + openAIModel: string +} + +const steps = [ + { name: 'Select Repository' }, + { name: 'Your Instructions' }, + { name: 'OpenAI API Key' }, + { name: 'Overview & Deploy' }, +] + +const openAIModels = [ + { displayName: 'GPT-4', value: 'gpt-4' }, + { displayName: 'GPT-4 32k', value: 'gpt-4-32k' }, + { displayName: 'GPT-3.5 Turbo', value: 'gpt-3.5-turbo' }, + { displayName: 'GPT-3.5 Turbo 16k', value: 'gpt-3.5-turbo-16k' }, +] + +export interface PostAgentResponse { + issueID: number + owner: string + repo: string + pullURL: string + pullNumber: number + projectID: string + projectSlug: string +} + +async function handlePostAgent(url: string, { arg }: { arg: PostAgentBody }) { + const response = await fetch(url, { + method: 'POST', + body: JSON.stringify(arg), + headers: { + 'Content-Type': 'application/json', + }, + }) + return await response.json() as PostAgentResponse +} + +function Page() { + const user = useUser() + const session = useSession() + const supabaseClient = useSupabaseClient() + const [accessToken, setAccessToken] = useLocalStorage('gh_access_token', undefined) + const github = useGitHubClient(accessToken) + const [githubAccounts, setGitHubAccounts] = useState([]) + const [currentStepIdx, setCurrentStepIdx] = useState(0) + const [selectedRepository, setSelectedRepository] = useState() + const { repos, refetch } = useRepositories(github) + const [instructions, setInstructions] = useState('') + const posthog = usePostHog() + const router = useRouter() + const [isDeploying, setIsDeploying] = useState(false) + const [selectedOpenAIKeyType, setSelectedOpenAIKeyType] = useState('e2b') + const [userOpenAIKey, setUserOpenAIKey] = useState('') + const [errorMessage, setErrorMessage] = useState('') + const [selectedOpenAIModel, setSelectedOpenAIModel] = useState(openAIModels[0]) + + const handleMessageEvent = useCallback((event: MessageEvent) => { + if (event.data.accessToken) { + setAccessToken(event.data.accessToken) + } else if (event.data.installationID) { + refetch() + } + }, [refetch, setAccessToken]) + useListenOnMessage(handleMessageEvent) + + const handleSelectOpenAIModel = useCallback((value: string) => { + const model = openAIModels.find((model) => model.value === value) + if (!model) throw new Error(`Invalid OpenAI model: ${value}`) + setSelectedOpenAIModel(model) + }, []) + + const handleOpenAIKeyChange = useCallback((event: React.ChangeEvent) => { + setUserOpenAIKey(event.target.value) + }, []) + + const { + trigger: createAgent, + } = useSWRMutation('/api/agent', handlePostAgent) + + if (!session) { // if no session, redirect to agent's main page with login + redirect('/agent/smol-developer') + } + + async function deployAgent() { + if (!selectedRepository) { + console.error('No repository selected') + return + } + if (!instructions) { + console.error('No instructions provided') + return + } + setErrorMessage('') + try { + setIsDeploying(true) + const response = await createAgent({ + defaultBranch: selectedRepository.defaultBranch, + installationID: selectedRepository.installationID, + owner: selectedRepository.owner, + repo: selectedRepository.repo, + repositoryID: selectedRepository.repositoryID, + title: 'Smol PR', + branch: `pr/smol-dev/${nanoid(6).toLowerCase()}`, + body: instructions, + commitMessage: 'Initial commit', + templateID: smolDeveloperTemplateID, + openAIKey: userOpenAIKey, + openAIModel: selectedOpenAIModel.value, + }) + + posthog?.capture('clicked on deploy agent', { + repository: `${selectedRepository.owner}/${selectedRepository.repo}`, + instructions, + hasUserProvidedOpenAIKey: !!userOpenAIKey, + }) + + // Redirect to the dashboard. + if (response) { + if ((response as any).statusCode === 500) { + console.log('Error response', response) + setErrorMessage((response as any).message) + setIsDeploying(false) + return + } + + router.push(`/logs/${response.projectSlug}-run-0`) + } else { + console.error('No response from agent creation') + } + } catch (error: any) { + setIsDeploying(false) + } + } + + function nextStep() { + setCurrentStepIdx(currentStepIdx + 1) + } + + function previousStep() { + setCurrentStepIdx(currentStepIdx - 1) + posthog?.capture('clicked previous step', { + step: currentStepIdx - 1, + }) + } + + function handleRepoSelection(repo: any) { + setSelectedRepository(repo) + nextStep() + } + + async function signOut() { + await supabaseClient.auth.signOut() + posthog?.reset(true) + location.reload() + } + + useEffect(function getGitHubAccounts() { + async function getAccounts() { + if (!github) return + const installations = await github.apps.listInstallationsForAuthenticatedUser() + const accounts: GitHubAccount[] = [] + installations.data.installations.forEach(i => { + if (i.account) { + const ghAccType = (i.account as any)['type'] + const ghLogin = (i.account as any)['login'] + // Filter out user accounts that are not the current user (when a user has access to repos that aren't theirs) + if (ghAccType === 'User' && ghLogin !== user?.user_metadata?.user_name) return + accounts.push({ name: ghLogin, isOrg: ghAccType === 'Organization', installationID: i.id }) + } + }) + setGitHubAccounts(accounts) + } + getAccounts() + }, [github, user]) + + return ( +
+
+ + +
+
+ +
+ {currentStepIdx === 0 && ( + + )} + {currentStepIdx === 1 && ( + { console.log(t); setInstructions(t) }} + onChange={setInstructions} + onBack={previousStep} + onNext={nextStep} + /> + )} + {currentStepIdx === 2 && ( + + )} + {currentStepIdx === 3 && ( + { + setCurrentStepIdx(0) + posthog?.capture('returned to repository selection step') + }} + error={errorMessage} + onChangeTemplate={() => { + setCurrentStepIdx(1) + posthog?.capture('returned to the instructions step') + }} + onBack={previousStep} + onDeploy={deployAgent} + isDeploying={isDeploying} + selectedOpenAIKeyType={selectedOpenAIKeyType} + /> + )} +
+
+ ) +} + +export default Page diff --git a/app/favicon.png b/app/favicon.png new file mode 100644 index 000000000..9dcba878b Binary files /dev/null and b/app/favicon.png differ diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 000000000..aca83f62b --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,43 @@ +import React, { ReactNode } from 'react' +import { Metadata } from 'next' +import { PostHogProvider } from './_lib/PostHogProvider' +import { SupabaseProvider } from './_lib/SupabaseProvider' +import { createServerComponentSupabaseClient } from '@supabase/auth-helpers-nextjs' +import { cookies, headers } from 'next/headers' + +import 'styles/global.css' + +// Reasoning: https://github.com/vercel/next.js/pull/52916 +export const dynamic = 'force-dynamic' + +export default async function RootLayout({ + children, +}: { + children: ReactNode; +}) { + const supabaseClient = createServerComponentSupabaseClient({ headers, cookies }) + const { + data: { session }, + } = await supabaseClient.auth.getSession() + return ( + + + + {children} + + + + ) +} + +export const metadata: Metadata = { + robots: 'follow, index', + // override in sub routes as needed + title: 'Smol Developer | e2b', + description: 'Smol Developer | e2b', + openGraph: { + description: 'Smol Developer | e2b', + siteName: 'Smol Developer | e2b', + type: 'website', + }, +} diff --git a/app/template.tsx b/app/template.tsx new file mode 100644 index 000000000..bb10e860e --- /dev/null +++ b/app/template.tsx @@ -0,0 +1,75 @@ +'use client' + +import React, {useEffect, useState} from 'react' +import {usePathname, useSearchParams} from 'next/navigation' +import {usePostHog} from 'posthog-js/react' +import {useUser} from '@supabase/auth-helpers-react' +import * as gtag from '../utils/gtag' +import Script from 'next/script' + +// Based on https://posthog.com/docs/libraries/next-js +function getFullUrl(pathname: string = '', searchParams: URLSearchParams | null) { + let url = (typeof window !== 'undefined' ? window.origin : '') + pathname + if (searchParams?.toString()) { + url = url + `?${searchParams.toString()}` + } + return url +} +// See https://nextjs.org/docs/app/building-your-application/routing/pages-and-layouts#templates +// Templates are suitable for features that rely on useEffect (e.g logging page views) +// See "Listening to page changes" https://nextjs.org/docs/app/api-reference/functions/use-router#router-events +export function Template({ children }: { children: React.ReactNode }) { + const pathname = usePathname() + const searchParams = useSearchParams() + const posthog = usePostHog() + const user = useUser() + const [distinctID, setDistinctID] = useState() + + // PostHog: Obtain distinct ID + useEffect(function handleDistinctID() { + try { + setDistinctID(posthog?.get_distinct_id()) + } catch (err: any) { + // See https://github.com/PostHog/posthog-js/issues/769 + if (!err.message.includes('reading \'props\'')) throw err + } + }, [posthog]) + + // PostHog: Identify user + useEffect(function identifyUser() { + if (!user) return + posthog?.identify(user.id, { + email: user.email, + }) + }, [posthog, user]) + + // PostHog: Tracking + useEffect(() => { + posthog?.capture('$pageview') + }, [posthog, pathname, searchParams]) + + // Google Analytics: Tracking + useEffect(() => { + if (!pathname) return + gtag.pageview(getFullUrl(pathname, searchParams), distinctID) + }, [pathname, searchParams, user?.id, distinctID]) + + return <> + +