From 816e16decabcf1cd493b1f38cb5e0a17fe72acee Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Thu, 3 Oct 2024 16:25:33 +0200 Subject: [PATCH] Redesign (#59) * added menu dropdown * sign in btn reset * updates to the layout * refinissimo * sizes * chatbox changes * updated file handing * design changes * design tokens, added back spinner * visual nicities * improved chat ux * api key input password * added url bar, make it more round * tooltips everywhere * fixes vercel build * added artifact history * added undo * share sheet * improves reactivity around recent changes * css grid * auto grid * mobile responsiveness * mobile-friendly sidebar * new chat reset chatinput * error handling + retry * grammar * improved imput ux, minor changes * updated design tokens * form overflow fix * refreshed sign in screen * moved sign in design around * google auth sign-in * updated logo, wording * logo component * added dark theme * tooltip theming * added light theme switch * removed unnecessary files/dependencies, added prettierrc * removed unused deps, code import sort * prettify * publish, url shortener * format select.tsx * publish sandbox extended timeout * publish button changes * fixes vercel build * pass e2b api key to publish * posthog capture on url publish * undo rebrand (for now) * next neutral logo * file conventions * renamed files * sandbox endpoint server action * renamed file structure * work on types * work on types and files * renamed side to preview * improved chat input error ui * fixes invalid property in svg * changed tooltip duration * flipped dark/light switch * new chat > clear chat * added tooltip for profile menu * added disclaimer * undo favicon * improved contrast ratio * added multi-file picker changed artifact view * changed display of loading states * duplicated loading state to the chat input * added toast * copy button and fixes preview streaming * fixes tooltip on copybutton * copy-button moved props order * changed share link > copy url * added display name to copy button * improved reactive data structures in preview * moved loader to more prominent place * chat input e2b star * moved llm settings to chat input * llm settings picker * moved toggle to navbar * sandbox api endpoint * updated preview.png * publish > deploy * lint, o1 rate limit env var --------- Co-authored-by: Mish Ushakov --- .prettierrc | 5 + app/actions/publish.ts | 30 + app/api/chat-o1/route.ts | 48 +- app/api/chat/route.ts | 45 +- app/api/sandbox/route.ts | 72 +- app/globals.css | 33 +- app/layout.tsx | 24 +- app/page.tsx | 284 +++-- app/providers.tsx | 12 +- app/xterm.css | 211 ---- components/AuthDialog.tsx | 22 - components/AuthForm.tsx | 55 - components/Spinner.tsx | 26 - components/Terminal.tsx | 174 ---- components/artifact-code.tsx | 92 ++ components/artifact-interpreter.tsx | 86 ++ components/artifact-preview.tsx | 13 + components/artifact-view.tsx | 109 -- components/artifact-web.tsx | 65 ++ components/auth-dialog.tsx | 28 + components/auth-form.tsx | 71 ++ components/chat-input.tsx | 190 ++++ components/chat-picker.tsx | 109 ++ components/chat-settings.tsx | 205 ++++ components/chat.tsx | 140 ++- components/code-theme.css | 160 ++- components/code-view.tsx | 29 +- components/deploy-dialog.tsx | 73 ++ components/logo.tsx | 34 + components/navbar.tsx | 411 +++----- components/preview.tsx | 131 +++ components/side-view.tsx | 126 --- components/ui/avatar.tsx | 50 + components/ui/button.tsx | 43 +- components/ui/copy-button.tsx | 37 + components/ui/dialog.tsx | 2 +- components/ui/select.tsx | 2 +- components/ui/textarea.tsx | 24 + components/ui/toast.tsx | 129 +++ components/ui/toaster.tsx | 35 + components/ui/tooltip.tsx | 30 + components/ui/use-toast.ts | 194 ++++ components/useSandbox.ts | 25 - components/useTerminal.ts | 82 -- lib/auth.ts | 53 +- lib/messages.ts | 32 +- lib/models.json | 2 +- lib/models.ts | 28 +- lib/ratelimit.ts | 16 +- lib/supabase.ts | 2 +- lib/types.ts | 21 + lib/utils.ts | 4 +- middleware.ts | 22 + next.config.mjs | 4 +- package-lock.json | 966 ++++++++---------- package.json | 12 +- postcss.config.mjs | 4 +- preview.png | Bin 732202 -> 1926837 bytes public/logo.svg | 3 - public/next.svg | 1 - .../thirdparty/templates/nextjs-developer.svg | 2 +- public/vercel.svg | 1 - tailwind.config.ts | 166 +-- 63 files changed, 3008 insertions(+), 2097 deletions(-) create mode 100644 .prettierrc create mode 100644 app/actions/publish.ts delete mode 100644 app/xterm.css delete mode 100644 components/AuthDialog.tsx delete mode 100644 components/AuthForm.tsx delete mode 100644 components/Spinner.tsx delete mode 100644 components/Terminal.tsx create mode 100644 components/artifact-code.tsx create mode 100644 components/artifact-interpreter.tsx create mode 100644 components/artifact-preview.tsx delete mode 100644 components/artifact-view.tsx create mode 100644 components/artifact-web.tsx create mode 100644 components/auth-dialog.tsx create mode 100644 components/auth-form.tsx create mode 100644 components/chat-input.tsx create mode 100644 components/chat-picker.tsx create mode 100644 components/chat-settings.tsx create mode 100644 components/deploy-dialog.tsx create mode 100644 components/logo.tsx create mode 100644 components/preview.tsx delete mode 100644 components/side-view.tsx create mode 100644 components/ui/avatar.tsx create mode 100644 components/ui/copy-button.tsx create mode 100644 components/ui/textarea.tsx create mode 100644 components/ui/toast.tsx create mode 100644 components/ui/toaster.tsx create mode 100644 components/ui/tooltip.tsx create mode 100644 components/ui/use-toast.ts delete mode 100644 components/useSandbox.ts delete mode 100644 components/useTerminal.ts create mode 100644 middleware.ts delete mode 100644 public/logo.svg delete mode 100644 public/next.svg delete mode 100644 public/vercel.svg diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..163eff1 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,5 @@ +{ + "singleQuote": true, + "semi": false, + "plugins": ["@trivago/prettier-plugin-sort-imports"] +} diff --git a/app/actions/publish.ts b/app/actions/publish.ts new file mode 100644 index 0000000..29c3d9a --- /dev/null +++ b/app/actions/publish.ts @@ -0,0 +1,30 @@ +'use server' + +import { Sandbox } from '@e2b/code-interpreter' +import { kv } from '@vercel/kv' +import { customAlphabet } from 'nanoid' + +const nanoid = customAlphabet('1234567890abcdef', 7) +const sandboxTimeout = 3 * 60 * 60 * 1000 // 3 hours + +export async function publish( + url: string, + sbxId: string, + apiKey: string | undefined, +) { + if (process.env.KV_REST_API_URL && process.env.KV_REST_API_TOKEN) { + const id = nanoid() + await kv.set(`fragment:${id}`, url) + await Sandbox.setTimeout(sbxId, sandboxTimeout, { apiKey }) + + return { + url: process.env.VERCEL_URL + ? `https://${process.env.VERCEL_URL}/s/${id}` + : `/s/${id}`, + } + } + + return { + url, + } +} diff --git a/app/api/chat-o1/route.ts b/app/api/chat-o1/route.ts index 049ec50..3ac3d9e 100644 --- a/app/api/chat-o1/route.ts +++ b/app/api/chat-o1/route.ts @@ -1,37 +1,51 @@ -import { - streamObject, - LanguageModel, - CoreMessage, - generateText, -} from 'ai' - -import ratelimit from '@/lib/ratelimit' -import { Templates, templatesToPrompt } from '@/lib/templates' -import { getModelClient, getDefaultMode } from '@/lib/models' +import { getModelClient } from '@/lib/models' import { LLMModel, LLMModelConfig } from '@/lib/models' +import { toPrompt } from '@/lib/prompt' +import ratelimit, { Duration } from '@/lib/ratelimit' import { artifactSchema as schema } from '@/lib/schema' +import { Templates, templatesToPrompt } from '@/lib/templates' import { openai } from '@ai-sdk/openai' -import { toPrompt } from '@/lib/prompt' +import { streamObject, LanguageModel, CoreMessage, generateText } from 'ai' export const maxDuration = 60 -const rateLimitMaxRequests = 10 -const ratelimitWindow = '1d' +const rateLimitMaxRequests = process.env.RATE_LIMIT_MAX_REQUESTS + ? parseInt(process.env.RATE_LIMIT_MAX_REQUESTS) + : 10 +const ratelimitWindow = process.env.RATE_LIMIT_WINDOW + ? (process.env.RATE_LIMIT_WINDOW as Duration) + : '1d' export async function POST(req: Request) { - const limit = await ratelimit(req.headers.get('x-forwarded-for'), rateLimitMaxRequests, ratelimitWindow) + const limit = await ratelimit( + req.headers.get('x-forwarded-for'), + rateLimitMaxRequests, + ratelimitWindow, + ) if (limit) { return new Response('You have reached your request limit for the day.', { status: 429, headers: { 'X-RateLimit-Limit': limit.amount.toString(), 'X-RateLimit-Remaining': limit.remaining.toString(), - 'X-RateLimit-Reset': limit.reset.toString() - } + 'X-RateLimit-Reset': limit.reset.toString(), + }, }) } - const { messages, userID, template, model, config }: { messages: CoreMessage[], userID: string, template: Templates, model: LLMModel, config: LLMModelConfig } = await req.json() + const { + messages, + userID, + template, + model, + config, + }: { + messages: CoreMessage[] + userID: string + template: Templates + model: LLMModel + config: LLMModelConfig + } = await req.json() console.log('userID', userID) // console.log('template', template) console.log('model', model) diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 0ee6062..4394cce 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -1,35 +1,50 @@ -import { - streamObject, - LanguageModel, - CoreMessage, -} from 'ai' - -import ratelimit, { Duration } from '@/lib/ratelimit' -import { Templates, templatesToPrompt } from '@/lib/templates' import { getModelClient, getDefaultMode } from '@/lib/models' import { LLMModel, LLMModelConfig } from '@/lib/models' -import { artifactSchema as schema } from '@/lib/schema' import { toPrompt } from '@/lib/prompt' +import ratelimit, { Duration } from '@/lib/ratelimit' +import { artifactSchema as schema } from '@/lib/schema' +import { Templates } from '@/lib/templates' +import { streamObject, LanguageModel, CoreMessage } from 'ai' export const maxDuration = 60 -const rateLimitMaxRequests = process.env.RATE_LIMIT_MAX_REQUESTS ? parseInt(process.env.RATE_LIMIT_MAX_REQUESTS) : 10 -const ratelimitWindow = process.env.RATE_LIMIT_WINDOW ? process.env.RATE_LIMIT_WINDOW as Duration : '1d' +const rateLimitMaxRequests = process.env.RATE_LIMIT_MAX_REQUESTS + ? parseInt(process.env.RATE_LIMIT_MAX_REQUESTS) + : 10 +const ratelimitWindow = process.env.RATE_LIMIT_WINDOW + ? (process.env.RATE_LIMIT_WINDOW as Duration) + : '1d' export async function POST(req: Request) { - const limit = await ratelimit(req.headers.get('x-forwarded-for'), rateLimitMaxRequests, ratelimitWindow) + const limit = await ratelimit( + req.headers.get('x-forwarded-for'), + rateLimitMaxRequests, + ratelimitWindow, + ) if (limit) { return new Response('You have reached your request limit for the day.', { status: 429, headers: { 'X-RateLimit-Limit': limit.amount.toString(), 'X-RateLimit-Remaining': limit.remaining.toString(), - 'X-RateLimit-Reset': limit.reset.toString() - } + 'X-RateLimit-Reset': limit.reset.toString(), + }, }) } - const { messages, userID, template, model, config }: { messages: CoreMessage[], userID: string, template: Templates, model: LLMModel, config: LLMModelConfig } = await req.json() + const { + messages, + userID, + template, + model, + config, + }: { + messages: CoreMessage[] + userID: string + template: Templates + model: LLMModel + config: LLMModelConfig + } = await req.json() console.log('userID', userID) // console.log('template', template) console.log('model', model) diff --git a/app/api/sandbox/route.ts b/app/api/sandbox/route.ts index 75591c8..b029457 100644 --- a/app/api/sandbox/route.ts +++ b/app/api/sandbox/route.ts @@ -1,22 +1,18 @@ import { ArtifactSchema } from '@/lib/schema' -import { Sandbox, CodeInterpreter, Execution, Result, ExecutionError } from "@e2b/code-interpreter"; -import { TemplateId } from '@/lib/templates'; +import { ExecutionResultInterpreter, ExecutionResultWeb } from '@/lib/types' +import { Sandbox, CodeInterpreter } from '@e2b/code-interpreter' const sandboxTimeout = 10 * 60 * 1000 // 10 minute in ms export const maxDuration = 60 -export type ExecutionResult = { - template: TemplateId - stdout: string[] - stderr: string[] - runtimeError?: ExecutionError - cellResults: Result[] - url: string -} - export async function POST(req: Request) { - const { artifact, userID, apiKey }: { artifact: ArtifactSchema, userID: string, apiKey: string } = await req.json() + const { + artifact, + userID, + apiKey, + }: { artifact: ArtifactSchema; userID: string; apiKey?: string } = + await req.json() console.log('artifact', artifact) console.log('userID', userID) console.log('apiKey', apiKey) @@ -25,10 +21,18 @@ export async function POST(req: Request) { // Create a interpreter or a sandbox if (artifact.template === 'code-interpreter-multilang') { - sbx = await CodeInterpreter.create({ metadata: { template: artifact.template, userID: userID }, timeoutMs: sandboxTimeout, apiKey }) + sbx = await CodeInterpreter.create({ + metadata: { template: artifact.template, userID: userID }, + timeoutMs: sandboxTimeout, + apiKey, + }) console.log('Created code interpreter', sbx.sandboxID) } else { - sbx = await Sandbox.create(artifact.template, { metadata: { template: artifact.template, userID: userID }, timeoutMs: sandboxTimeout, apiKey }) + sbx = await Sandbox.create(artifact.template, { + metadata: { template: artifact.template, userID: userID }, + timeoutMs: sandboxTimeout, + apiKey, + }) console.log('Created sandbox', sbx.sandboxID) } @@ -36,10 +40,14 @@ export async function POST(req: Request) { if (artifact.has_additional_dependencies) { if (sbx instanceof CodeInterpreter) { await sbx.notebook.execCell(artifact.install_dependencies_command) - console.log(`Installed dependencies: ${artifact.additional_dependencies.join(', ')} in code interpreter ${sbx.sandboxID}`) + console.log( + `Installed dependencies: ${artifact.additional_dependencies.join(', ')} in code interpreter ${sbx.sandboxID}`, + ) } else if (sbx instanceof Sandbox) { await sbx.commands.run(artifact.install_dependencies_command) - console.log(`Installed dependencies: ${artifact.additional_dependencies.join(', ')} in sandbox ${sbx.sandboxID}`) + console.log( + `Installed dependencies: ${artifact.additional_dependencies.join(', ')} in sandbox ${sbx.sandboxID}`, + ) } } @@ -56,19 +64,27 @@ export async function POST(req: Request) { // Execute code or return a URL to the running sandbox if (artifact.template === 'code-interpreter-multilang') { - const result = await (sbx as CodeInterpreter).notebook.execCell(artifact.code || '') + const result = await (sbx as CodeInterpreter).notebook.execCell( + artifact.code || '', + ) await (sbx as CodeInterpreter).close() - return new Response(JSON.stringify({ - template: artifact.template, - stdout: result.logs.stdout, - stderr: result.logs.stderr, - runtimeError: result.error, - cellResults: result.results, - })) + return new Response( + JSON.stringify({ + sbxId: sbx?.sandboxID, + template: artifact.template, + stdout: result.logs.stdout, + stderr: result.logs.stderr, + runtimeError: result.error, + cellResults: result.results, + } as ExecutionResultInterpreter), + ) } else { - return new Response(JSON.stringify({ - template: artifact.template, - url: `https://${sbx?.getHost(artifact.port || 80)}` - })) + return new Response( + JSON.stringify({ + sbxId: sbx?.sandboxID, + template: artifact.template, + url: `https://${sbx?.getHost(artifact.port || 80)}`, + } as ExecutionResultWeb), + ) } } diff --git a/app/globals.css b/app/globals.css index 0e8e831..e8ea28e 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,11 +1,37 @@ -@import './xterm.css'; - @tailwind base; @tailwind components; @tailwind utilities; @layer base { :root { + --background: 0 0% 100%; + --foreground: 240 10% 3.9%; + --card: 0 0% 100%; + --card-foreground: 240 10% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 240 10% 3.9%; + --primary: 240 5.9% 10%; + --primary-foreground: 0 0% 98%; + --secondary: 240 4.8% 95.9%; + --secondary-foreground: 240 5.9% 10%; + --muted: 240 4.8% 95.9%; + --muted-foreground: 240 3.8% 46.1%; + --accent: 240 4.8% 95.9%; + --accent-foreground: 240 5.9% 10%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --border: 240 5.9% 90%; + --input: 240 5.9% 90%; + --ring: 240 10% 3.9%; + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + --radius: 0.75rem; + } + + .dark { --background: 240, 6%, 10%; --foreground: 0 0% 98%; --card: 240 10% 3.9%; @@ -24,13 +50,12 @@ --destructive-foreground: 0 0% 98%; --border: 270, 2%, 19%; --input: 240 3.7% 15.9%; - --ring: 240 4.9% 83.9%; + --ring: 0, 0%, 100%, 0.1; --chart-1: 220 70% 50%; --chart-2: 160 60% 45%; --chart-3: 30 80% 55%; --chart-4: 280 65% 60%; --chart-5: 340 75% 55%; - --radius: 0.5rem; } } diff --git a/app/layout.tsx b/app/layout.tsx index 7102951..a525d2f 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,25 +1,35 @@ +import './globals.css' +import { PostHogProvider, ThemeProvider } from './providers' +import { Toaster } from '@/components/ui/toaster' import type { Metadata } from 'next' import { Inter } from 'next/font/google' -import { PostHogProvider } from './providers' -import './globals.css' const inter = Inter({ subsets: ['latin'] }) export const metadata: Metadata = { - title: 'AI Artifacts by E2B', - description: 'About Hackable open-source version of Anthropic\'s AI Artifacts chat', + title: 'Artifacts by E2B', + description: + "About Hackable open-source version of Anthropic's AI Artifacts chat", } export default function RootLayout({ children, }: Readonly<{ - children: React.ReactNode; + children: React.ReactNode }>) { return ( - + - {children} + + {children} + + diff --git a/app/page.tsx b/app/page.tsx index 54814d9..200c7f1 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,55 +1,70 @@ 'use client' -import { useEffect, useState } from 'react' -import { experimental_useObject as useObject } from 'ai/react' -import { useLocalStorage } from 'usehooks-ts' -import { usePostHog } from 'posthog-js/react' -import { ArtifactSchema, artifactSchema as schema } from '@/lib/schema' - +import { AuthDialog } from '@/components/auth-dialog' import { Chat } from '@/components/chat' -import { SideView } from '@/components/side-view' -import NavBar from '@/components/navbar' - -import { supabase } from '@/lib/supabase' -import { AuthDialog } from '@/components/AuthDialog' +import { ChatInput } from '@/components/chat-input' +import { ChatPicker } from '@/components/chat-picker' +import { ChatSettings } from '@/components/chat-settings' +import { NavBar } from '@/components/navbar' +import { Preview } from '@/components/preview' import { AuthViewType, useAuth } from '@/lib/auth' import { Message, toAISDKMessages, toMessageImage } from '@/lib/messages' - -import { LLMModel, LLMModelConfig } from '@/lib/models' +import { LLMModelConfig } from '@/lib/models' import modelsList from '@/lib/models.json' -import templates, { TemplateId } from '@/lib/templates'; - -import { ExecutionResult } from './api/sandbox/route'; +import { ArtifactSchema, artifactSchema as schema } from '@/lib/schema' +import { supabase } from '@/lib/supabase' +import templates, { TemplateId } from '@/lib/templates' +import { ExecutionResult } from '@/lib/types' +import { DeepPartial } from 'ai' +import { experimental_useObject as useObject } from 'ai/react' +import { usePostHog } from 'posthog-js/react' +import { useEffect, useState } from 'react' +import { useLocalStorage } from 'usehooks-ts' export default function Home() { const [chatInput, setChatInput] = useLocalStorage('chat', '') - const [files, setFiles] = useState(null) - const [selectedTemplate, setSelectedTemplate] = useState<'auto' | TemplateId>('auto') - const [languageModel, setLanguageModel] = useLocalStorage('languageModel', { - model: 'claude-3-5-sonnet-20240620' - }) + const [files, setFiles] = useState([]) + const [selectedTemplate, setSelectedTemplate] = useState<'auto' | TemplateId>( + 'auto', + ) + const [languageModel, setLanguageModel] = useLocalStorage( + 'languageModel', + { + model: 'claude-3-5-sonnet-20240620', + }, + ) const posthog = usePostHog() const [result, setResult] = useState() const [messages, setMessages] = useState([]) - const [artifact, setArtifact] = useState | undefined>() + const [artifact, setArtifact] = useState>() const [currentTab, setCurrentTab] = useState<'code' | 'artifact'>('code') const [isPreviewLoading, setIsPreviewLoading] = useState(false) const [isAuthDialogOpen, setAuthDialog] = useState(false) const [authView, setAuthView] = useState('sign_in') const { session, apiKey } = useAuth(setAuthDialog, setAuthView) - const currentModel = modelsList.models.find(model => model.id === languageModel.model) - const currentTemplate = selectedTemplate === 'auto' ? templates : { [selectedTemplate]: templates[selectedTemplate] } + const currentModel = modelsList.models.find( + (model) => model.id === languageModel.model, + ) + const currentTemplate = + selectedTemplate === 'auto' + ? templates + : { [selectedTemplate]: templates[selectedTemplate] } + const lastMessage = messages[messages.length - 1] const { object, submit, isLoading, stop, error } = useObject({ - api: currentModel?.id === 'o1-preview' || currentModel?.id === 'o1-mini' ? '/api/chat-o1' : '/api/chat', + api: + currentModel?.id === 'o1-preview' || currentModel?.id === 'o1-mini' + ? '/api/chat-o1' + : '/api/chat', schema, onFinish: async ({ object: artifact, error }) => { if (!error) { // send it to /api/sandbox console.log('artifact', artifact) + setIsPreviewLoading(true) posthog.capture('artifact_generated', { template: artifact?.template, }) @@ -59,8 +74,8 @@ export default function Home() { body: JSON.stringify({ artifact, userID: session?.user?.id, - apiKey - }) + apiKey, + }), }) const result = await response.json() @@ -68,27 +83,56 @@ export default function Home() { posthog.capture('sandbox_created', { url: result.url }) setResult(result) + setCurrentPreview({ object: artifact, result }) + setMessage({ result }) setCurrentTab('artifact') setIsPreviewLoading(false) } - } + }, }) useEffect(() => { if (object) { - setArtifact(object as ArtifactSchema) - const lastAssistantMessage = messages.findLast(message => message.role === 'assistant') - if (lastAssistantMessage) { - lastAssistantMessage.content = [{ type: 'text', text: object.commentary || '' }, { type: 'code', text: object.code || '' }] - lastAssistantMessage.meta = { - title: object.title, - description: object.description - } + setArtifact(object) + const content: Message['content'] = [ + { type: 'text', text: object.commentary || '' }, + { type: 'code', text: object.code || '' }, + ] + + if (!lastMessage || lastMessage.role !== 'assistant') { + addMessage({ + role: 'assistant', + content, + object, + }) + } + + if (lastMessage && lastMessage.role === 'assistant') { + setMessage({ + content, + object, + }) } } }, [object]) - async function handleSubmitAuth (e: React.FormEvent) { + useEffect(() => { + if (error) stop() + }, [error]) + + function setMessage(message: Partial, index?: number) { + setMessages((previousMessages) => { + const updatedMessages = [...previousMessages] + updatedMessages[index ?? previousMessages.length - 1] = { + ...previousMessages[index ?? previousMessages.length - 1], + ...message, + } + + return updatedMessages + }) + } + + async function handleSubmitAuth(e: React.FormEvent) { e.preventDefault() if (!session) { @@ -103,7 +147,7 @@ export default function Home() { const images = await toMessageImage(files) if (images.length > 0) { - images.forEach(image => { + images.forEach((image) => { content.push({ type: 'image', image }) }) } @@ -121,15 +165,9 @@ export default function Home() { config: languageModel, }) - addMessage({ - role: 'assistant', - content: [{ type: 'text', text: 'Generating artifact...' }], - }) - setChatInput('') - setFiles(null) + setFiles([]) setCurrentTab('code') - setIsPreviewLoading(true) posthog.capture('chat_submit', { template: selectedTemplate, @@ -137,36 +175,55 @@ export default function Home() { }) } - function addMessage (message: Message) { - setMessages(previousMessages => [...previousMessages, message]) + function retry() { + submit({ + userID: session?.user?.id, + messages: toAISDKMessages(messages), + template: currentTemplate, + model: currentModel, + config: languageModel, + }) + } + + function addMessage(message: Message) { + setMessages((previousMessages) => [...previousMessages, message]) return [...messages, message] } - function handleSaveInputChange (e: React.ChangeEvent) { + function handleSaveInputChange(e: React.ChangeEvent) { setChatInput(e.target.value) } - function handleFileChange (e: React.ChangeEvent) { - if (e.target.files) { - setFiles(e.target.files) - } + function handleFileChange(files: File[]) { + setFiles(files) } - function logout () { - supabase ? supabase.auth.signOut() : console.warn('Supabase is not initialized') + function logout() { + supabase + ? supabase.auth.signOut() + : console.warn('Supabase is not initialized') } - function handleLanguageModelChange (e: LLMModelConfig) { + function handleLanguageModelChange(e: LLMModelConfig) { setLanguageModel({ ...languageModel, ...e }) } - function handleGitHubClick () { - window.open('https://github.com/e2b-dev/ai-artifacts', '_blank') - posthog.capture('github_click') + function handleSocialClick(target: 'github' | 'x' | 'discord') { + if (target === 'github') { + window.open('https://github.com/e2b-dev/ai-artifacts', '_blank') + } else if (target === 'x') { + window.open('https://x.com/e2b_dev', '_blank') + } else if (target === 'discord') { + window.open('https://discord.gg/U7KEcGErtQ', '_blank') + } + + posthog.capture(`${target}_click`) } - function handleNewChat () { + function handleClearChat() { stop() + setChatInput('') + setFiles([]) setMessages([]) setArtifact(undefined) setResult(undefined) @@ -174,46 +231,85 @@ export default function Home() { setIsPreviewLoading(false) } + function setCurrentPreview(preview: { + object: DeepPartial | undefined + result: ExecutionResult | undefined + }) { + setArtifact(preview.object) + setResult(preview.result) + } + + function handleUndo() { + setMessages((previousMessages) => [...previousMessages.slice(0, -2)]) + setCurrentPreview({ object: undefined, result: undefined }) + } + return (
- { - supabase && - } - setAuthDialog(true)} - signOut={logout} - templates={templates} - selectedTemplate={selectedTemplate} - onSelectedTemplateChange={setSelectedTemplate} - models={modelsList.models} - languageModel={languageModel} - onLanguageModelChange={handleLanguageModelChange} - onGitHubClick={handleGitHubClick} - onNewChat={handleNewChat} - apiKeyConfigurable={!process.env.NEXT_PUBLIC_NO_API_KEY_INPUT} - baseURLConfigurable={!process.env.NEXT_PUBLIC_NO_BASE_URL_INPUT} - /> - -
- - +
+ setAuthDialog(true)} + signOut={logout} + onSocialClick={handleSocialClick} + onClear={handleClearChat} + canClear={messages.length > 0} + canUndo={messages.length > 1 && !isLoading} + onUndo={handleUndo} + /> + + + + + +
+ setArtifact(undefined)} />
diff --git a/app/providers.tsx b/app/providers.tsx index 2bf0df1..7168129 100644 --- a/app/providers.tsx +++ b/app/providers.tsx @@ -1,5 +1,7 @@ -// app/providers.js 'use client' + +import { ThemeProvider as NextThemesProvider } from 'next-themes' +import { type ThemeProviderProps } from 'next-themes/dist/types' import posthog from 'posthog-js' import { PostHogProvider as PostHogProviderJS } from 'posthog-js/react' @@ -15,10 +17,12 @@ if (typeof window !== 'undefined' && process.env.NEXT_PUBLIC_ENABLE_POSTHOG) { export function PostHogProvider({ children }: { children: React.ReactNode }) { return process.env.NEXT_PUBLIC_ENABLE_POSTHOG ? ( - - {children} - + {children} ) : ( children ) } + +export function ThemeProvider({ children, ...props }: ThemeProviderProps) { + return {children} +} diff --git a/app/xterm.css b/app/xterm.css deleted file mode 100644 index 61d5c0a..0000000 --- a/app/xterm.css +++ /dev/null @@ -1,211 +0,0 @@ -/** - * Copyright (c) 2014 The xterm.js authors. All rights reserved. - * Copyright (c) 2012-2013, Christopher Jeffrey (MIT License) - * https://github.com/chjj/term.js - * @license MIT - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - * - * Originally forked from (with the author's permission): - * Fabrice Bellard's javascript vt100 for jslinux: - * http://bellard.org/jslinux/ - * Copyright (c) 2011 Fabrice Bellard - * The original design remains. The terminal itself - * has been extended to include xterm CSI codes, among - * other features. - */ - -/** - * Default styles for xterm.js - */ - - .xterm { - font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; - position: relative; - user-select: none; - -ms-user-select: none; - -webkit-user-select: none; - - /* Custom */ -} - -.xterm.focus, -.xterm:focus { - outline: none; -} - -.xterm .xterm-helpers { - position: absolute; - top: 0; - /** - * The z-index of the helpers must be higher than the canvases in order for - * IMEs to appear on top. - */ - z-index: 5; -} - -.xterm .xterm-helper-textarea { - padding: 0; - border: 0; - margin: 0; - /* Move textarea out of the screen to the far left, so that the cursor is not visible */ - position: absolute; - opacity: 0; - left: -9999em; - top: 0; - width: 0; - height: 0; - z-index: -5; - /** Prevent wrapping so the IME appears against the textarea at the correct position */ - white-space: nowrap; - overflow: hidden; - resize: none; -} - -.xterm .composition-view { - /* TODO: Composition position got messed up somewhere */ - background: #000; - color: #FFF; - display: none; - position: absolute; - white-space: nowrap; - z-index: 1; -} - -.terminal.terminal-wrapper .xterm .xterm-selection.focus > div { - @apply bg-[#6c6c6c44]; -} - -.terminal.terminal-wrapper .xterm .xterm-selection > div { - @apply bg-[#6c6c6c44]; -} - -.xterm .composition-view.active { - display: block; -} - -.xterm .xterm-screen { - position: relative; -} - -.xterm .xterm-screen canvas { - position: absolute; - left: 0; - top: 0; -} - -.xterm .xterm-scroll-area { - visibility: hidden; -} - -.xterm-char-measure-element { - display: inline-block; - visibility: hidden; - position: absolute; - top: 0; - left: -9999em; - line-height: normal; -} - -.xterm { - cursor: text; -} - -.xterm.enable-mouse-events { - /* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */ - cursor: default; -} - -.xterm.xterm-cursor-pointer, -.xterm .xterm-cursor-pointer { - cursor: pointer; -} - -.xterm.column-select.focus { - /* Column selection mode */ - cursor: crosshair; -} - -.xterm .xterm-accessibility, -.xterm .xterm-message { - position: absolute; - left: 0; - top: 0; - bottom: 0; - right: 0; - z-index: 10; - color: transparent; -} - -.xterm .live-region { - position: absolute; - left: -9999px; - width: 1px; - height: 1px; - overflow: hidden; -} - -.xterm-dim { - opacity: 0.5; -} - -.xterm-underline { - text-decoration: underline; -} - -.xterm-strikethrough { - text-decoration: line-through; -} - -.xterm-screen .xterm-decoration-container .xterm-decoration { - z-index: 6; - position: absolute; -} - -.xterm .xterm-viewport { - /* On OS X this is required in order for the scroll bar to appear fully opaque */ - background-color: transparent; - cursor: default; - position: absolute; - right: 0; - left: 0; - top: 0; - bottom: 0; - scrollbar-color: #8F8F8F transparent; - scrollbar-width: thin; - - /* CUSTOM */ - @apply overflow-auto; - @apply inset-x-1; -} - -.xterm-viewport::-webkit-scrollbar { - /* CUSTOM */ - width: 8px; - height: 8px; -} - -.xterm-viewport::-webkit-scrollbar-thumb { - @apply bg-gray-600; - @apply rounded; -} - -.xterm-viewport::-webkit-scrollbar-corner { - @apply bg-transparent; -} \ No newline at end of file diff --git a/components/AuthDialog.tsx b/components/AuthDialog.tsx deleted file mode 100644 index 1a0688a..0000000 --- a/components/AuthDialog.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { - Dialog, - DialogContent, - DialogTitle, -} from "@/components/ui/dialog" -import AuthForm from "./AuthForm" -import { SupabaseClient } from "@supabase/supabase-js" -import { VisuallyHidden } from "@radix-ui/react-visually-hidden" -import { AuthViewType } from "@/lib/auth" - -export function AuthDialog({ open, setOpen, supabase, view }: { open: boolean, setOpen: (open: boolean) => void, supabase: SupabaseClient, view: AuthViewType }) { - return ( - - - - Sign in to E2B - - - - - ) -} diff --git a/components/AuthForm.tsx b/components/AuthForm.tsx deleted file mode 100644 index d52822f..0000000 --- a/components/AuthForm.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { AuthViewType } from '@/lib/auth' -import { Auth } from '@supabase/auth-ui-react' -import { - ThemeSupa -} from '@supabase/auth-ui-shared' -import { SupabaseClient } from '@supabase/supabase-js' - -function AuthForm({ supabase, view = 'sign_in' }: { supabase: SupabaseClient, view: AuthViewType }) { - return ( -
-

- Sign in to E2B -

-
- -
-
- ) -} - -export default AuthForm diff --git a/components/Spinner.tsx b/components/Spinner.tsx deleted file mode 100644 index 204dc93..0000000 --- a/components/Spinner.tsx +++ /dev/null @@ -1,26 +0,0 @@ -function Spinner() { - return ( - - - - - ) -} - -export default Spinner \ No newline at end of file diff --git a/components/Terminal.tsx b/components/Terminal.tsx deleted file mode 100644 index 7024ca5..0000000 --- a/components/Terminal.tsx +++ /dev/null @@ -1,174 +0,0 @@ -import { - forwardRef, - useCallback, - useEffect, - useState, -} from 'react' -import { useResizeDetector } from 'react-resize-detector' -import type { Terminal as XTermTerminal } from '@xterm/xterm' -import type { FitAddon } from '@xterm/addon-fit' - -import useTerminal from './useTerminal' -import useSandbox from './useSandbox' -import Spinner from './Spinner' - -export interface Props { - sandboxID: string - autofocus?: boolean -} - -const Terminal = forwardRef<{}, Props>(({ - autofocus, - sandboxID, -}, ref) => { - const [errMessage, setErrMessage] = useState('') - const [terminal, setTerminal] = useState<{ terminal: XTermTerminal, fitAddon: FitAddon }>() - - const { sandbox } = useSandbox(sandboxID) - const pty = useTerminal({ - sandbox, - terminal: terminal?.terminal, - }) - - useEffect(function removeErrorMessage() { - setErrMessage('') - }, [ - sandbox, - pty, - ]) - - const focus = useCallback(() => { - terminal?.terminal.focus() - }, [terminal?.terminal]) - - const onResize = useCallback(() => { - if (!terminal?.fitAddon) return - - const dim = terminal.fitAddon.proposeDimensions() - - if (!dim) return - if (isNaN(dim.cols) || isNaN(dim.rows)) return - - terminal.fitAddon.fit() - }, [terminal?.fitAddon]) - - const { ref: terminalRef } = useResizeDetector({ onResize }) - - useEffect(function initialize() { - async function init() { - if (!terminalRef.current) return - - const xterm = await import('@xterm/xterm') - - const term = new xterm.Terminal({ - cursorStyle: 'block', - fontSize: 13, - theme: { - background: '#000', - foreground: '#FFFFFF', - cursor: '#FFFFFF', - }, - allowProposedApi: true, - }) - - const { FitAddon } = await import('@xterm/addon-fit') - const fitAddon = new FitAddon() - term.loadAddon(fitAddon) - term.open(terminalRef.current) - - setTerminal({ - fitAddon, - terminal: term, - }) - - // TODO: We want to add handling of multiline commands - - if (autofocus) term.focus() - - - const { CanvasAddon } = await import('@xterm/addon-canvas') - const canvasAddon = new CanvasAddon() - term.loadAddon(canvasAddon) - - fitAddon.fit() - - return term - } - - const result = init() - - return () => { - result.then(i => i?.dispose()) - } - }, [ - terminalRef, - autofocus, - ]) - - const clear = useCallback(() => { - terminal?.terminal.clear() - }, [terminal?.terminal]) - - - return ( -
-
- {/* - * We assign the `sizeRef` and the `terminalRef` to a child element intentionally - * because the fit addon for xterm.js resizes the terminal based on the PARENT'S size. - * The child element MUST have set the same width and height of it's parent, hence - * the `w-full` and `h-full`. - */} -
- {(errMessage || !terminal || (!pty)) && -
-
- {errMessage && -
- {errMessage} -
- } - {(!terminal || (!pty)) && } -
-
- } -
-
- ) -}) - -Terminal.displayName = 'Terminal' - -export default Terminal diff --git a/components/artifact-code.tsx b/components/artifact-code.tsx new file mode 100644 index 0000000..bd42e70 --- /dev/null +++ b/components/artifact-code.tsx @@ -0,0 +1,92 @@ +import { CodeView } from './code-view' +import { Button } from './ui/button' +import { CopyButton } from './ui/copy-button' +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip' +import { Download, FileText } from 'lucide-react' +import { useState } from 'react' + +export function ArtifactCode({ + files, +}: { + files: { name: string; content: string }[] +}) { + const [currentFile, setCurrentFile] = useState(files[0].name) + const currentFileContent = files.find( + (file) => file.name === currentFile, + )?.content + + function download(filename: string, content: string) { + const blob = new Blob([content], { type: 'text/plain' }) + const url = window.URL.createObjectURL(blob) + const a = document.createElement('a') + a.style.display = 'none' + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + window.URL.revokeObjectURL(url) + document.body.removeChild(a) + } + + return ( +
+
+
+ {files.map((file) => ( +
setCurrentFile(file.name)} + > + + {file.name} +
+ ))} +
+
+ + + + + + Copy + + + + + + + + Download + + +
+
+
+ +
+
+ ) +} diff --git a/components/artifact-interpreter.tsx b/components/artifact-interpreter.tsx new file mode 100644 index 0000000..132741d --- /dev/null +++ b/components/artifact-interpreter.tsx @@ -0,0 +1,86 @@ +import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert' +import { ExecutionResultInterpreter } from '@/lib/types' +import { Terminal } from 'lucide-react' +import Image from 'next/image' + +function LogsOutput({ + stdout, + stderr, +}: { + stdout: string[] + stderr: string[] +}) { + if (stdout.length === 0 && stderr.length === 0) return null + + return ( +
+ {stdout && + stdout.length > 0 && + stdout.map((out: string, index: number) => ( +
+            {out}
+          
+ ))} + {stderr && + stderr.length > 0 && + stderr.map((err: string, index: number) => ( +
+            {err}
+          
+ ))} +
+ ) +} + +export function ArtifactInterpreter({ + result, +}: { + result: ExecutionResultInterpreter +}) { + const { cellResults, stdout, stderr, runtimeError } = result + + // The AI-generated code experienced runtime error + if (runtimeError) { + const { name, value, tracebackRaw } = runtimeError + return ( +
+ + + + {name}: {value} + + + {tracebackRaw} + + +
+ ) + } + + // Cell results can contain text, pdfs, images, and code (html, latex, json) + // TODO: Show all results + // TODO: Check other formats than `png` + if (cellResults.length > 0) { + const imgInBase64 = cellResults[0].png + return ( +
+
+ result +
+ +
+ ) + } + + // No cell results, but there is stdout or stderr + if (stdout.length > 0 || stderr.length > 0) { + return + } + + return No output or logs +} diff --git a/components/artifact-preview.tsx b/components/artifact-preview.tsx new file mode 100644 index 0000000..409c62f --- /dev/null +++ b/components/artifact-preview.tsx @@ -0,0 +1,13 @@ +'use client' + +import { ArtifactInterpreter } from './artifact-interpreter' +import { ArtifactWeb } from './artifact-web' +import { ExecutionResult } from '@/lib/types' + +export function ArtifactPreview({ result }: { result: ExecutionResult }) { + if (result.template === 'code-interpreter-multilang') { + return + } + + return +} diff --git a/components/artifact-view.tsx b/components/artifact-view.tsx deleted file mode 100644 index 89a0fe0..0000000 --- a/components/artifact-view.tsx +++ /dev/null @@ -1,109 +0,0 @@ -'use client' -import { useState, useEffect } from 'react' -import Image from 'next/image' -import { Terminal } from 'lucide-react' - -import { - Alert, - AlertTitle, - AlertDescription, -} from '@/components/ui/alert' -import { ExecutionResult } from '@/app/api/sandbox/route' -import { TemplateId } from '@/lib/templates' - -function LogsOutput({ stdout, stderr }: { - stdout: string[] - stderr: string[] -}) { - if (stdout.length === 0 && stderr.length === 0) return null - - return ( -
- {stdout && stdout.length > 0 && stdout.map((out: string, index: number) => ( -
-          {out}
-        
- ))} - {stderr && stderr.length > 0 && stderr.map((err: string, index: number) => ( -
-          {err}
-        
- ))} -
- ) -} - -export function ArtifactView({ - iframeKey, - result, - template, -}: { - iframeKey: number - result: ExecutionResult - template?: TemplateId -}) { - if (!result) return null - - if (template !== 'code-interpreter-multilang') { - return ( -
-