Compare commits

...

10 Commits

Author SHA1 Message Date
a59246b1ef feat: correctly show whether a project has been deployed 2024-12-01 19:14:44 -08:00
1630a5a9cd docs: add OpenAI API key to README 2024-11-30 18:53:24 -08:00
ffdcdef56c docs: remove AI worker row from README 2024-11-30 18:53:24 -08:00
6612692d98 feat: introduce apply button functionality (v0.1)
### Summary
- Added a new "Apply" button to code snippets provided by the AI assistant.
- The button is designed to seamlessly merge the AI-generated snippet into the relevant file in the editor.

### Current Issues
1. **Sticky Accept/Decline Buttons:** These activate for every snippet instead of being limited to the relevant snippet.
2. **Discard Button:** Currently non-functional.
3. **Highlight Inconsistencies:** The green-red code highlights for old and new code are inconsistent.

### To Do
- Implement a toast notification when the "Apply" button is pressed on an irrelevant tab to prevent code application errors.

### Workflow Implemented
1. The "Apply" button is added alongside "Copy" and "Reply" for AI-generated code snippets.
2. Upon clicking "Apply," the code snippet and relevant file content (active file) are sent to a secondary model (GPT-4O).
3. The system prompt for GPT-4O instructs it to merge the snippet with the file content:
   - Ensure the original file functionality remains intact.
   - Integrate the code snippet seamlessly.
4. The output from GPT-4O is injected directly into the code editor.
5. Changes are visually highlighted:
   - Green for new code.
   - Red for removed code.
6. Highlights remain until the user explicitly accepts or discards the changes.
2024-11-30 21:52:17 -05:00
534b148b86 feat: add dynamic file structure context in AI chat
- Improved file structure formatting with tree-like visualization
- Added filtering for ignored files and folders
- Added scripts section to template context
- Fixed folder hierarchy display with proper indentation
- Maintains sorting with folders first, then files alphabetically
- Now uses actual project files instead of template structure

Example output:
├── app/
│   ├── api/
│   └── page.tsx
├── components/
└── package.json
2024-11-30 15:53:30 -05:00
e384607d24 chore: context tab updates
- Context tab updates with the latest file changes and will not be removed from context when a message is sent
2024-11-30 02:26:48 -05:00
e7d9989931 feat: sticky copy-reply button on chat code-snippets 2024-11-30 01:34:07 -05:00
42305d67b9 fix: terminal paste functionality 2024-11-30 00:04:04 -05:00
06dadf3a0b fix: image handling in context 2024-11-29 21:50:08 -05:00
ba7a1dcc2c chore: formatting the code of recent changes 2024-11-29 13:05:35 -05:00
28 changed files with 1597 additions and 982 deletions

View File

@ -22,6 +22,7 @@ Needed accounts to set up:
- [Liveblocks](https://liveblocks.io/): Used for collaborative editing. - [Liveblocks](https://liveblocks.io/): Used for collaborative editing.
- [E2B](https://e2b.dev/): Used for the terminals and live preview. - [E2B](https://e2b.dev/): Used for the terminals and live preview.
- [Cloudflare](https://www.cloudflare.com/): Used for relational data storage (D2) and file storage (R2). - [Cloudflare](https://www.cloudflare.com/): Used for relational data storage (D2) and file storage (R2).
- [Anthropic](https://anthropic.com/) and [OpenAI](https://openai.com/): API keys for code generation.
A quick overview of the tech before we start: The deployment uses a **NextJS** app for the frontend and an **ExpressJS** server on the backend. Presumably that's because NextJS integrates well with Clerk middleware but not with Socket.io. A quick overview of the tech before we start: The deployment uses a **NextJS** app for the frontend and an **ExpressJS** server on the backend. Presumably that's because NextJS integrates well with Clerk middleware but not with Socket.io.
@ -152,6 +153,7 @@ NEXT_PUBLIC_DATABASE_WORKER_URL='https://database.🍎.workers.dev'
NEXT_PUBLIC_STORAGE_WORKER_URL='https://storage.🍎.workers.dev' NEXT_PUBLIC_STORAGE_WORKER_URL='https://storage.🍎.workers.dev'
NEXT_PUBLIC_WORKERS_KEY='SUPERDUPERSECRET' NEXT_PUBLIC_WORKERS_KEY='SUPERDUPERSECRET'
ANTHROPIC_API_KEY='🔑' ANTHROPIC_API_KEY='🔑'
OPENAI_API_KEY='🔑'
``` ```
### 10. Running the IDE ### 10. Running the IDE
@ -256,7 +258,6 @@ backend/
| `backend/server` | The Express websocket server. | | `backend/server` | The Express websocket server. |
| `backend/database` | API for interfacing with the D1 database (SQLite). | | `backend/database` | API for interfacing with the D1 database (SQLite). |
| `backend/storage` | API for interfacing with R2 storage. Service-bound to `/backend/database`. | | `backend/storage` | API for interfacing with R2 storage. Service-bound to `/backend/database`. |
| `backend/ai` | API for making requests to Workers AI . |
### Development ### Development

View File

@ -325,7 +325,18 @@ export default {
const body = await request.json() const body = await request.json()
const { id, name, email, username, avatarUrl, createdAt, generations, tier, tierExpiresAt, lastResetDate } = userSchema.parse(body) const {
id,
name,
email,
username,
avatarUrl,
createdAt,
generations,
tier,
tierExpiresAt,
lastResetDate,
} = userSchema.parse(body)
const res = await db const res = await db
.insert(user) .insert(user)
.values({ .values({
@ -418,7 +429,10 @@ export default {
return json({ exists: !!exists }) return json({ exists: !!exists })
} }
return methodNotAllowed return methodNotAllowed
} else if (path === "/api/user/increment-generations" && method === "POST") { } else if (
path === "/api/user/increment-generations" &&
method === "POST"
) {
const schema = z.object({ const schema = z.object({
userId: z.string(), userId: z.string(),
}) })
@ -449,7 +463,7 @@ export default {
tier, tier,
tierExpiresAt: tierExpiresAt.getTime(), tierExpiresAt: tierExpiresAt.getTime(),
// Reset generations when upgrading tier // Reset generations when upgrading tier
generations: 0 generations: 0,
}) })
.where(eq(user.id, userId)) .where(eq(user.id, userId))
.get() .get()
@ -472,14 +486,19 @@ export default {
} }
const now = new Date() const now = new Date()
const lastReset = dbUser.lastResetDate ? new Date(dbUser.lastResetDate) : new Date(0) const lastReset = dbUser.lastResetDate
? new Date(dbUser.lastResetDate)
: new Date(0)
if (now.getMonth() !== lastReset.getMonth() || now.getFullYear() !== lastReset.getFullYear()) { if (
now.getMonth() !== lastReset.getMonth() ||
now.getFullYear() !== lastReset.getFullYear()
) {
await db await db
.update(user) .update(user)
.set({ .set({
generations: 0, generations: 0,
lastResetDate: now.getTime() lastResetDate: now.getTime(),
}) })
.where(eq(user.id, userId)) .where(eq(user.id, userId))
.get() .get()

View File

@ -31,9 +31,30 @@ export class DokkuClient extends SSHSocketClient {
// List all deployed Dokku apps // List all deployed Dokku apps
async listApps(): Promise<string[]> { async listApps(): Promise<string[]> {
const response = await this.sendCommand("apps:list") const response = await this.sendCommand("--quiet apps:list")
// Split the output by newline and remove the header return response.output.split("\n")
return response.output.split("\n").slice(1) }
// Get the creation timestamp of an app
async getAppCreatedAt(appName: string): Promise<number> {
const response = await this.sendCommand(
`apps:report --app-created-at ${appName}`
)
const createdAt = parseInt(response.output.trim(), 10)
if (isNaN(createdAt)) {
throw new Error(
`Failed to retrieve creation timestamp for app ${appName}`
)
}
return createdAt
}
// Check if an app exists
async appExists(appName: string): Promise<boolean> {
const response = await this.sendCommand(`apps:exists ${appName}`)
return response.output.includes("App") === false
} }
} }

View File

@ -156,6 +156,28 @@ export class Sandbox {
return { success: true, apps: await this.dokkuClient.listApps() } return { success: true, apps: await this.dokkuClient.listApps() }
} }
// Handle getting app creation timestamp
const handleGetAppCreatedAt: SocketHandler = async ({ appName }) => {
if (!this.dokkuClient)
throw new Error(
"Failed to retrieve app creation timestamp: No Dokku client"
)
return {
success: true,
createdAt: await this.dokkuClient.getAppCreatedAt(appName),
}
}
// Handle checking if an app exists
const handleAppExists: SocketHandler = async ({ appName }) => {
if (!this.dokkuClient)
throw new Error("Failed to check app existence: No Dokku client")
return {
success: true,
exists: await this.dokkuClient.appExists(appName),
}
}
// Handle deploying code // Handle deploying code
const handleDeploy: SocketHandler = async (_: any) => { const handleDeploy: SocketHandler = async (_: any) => {
if (!this.gitClient) throw Error("No git client") if (!this.gitClient) throw Error("No git client")
@ -253,7 +275,9 @@ export class Sandbox {
getFolder: handleGetFolder, getFolder: handleGetFolder,
saveFile: handleSaveFile, saveFile: handleSaveFile,
moveFile: handleMoveFile, moveFile: handleMoveFile,
list: handleListApps, listApps: handleListApps,
getAppCreatedAt: handleGetAppCreatedAt,
getAppExists: handleAppExists,
deploy: handleDeploy, deploy: handleDeploy,
createFile: handleCreateFile, createFile: handleCreateFile,
createFolder: handleCreateFolder, createFolder: handleCreateFolder,

View File

@ -146,6 +146,8 @@ io.on("connection", async (socket) => {
) )
}) })
socket.emit("ready")
// Handle disconnection event // Handle disconnection event
socket.on("disconnect", async () => { socket.on("disconnect", async () => {
try { try {

View File

@ -2,11 +2,44 @@ import { currentUser } from "@clerk/nextjs"
import { Anthropic } from "@anthropic-ai/sdk" import { Anthropic } from "@anthropic-ai/sdk"
import { TIERS } from "@/lib/tiers" import { TIERS } from "@/lib/tiers"
import { templateConfigs } from "@/lib/templates" import { templateConfigs } from "@/lib/templates"
import { TFile, TFolder } from "@/lib/types"
import { ignoredFiles, ignoredFolders } from "@/components/editor/AIChat/lib/ignored-paths"
const anthropic = new Anthropic({ const anthropic = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY!, apiKey: process.env.ANTHROPIC_API_KEY!,
}) })
// Format file structure for context
function formatFileStructure(items: (TFile | TFolder)[] | undefined, prefix = ""): string {
if (!items || !Array.isArray(items)) {
return "No files available"
}
// Sort items to show folders first, then files
const sortedItems = [...items].sort((a, b) => {
if (a.type === b.type) return a.name.localeCompare(b.name)
return a.type === "folder" ? -1 : 1
})
return sortedItems
.map((item) => {
if (item.type === "file" && !ignoredFiles.some(
(pattern) => item.name.endsWith(pattern.replace("*", "")) || item.name === pattern
)) {
return `${prefix}├── ${item.name}`
} else if (
item.type === "folder" &&
!ignoredFolders.some((folder) => folder === item.name)
) {
const folderContent = formatFileStructure(item.children, `${prefix}`)
return `${prefix}├── ${item.name}/\n${folderContent}`
}
return null
})
.filter(Boolean)
.join("\n")
}
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
const user = await currentUser() const user = await currentUser()
@ -43,7 +76,8 @@ export async function POST(request: Request) {
const userData = await dbUser.json() const userData = await dbUser.json()
// Get tier settings // Get tier settings
const tierSettings = TIERS[userData.tier as keyof typeof TIERS] || TIERS.FREE const tierSettings =
TIERS[userData.tier as keyof typeof TIERS] || TIERS.FREE
if (userData.generations >= tierSettings.generations) { if (userData.generations >= tierSettings.generations) {
return new Response( return new Response(
`AI generation limit reached for your ${userData.tier || "FREE"} tier`, `AI generation limit reached for your ${userData.tier || "FREE"} tier`,
@ -58,27 +92,31 @@ export async function POST(request: Request) {
isEditMode, isEditMode,
fileName, fileName,
line, line,
templateType templateType,
files,
} = await request.json() } = await request.json()
// Get template configuration // Get template configuration
const templateConfig = templateConfigs[templateType] const templateConfig = templateConfigs[templateType]
// Create template context // Create template context
const templateContext = templateConfig ? ` const templateContext = templateConfig
? `
Project Template: ${templateConfig.name} Project Template: ${templateConfig.name}
File Structure: Current File Structure:
${Object.entries(templateConfig.fileStructure) ${files ? formatFileStructure(files) : "No files available"}
.map(([path, info]) => `${path} - ${info.description}`)
.join('\n')}
Conventions: Conventions:
${templateConfig.conventions.join('\n')} ${templateConfig.conventions.join("\n")}
Dependencies: Dependencies:
${JSON.stringify(templateConfig.dependencies, null, 2)} ${JSON.stringify(templateConfig.dependencies, null, 2)}
` : ''
Scripts:
${JSON.stringify(templateConfig.scripts, null, 2)}
`
: ""
// Create system message based on mode // Create system message based on mode
let systemMessage let systemMessage
@ -146,7 +184,10 @@ ${activeFileContent ? `Active File Content:\n${activeFileContent}\n` : ""}`
new ReadableStream({ new ReadableStream({
async start(controller) { async start(controller) {
for await (const chunk of stream) { for await (const chunk of stream) {
if (chunk.type === "content_block_delta" && chunk.delta.type === "text_delta") { if (
chunk.type === "content_block_delta" &&
chunk.delta.type === "text_delta"
) {
controller.enqueue(encoder.encode(chunk.delta.text)) controller.enqueue(encoder.encode(chunk.delta.text))
} }
} }

View File

@ -0,0 +1,67 @@
import OpenAI from "openai"
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
})
export async function POST(request: Request) {
try {
const { originalCode, newCode, fileName } = await request.json()
const systemPrompt = `You are a code merging assistant. Your task is to merge the new code snippet with the original file content while:
1. Preserving the original file's functionality
2. Ensuring proper integration of the new code
3. Maintaining consistent style and formatting
4. Resolving any potential conflicts
5. Output ONLY the raw code without any:
- Code fence markers (\`\`\`)
- Language identifiers (typescript, javascript, etc.)
- Explanations or comments
- Markdown formatting
The output should be the exact code that will replace the existing code, nothing more and nothing less.`
const mergedCode = `Original file (${fileName}):\n${originalCode}\n\nNew code to merge:\n${newCode}`
const response = await openai.chat.completions.create({
model: "gpt-4o",
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: mergedCode },
],
prediction: {
type: "content",
content: mergedCode,
},
stream: true,
})
// Clean and stream response
const encoder = new TextEncoder()
return new Response(
new ReadableStream({
async start(controller) {
let buffer = ""
for await (const chunk of response) {
if (chunk.choices[0]?.delta?.content) {
buffer += chunk.choices[0].delta.content
// Clean any code fence markers that might appear in the stream
const cleanedContent = buffer
.replace(/^```[\w-]*\n|```\s*$/gm, "") // Remove code fences
.replace(/^(javascript|typescript|python|html|css)\n/gm, "") // Remove language identifiers
controller.enqueue(encoder.encode(cleanedContent))
buffer = ""
}
}
controller.close()
},
})
)
} catch (error) {
console.error("Merge error:", error)
return new Response(
error instanceof Error ? error.message : "Failed to merge code",
{ status: 500 }
)
}
}

View File

@ -175,3 +175,23 @@
.tab-scroll::-webkit-scrollbar { .tab-scroll::-webkit-scrollbar {
display: none; display: none;
} }
.added-line-decoration {
background-color: rgba(0, 255, 0, 0.1);
}
.removed-line-decoration {
background-color: rgba(255, 0, 0, 0.1);
}
.added-line-glyph {
background-color: #28a745;
width: 4px !important;
margin-left: 3px;
}
.removed-line-glyph {
background-color: #dc3545;
width: 4px !important;
margin-left: 3px;
}

View File

@ -0,0 +1,77 @@
import { Check, Loader2 } from "lucide-react"
import { useState } from "react"
import { toast } from "sonner"
import { Button } from "../../ui/button"
interface ApplyButtonProps {
code: string
activeFileName: string
activeFileContent: string
editorRef: { current: any }
onApply: (mergedCode: string) => void
}
export default function ApplyButton({
code,
activeFileName,
activeFileContent,
editorRef,
onApply,
}: ApplyButtonProps) {
const [isApplying, setIsApplying] = useState(false)
const handleApply = async () => {
setIsApplying(true)
try {
const response = await fetch("/api/merge", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
originalCode: activeFileContent,
newCode: String(code),
fileName: activeFileName,
}),
})
if (!response.ok) {
throw new Error(await response.text())
}
const reader = response.body?.getReader()
const decoder = new TextDecoder()
let mergedCode = ""
if (reader) {
while (true) {
const { done, value } = await reader.read()
if (done) break
mergedCode += decoder.decode(value, { stream: true })
}
}
onApply(mergedCode.trim())
} catch (error) {
console.error("Error applying code:", error)
toast.error(
error instanceof Error ? error.message : "Failed to apply code changes"
)
} finally {
setIsApplying(false)
}
}
return (
<Button
onClick={handleApply}
size="sm"
variant="ghost"
className="p-1 h-6"
disabled={isApplying}
>
{isApplying ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Check className="w-4 h-4" />
)}
</Button>
)
}

View File

@ -13,6 +13,12 @@ export default function ChatMessage({
setContext, setContext,
setIsContextExpanded, setIsContextExpanded,
socket, socket,
handleApplyCode,
activeFileName,
activeFileContent,
editorRef,
mergeDecorationsCollection,
setMergeDecorationsCollection,
}: MessageProps) { }: MessageProps) {
// State for expanded message index // State for expanded message index
const [expandedMessageIndex, setExpandedMessageIndex] = useState< const [expandedMessageIndex, setExpandedMessageIndex] = useState<
@ -104,7 +110,13 @@ export default function ChatMessage({
const components = createMarkdownComponents( const components = createMarkdownComponents(
renderCopyButton, renderCopyButton,
renderMarkdownElement, renderMarkdownElement,
askAboutCode askAboutCode,
activeFileName,
activeFileContent,
editorRef,
handleApplyCode,
mergeDecorationsCollection,
setMergeDecorationsCollection
) )
return ( return (
@ -201,7 +213,8 @@ export default function ChatMessage({
// Parse context to tabs for context tabs component // Parse context to tabs for context tabs component
function parseContextToTabs(context: string) { function parseContextToTabs(context: string) {
const sections = context.split(/(?=File |Code from )/) // Use specific regex patterns to avoid matching import statements
const sections = context.split(/(?=File |Code from |Image \d{1,2}:)/)
return sections return sections
.map((section, index) => { .map((section, index) => {
const lines = section.trim().split("\n") const lines = section.trim().split("\n")
@ -211,16 +224,29 @@ function parseContextToTabs(context: string) {
// Remove code block markers for display // Remove code block markers for display
content = content.replace(/^```[\w-]*\n/, "").replace(/\n```$/, "") content = content.replace(/^```[\w-]*\n/, "").replace(/\n```$/, "")
// Determine if the context is a file or code // Determine the type of context
const isFile = titleLine.startsWith("File ") const isFile = titleLine.startsWith("File ")
const name = titleLine.replace(/^(File |Code from )/, "").replace(":", "") const isImage = titleLine.startsWith("Image ")
const type = isFile ? "file" : isImage ? "image" : "code"
const name = titleLine
.replace(/^(File |Code from |Image )/, "")
.replace(":", "")
.trim()
// Skip if the content is empty or if it's just an import statement
if (!content || content.trim().startsWith('from "')) {
return null
}
return { return {
id: `context-${index}`, id: `context-${index}`,
type: isFile ? ("file" as const) : ("code" as const), type: type as "file" | "code" | "image",
name: name, name: name,
content: content, content: content,
} }
}) })
.filter((tab) => tab.content.length > 0) .filter(
(tab): tab is NonNullable<typeof tab> =>
tab !== null && tab.content.length > 0
)
} }

View File

@ -1,6 +1,6 @@
import { useSocket } from "@/context/SocketContext" import { useSocket } from "@/context/SocketContext"
import { TFile } from "@/lib/types" import { TFile } from "@/lib/types"
import { X, ChevronDown } from "lucide-react" import { ChevronDown, X } from "lucide-react"
import { nanoid } from "nanoid" import { nanoid } from "nanoid"
import { useEffect, useRef, useState } from "react" import { useEffect, useRef, useState } from "react"
import LoadingDots from "../../ui/LoadingDots" import LoadingDots from "../../ui/LoadingDots"
@ -18,6 +18,9 @@ export default function AIChat({
lastCopiedRangeRef, lastCopiedRangeRef,
files, files,
templateType, templateType,
handleApplyCode,
mergeDecorationsCollection,
setMergeDecorationsCollection,
}: AIChatProps) { }: AIChatProps) {
// Initialize socket and messages // Initialize socket and messages
const { socket } = useSocket() const { socket } = useSocket()
@ -75,8 +78,8 @@ export default function AIChat({
useEffect(() => { useEffect(() => {
const container = chatContainerRef.current const container = chatContainerRef.current
if (container) { if (container) {
container.addEventListener('scroll', handleScroll) container.addEventListener("scroll", handleScroll)
return () => container.removeEventListener('scroll', handleScroll) return () => container.removeEventListener("scroll", handleScroll)
} }
}, []) }, [])
@ -125,6 +128,8 @@ export default function AIChat({
} else if (tab.type === "code") { } else if (tab.type === "code") {
const cleanContent = formatCodeContent(tab.content) const cleanContent = formatCodeContent(tab.content)
return `Code from ${tab.name}:\n\`\`\`typescript\n${cleanContent}\n\`\`\`` return `Code from ${tab.name}:\n\`\`\`typescript\n${cleanContent}\n\`\`\``
} else if (tab.type === "image") {
return `Image ${tab.name}:\n${tab.content}`
} }
return `${tab.name}:\n${tab.content}` return `${tab.name}:\n${tab.content}`
}) })
@ -146,10 +151,9 @@ export default function AIChat({
abortControllerRef, abortControllerRef,
activeFileContent, activeFileContent,
false, false,
templateType templateType,
files
) )
// Clear context tabs after sending
setContextTabs([])
} }
// Set context for the chat // Set context for the chat
@ -167,6 +171,22 @@ export default function AIChat({
addContextTab("code", name, context, range) addContextTab("code", name, context, range)
} }
// update context tabs when file contents change
useEffect(() => {
setContextTabs((prevTabs) =>
prevTabs.map((tab) => {
if (tab.type === "file" && tab.name === activeFileName) {
const fileExt = tab.name.split(".").pop() || "txt"
return {
...tab,
content: `\`\`\`${fileExt}\n${activeFileContent}\n\`\`\``,
}
}
return tab
})
)
}, [activeFileContent, activeFileName])
return ( return (
<div className="flex flex-col h-screen w-full"> <div className="flex flex-col h-screen w-full">
<div className="flex justify-between items-center p-2 border-b"> <div className="flex justify-between items-center p-2 border-b">
@ -197,6 +217,12 @@ export default function AIChat({
setContext={setContext} setContext={setContext}
setIsContextExpanded={setIsContextExpanded} setIsContextExpanded={setIsContextExpanded}
socket={socket} socket={socket}
handleApplyCode={handleApplyCode}
activeFileName={activeFileName}
activeFileContent={activeFileContent}
editorRef={editorRef}
mergeDecorationsCollection={mergeDecorationsCollection}
setMergeDecorationsCollection={setMergeDecorationsCollection}
/> />
))} ))}
{isLoading && <LoadingDots />} {isLoading && <LoadingDots />}

View File

@ -1,3 +1,4 @@
import { TFolder, TFile } from "@/lib/types"
import React from "react" import React from "react"
// Stringify content for chat message component // Stringify content for chat message component
@ -91,7 +92,8 @@ export const handleSend = async (
abortControllerRef: React.MutableRefObject<AbortController | null>, abortControllerRef: React.MutableRefObject<AbortController | null>,
activeFileContent: string, activeFileContent: string,
isEditMode: boolean = false, isEditMode: boolean = false,
templateType: string templateType: string,
files: (TFile | TFolder)[]
) => { ) => {
// Return if input is empty and context is null // Return if input is empty and context is null
if (input.trim() === "" && !context) return if (input.trim() === "" && !context) return
@ -131,8 +133,7 @@ export const handleSend = async (
})) }))
// Fetch AI response for chat message component // Fetch AI response for chat message component
const response = await fetch("/api/ai", const response = await fetch("/api/ai", {
{
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@ -143,10 +144,10 @@ export const handleSend = async (
activeFileContent: activeFileContent, activeFileContent: activeFileContent,
isEditMode: isEditMode, isEditMode: isEditMode,
templateType: templateType, templateType: templateType,
files: files,
}), }),
signal: abortControllerRef.current.signal, signal: abortControllerRef.current.signal,
} })
)
// Throw error if response is not ok // Throw error if response is not ok
if (!response.ok) { if (!response.ok) {
@ -201,7 +202,8 @@ export const handleSend = async (
console.error("Error fetching AI response:", error) console.error("Error fetching AI response:", error)
const errorMessage = { const errorMessage = {
role: "assistant" as const, role: "assistant" as const,
content: error.message || "Sorry, I encountered an error. Please try again.", content:
error.message || "Sorry, I encountered an error. Please try again.",
} }
setMessages((prev) => [...prev, errorMessage]) setMessages((prev) => [...prev, errorMessage])
} }

View File

@ -1,15 +1,23 @@
import { CornerUpLeft } from "lucide-react" import { Check, CornerUpLeft, X } from "lucide-react"
import monaco from "monaco-editor"
import { Components } from "react-markdown" import { Components } from "react-markdown"
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter" import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"
import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism" import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism"
import { Button } from "../../../ui/button" import { Button } from "../../../ui/button"
import ApplyButton from "../ApplyButton"
import { stringifyContent } from "./chatUtils" import { stringifyContent } from "./chatUtils"
// Create markdown components for chat message component // Create markdown components for chat message component
export const createMarkdownComponents = ( export const createMarkdownComponents = (
renderCopyButton: (text: any) => JSX.Element, renderCopyButton: (text: any) => JSX.Element,
renderMarkdownElement: (props: any) => JSX.Element, renderMarkdownElement: (props: any) => JSX.Element,
askAboutCode: (code: any) => void askAboutCode: (code: any) => void,
activeFileName: string,
activeFileContent: string,
editorRef: any,
handleApplyCode: (mergedCode: string) => void,
mergeDecorationsCollection?: monaco.editor.IEditorDecorationsCollection,
setMergeDecorationsCollection?: (collection: undefined) => void
): Components => ({ ): Components => ({
code: ({ code: ({
node, node,
@ -25,12 +33,65 @@ export const createMarkdownComponents = (
const match = /language-(\w+)/.exec(className || "") const match = /language-(\w+)/.exec(className || "")
return match ? ( return match ? (
<div className="relative border border-input rounded-md my-4"> <div className="relative border border-input rounded-md mt-8 my-2 translate-y-[-1rem]">
<div className="absolute top-0 left-0 px-2 py-1 text-xs font-semibold text-gray-200 bg-#1e1e1e rounded-tl"> <div className="absolute top-0 left-0 px-2 py-1 text-xs font-semibold text-gray-200 rounded-tl">
{match[1]} {match[1]}
</div> </div>
<div className="absolute top-0 right-0 flex"> <div className="sticky top-0 right-0 flex justify-end z-10">
<div className="flex border border-input shadow-lg bg-background rounded-md">
{renderCopyButton(children)} {renderCopyButton(children)}
<div className="w-px bg-input"></div>
{!mergeDecorationsCollection ? (
<ApplyButton
code={String(children)}
activeFileName={activeFileName}
activeFileContent={activeFileContent}
editorRef={editorRef}
onApply={handleApplyCode}
/>
) : (
<>
<Button
onClick={() => {
if (
setMergeDecorationsCollection &&
mergeDecorationsCollection &&
editorRef?.current
) {
mergeDecorationsCollection.clear()
setMergeDecorationsCollection(undefined)
}
}}
size="sm"
variant="ghost"
className="p-1 h-6"
title="Accept Changes"
>
<Check className="w-4 h-4 text-green-500" />
</Button>
<div className="w-px bg-input"></div>
<Button
onClick={() => {
if (
setMergeDecorationsCollection &&
mergeDecorationsCollection &&
editorRef?.current
) {
editorRef.current.getModel()?.setValue(activeFileContent)
mergeDecorationsCollection.clear()
setMergeDecorationsCollection(undefined)
}
}}
size="sm"
variant="ghost"
className="p-1 h-6"
title="Discard Changes"
>
<X className="w-4 h-4 text-red-500" />
</Button>
</>
)}
<div className="w-px bg-input"></div>
<Button <Button
onClick={(e) => { onClick={(e) => {
e.preventDefault() e.preventDefault()
@ -44,7 +105,7 @@ export const createMarkdownComponents = (
<CornerUpLeft className="w-4 h-4" /> <CornerUpLeft className="w-4 h-4" />
</Button> </Button>
</div> </div>
<div className="pt-6"> </div>
<SyntaxHighlighter <SyntaxHighlighter
style={vscDarkPlus as any} style={vscDarkPlus as any}
language={match[1]} language={match[1]}
@ -58,7 +119,6 @@ export const createMarkdownComponents = (
{stringifyContent(children)} {stringifyContent(children)}
</SyntaxHighlighter> </SyntaxHighlighter>
</div> </div>
</div>
) : ( ) : (
<code className={className} {...props}> <code className={className} {...props}>
{children} {children}

View File

@ -56,6 +56,9 @@ export interface AIChatProps {
files: (TFile | TFolder)[] files: (TFile | TFolder)[]
templateType: string templateType: string
templateConfig?: TemplateConfig templateConfig?: TemplateConfig
handleApplyCode: (mergedCode: string) => void
mergeDecorationsCollection?: monaco.editor.IEditorDecorationsCollection
setMergeDecorationsCollection?: (collection: undefined) => void
} }
// Chat input props interface // Chat input props interface
@ -105,6 +108,12 @@ export interface MessageProps {
) => void ) => void
setIsContextExpanded: (isExpanded: boolean) => void setIsContextExpanded: (isExpanded: boolean) => void
socket: Socket | null socket: Socket | null
handleApplyCode: (mergedCode: string) => void
activeFileName: string
activeFileContent: string
editorRef: any
mergeDecorationsCollection?: monaco.editor.IEditorDecorationsCollection
setMergeDecorationsCollection?: (collection: undefined) => void
} }
// Context tabs props interface // Context tabs props interface

View File

@ -66,15 +66,17 @@ export default function GenerateInput({
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
messages: [{ messages: [
{
role: "user", role: "user",
content: regenerate ? currentPrompt : input content: regenerate ? currentPrompt : input,
}], },
],
context: null, context: null,
activeFileContent: data.code, activeFileContent: data.code,
isEditMode: true, isEditMode: true,
fileName: data.fileName, fileName: data.fileName,
line: data.line line: data.line,
}), }),
}) })

View File

@ -104,6 +104,13 @@ export default function CodeEditor({
// Added this state to track the most recent content for each file // Added this state to track the most recent content for each file
const [fileContents, setFileContents] = useState<Record<string, string>>({}) const [fileContents, setFileContents] = useState<Record<string, string>>({})
// Apply Button merger decoration state
const [mergeDecorations, setMergeDecorations] = useState<
monaco.editor.IModelDeltaDecoration[]
>([])
const [mergeDecorationsCollection, setMergeDecorationsCollection] =
useState<monaco.editor.IEditorDecorationsCollection>()
// Editor state // Editor state
const [editorLanguage, setEditorLanguage] = useState("plaintext") const [editorLanguage, setEditorLanguage] = useState("plaintext")
const [cursorLine, setCursorLine] = useState(0) const [cursorLine, setCursorLine] = useState(0)
@ -375,6 +382,49 @@ export default function CodeEditor({
}) })
}, [editorRef]) }, [editorRef])
// handle apply code
const handleApplyCode = useCallback(
(mergedCode: string) => {
if (!editorRef) return
const originalLines = activeFileContent.split("\n")
const mergedLines = mergedCode.split("\n")
const decorations: monaco.editor.IModelDeltaDecoration[] = []
for (
let i = 0;
i < Math.max(originalLines.length, mergedLines.length);
i++
) {
if (originalLines[i] !== mergedLines[i]) {
decorations.push({
range: new monaco.Range(i + 1, 1, i + 1, 1),
options: {
isWholeLine: true,
className:
i < originalLines.length
? "removed-line-decoration"
: "added-line-decoration",
glyphMarginClassName:
i < originalLines.length
? "removed-line-glyph"
: "added-line-glyph",
},
})
}
}
// Update editor content
editorRef.setValue(mergedCode)
// Apply decorations
const newDecorations = editorRef.createDecorationsCollection(decorations)
setMergeDecorationsCollection(newDecorations)
},
[editorRef, activeFileContent]
)
// Generate widget effect // Generate widget effect
useEffect(() => { useEffect(() => {
if (generate.show) { if (generate.show) {
@ -1234,6 +1284,9 @@ export default function CodeEditor({
lastCopiedRangeRef={lastCopiedRangeRef} lastCopiedRangeRef={lastCopiedRangeRef}
files={files} files={files}
templateType={sandboxData.type} templateType={sandboxData.type}
handleApplyCode={handleApplyCode}
mergeDecorationsCollection={mergeDecorationsCollection}
setMergeDecorationsCollection={setMergeDecorationsCollection}
/> />
</ResizablePanel> </ResizablePanel>
</> </>

View File

@ -9,7 +9,7 @@ import {
import { useTerminal } from "@/context/TerminalContext" import { useTerminal } from "@/context/TerminalContext"
import { Sandbox, User } from "@/lib/types" import { Sandbox, User } from "@/lib/types"
import { Globe } from "lucide-react" import { Globe } from "lucide-react"
import { useState } from "react" import { useEffect, useState } from "react"
export default function DeployButtonModal({ export default function DeployButtonModal({
userData, userData,
@ -18,8 +18,19 @@ export default function DeployButtonModal({
userData: User userData: User
data: Sandbox data: Sandbox
}) { }) {
const { deploy } = useTerminal() const { deploy, getAppExists } = useTerminal()
const [isDeploying, setIsDeploying] = useState(false) const [isDeploying, setIsDeploying] = useState(false)
const [isDeployed, setIsDeployed] = useState(false)
useEffect(() => {
const checkDeployment = async () => {
if (getAppExists) {
const exists = await getAppExists(data.id)
setIsDeployed(exists)
}
}
checkDeployment()
}, [data.id, getAppExists])
const handleDeploy = () => { const handleDeploy = () => {
if (isDeploying) { if (isDeploying) {
@ -30,6 +41,7 @@ export default function DeployButtonModal({
setIsDeploying(true) setIsDeploying(true)
deploy(() => { deploy(() => {
setIsDeploying(false) setIsDeploying(false)
setIsDeployed(true)
}) })
} }
} }
@ -52,8 +64,9 @@ export default function DeployButtonModal({
<DeploymentOption <DeploymentOption
icon={<Globe className="text-gray-500 w-5 h-5" />} icon={<Globe className="text-gray-500 w-5 h-5" />}
domain={`${data.id}.gitwit.app`} domain={`${data.id}.gitwit.app`}
timestamp="Deployed 1h ago" timestamp="Deployed 1m ago"
user={userData.name} user={userData.name}
isDeployed={isDeployed}
/> />
</div> </div>
<Button <Button
@ -61,7 +74,7 @@ export default function DeployButtonModal({
className="mt-4 w-full bg-[#0a0a0a] text-white hover:bg-[#262626]" className="mt-4 w-full bg-[#0a0a0a] text-white hover:bg-[#262626]"
onClick={handleDeploy} onClick={handleDeploy}
> >
{isDeploying ? "Deploying..." : "Update"} {isDeploying ? "Deploying..." : isDeployed ? "Update" : "Deploy"}
</Button> </Button>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
@ -74,16 +87,19 @@ function DeploymentOption({
domain, domain,
timestamp, timestamp,
user, user,
isDeployed,
}: { }: {
icon: React.ReactNode icon: React.ReactNode
domain: string domain: string
timestamp: string timestamp: string
user: string user: string
isDeployed: boolean
}) { }) {
return ( return (
<div className="flex flex-col gap-2 w-full text-left p-2 rounded-md border border-gray-700 bg-gray-900"> <div className="flex flex-col gap-2 w-full text-left p-2 rounded-md border border-gray-700 bg-gray-900">
<div className="flex items-start gap-2 relative"> <div className="flex items-start gap-2 relative">
<div className="flex-shrink-0">{icon}</div> <div className="flex-shrink-0">{icon}</div>
{isDeployed ? (
<a <a
href={`https://${domain}`} href={`https://${domain}`}
target="_blank" target="_blank"
@ -92,9 +108,12 @@ function DeploymentOption({
> >
{domain} {domain}
</a> </a>
) : (
<span className="font-semibold text-gray-300">{domain}</span>
)}
</div> </div>
<p className="text-sm text-gray-400 mt-0 ml-7"> <p className="text-sm text-gray-400 mt-0 ml-7">
{timestamp} {user} {isDeployed ? `${timestamp}${user}` : "Never deployed"}
</p> </p>
</div> </div>
) )

View File

@ -7,7 +7,7 @@ import "./xterm.css"
import { debounce } from "@/lib/utils" import { debounce } from "@/lib/utils"
import { Loader2 } from "lucide-react" import { Loader2 } from "lucide-react"
import { useTheme } from "next-themes" import { useTheme } from "next-themes"
import { ElementRef, useEffect, useRef } from "react" import { ElementRef, useEffect, useRef, useCallback } from "react"
import { Socket } from "socket.io-client" import { Socket } from "socket.io-client"
export default function EditorTerminal({ export default function EditorTerminal({
socket, socket,
@ -28,7 +28,6 @@ export default function EditorTerminal({
useEffect(() => { useEffect(() => {
if (!terminalContainerRef.current) return if (!terminalContainerRef.current) return
// console.log("new terminal", id, term ? "reusing" : "creating");
const terminal = new Terminal({ const terminal = new Terminal({
cursorBlink: true, cursorBlink: true,
@ -37,13 +36,54 @@ export default function EditorTerminal({
fontSize: 14, fontSize: 14,
lineHeight: 1.5, lineHeight: 1.5,
letterSpacing: 0, letterSpacing: 0,
allowTransparency: true,
rightClickSelectsWord: true,
allowProposedApi: true, // for custom key events
})
// right-click paste handler
const handleContextMenu = (e: MouseEvent) => {
e.preventDefault()
navigator.clipboard.readText().then((text) => {
if (text) {
socket.emit("terminalData", { id, data: text })
}
})
}
terminalContainerRef.current.addEventListener(
"contextmenu",
handleContextMenu
)
// keyboard paste handler
terminal.attachCustomKeyEventHandler((event: KeyboardEvent) => {
if (event.type === "keydown") {
if (
(event.ctrlKey || event.metaKey) &&
event.key.toLowerCase() === "v"
) {
event.preventDefault()
navigator.clipboard.readText().then((text) => {
if (text) {
socket.emit("terminalData", { id, data: text })
}
})
return false
}
}
return true
}) })
setTerm(terminal) setTerm(terminal)
const dispose = () => {
return () => {
terminal.dispose() terminal.dispose()
terminalContainerRef.current?.removeEventListener(
"contextmenu",
handleContextMenu
)
} }
return dispose
}, []) }, [])
useEffect(() => { useEffect(() => {
@ -81,7 +121,6 @@ export default function EditorTerminal({
const { width, height } = entry.contentRect const { width, height } = entry.contentRect
// Only call fit if the size has actually changed
if ( if (
width !== terminalContainerRef.current.offsetWidth || width !== terminalContainerRef.current.offsetWidth ||
height !== terminalContainerRef.current.offsetHeight height !== terminalContainerRef.current.offsetHeight
@ -92,10 +131,9 @@ export default function EditorTerminal({
console.error("Error during fit:", err) console.error("Error during fit:", err)
} }
} }
}, 50) // Debounce for 50ms }, 50)
) )
// start observing for resize
resizeObserver.observe(terminalContainerRef.current) resizeObserver.observe(terminalContainerRef.current)
return () => { return () => {
disposableOnData.dispose() disposableOnData.dispose()
@ -124,6 +162,7 @@ export default function EditorTerminal({
ref={terminalContainerRef} ref={terminalContainerRef}
style={{ display: visible ? "block" : "none" }} style={{ display: visible ? "block" : "none" }}
className="w-full h-full text-left" className="w-full h-full text-left"
tabIndex={0}
> >
{term === null ? ( {term === null ? (
<div className="flex items-center text-muted-foreground p-2"> <div className="flex items-center text-muted-foreground p-2">

View File

@ -231,7 +231,10 @@ function ProfileCard({
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<p className="text-xs text-muted-foreground">{joinedAt}</p> <p className="text-xs text-muted-foreground">{joinedAt}</p>
{typeof generations === "number" && ( {typeof generations === "number" && (
<SubscriptionBadge generations={generations} tier={tier as keyof typeof TIERS} /> <SubscriptionBadge
generations={generations}
tier={tier as keyof typeof TIERS}
/>
)} )}
</div> </div>
</> </>
@ -445,7 +448,13 @@ const StatsItem = ({ icon: Icon, label }: StatsItemProps) => (
// #endregion // #endregion
// #region Sub Badge // #region Sub Badge
const SubscriptionBadge = ({ generations, tier = "FREE" }: { generations: number, tier?: keyof typeof TIERS }) => { const SubscriptionBadge = ({
generations,
tier = "FREE",
}: {
generations: number
tier?: keyof typeof TIERS
}) => {
return ( return (
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<Badge variant="secondary" className="text-sm cursor-pointer"> <Badge variant="secondary" className="text-sm cursor-pointer">

View File

@ -43,7 +43,11 @@ const TIER_INFO = {
}, },
} as const } as const
export default function UserButton({ userData: initialUserData }: { userData: User }) { export default function UserButton({
userData: initialUserData,
}: {
userData: User
}) {
const [userData, setUserData] = useState<User>(initialUserData) const [userData, setUserData] = useState<User>(initialUserData)
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const { signOut } = useClerk() const { signOut } = useClerk()
@ -57,7 +61,7 @@ export default function UserButton({ userData: initialUserData }: { userData: Us
headers: { headers: {
Authorization: `${process.env.NEXT_PUBLIC_WORKERS_KEY}`, Authorization: `${process.env.NEXT_PUBLIC_WORKERS_KEY}`,
}, },
cache: 'no-store' cache: "no-store",
} }
) )
if (res.ok) { if (res.ok) {
@ -75,9 +79,12 @@ export default function UserButton({ userData: initialUserData }: { userData: Us
} }
}, [isOpen]) }, [isOpen])
const tierInfo = TIER_INFO[userData.tier as keyof typeof TIER_INFO] || TIER_INFO.FREE const tierInfo =
TIER_INFO[userData.tier as keyof typeof TIER_INFO] || TIER_INFO.FREE
const TierIcon = tierInfo.icon const TierIcon = tierInfo.icon
const usagePercentage = Math.floor((userData.generations || 0) * 100 / tierInfo.limit) const usagePercentage = Math.floor(
((userData.generations || 0) * 100) / tierInfo.limit
)
const handleUpgrade = async () => { const handleUpgrade = async () => {
router.push(`/@${userData.username}`) router.push(`/@${userData.username}`)
@ -98,7 +105,6 @@ export default function UserButton({ userData: initialUserData }: { userData: Us
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem className="cursor-pointer" asChild> <DropdownMenuItem className="cursor-pointer" asChild>
<Link href={"/dashboard"}> <Link href={"/dashboard"}>
<LayoutDashboard className="mr-2 size-4" /> <LayoutDashboard className="mr-2 size-4" />
@ -114,12 +120,13 @@ export default function UserButton({ userData: initialUserData }: { userData: Us
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<div className="py-1.5 px-2 w-full"> <div className="py-1.5 px-2 w-full">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<TierIcon className={`h-4 w-4 ${tierInfo.color}`} /> <TierIcon className={`h-4 w-4 ${tierInfo.color}`} />
<span className="text-sm font-medium">{userData.tier || "FREE"} Plan</span> <span className="text-sm font-medium">
{userData.tier || "FREE"} Plan
</span>
</div> </div>
{(userData.tier === "FREE" || userData.tier === "PRO") && ( {(userData.tier === "FREE" || userData.tier === "PRO") && (
<Button <Button
@ -139,15 +146,19 @@ export default function UserButton({ userData: initialUserData }: { userData: Us
<div className="w-full"> <div className="w-full">
<div className="flex items-center justify-between text-sm text-muted-foreground mb-2"> <div className="flex items-center justify-between text-sm text-muted-foreground mb-2">
<span>AI Usage</span> <span>AI Usage</span>
<span>{userData.generations}/{tierInfo.limit}</span> <span>
{userData.generations}/{tierInfo.limit}
</span>
</div> </div>
<div className="rounded-full w-full h-2 overflow-hidden bg-secondary mb-1"> <div className="rounded-full w-full h-2 overflow-hidden bg-secondary mb-1">
<div <div
className={`h-full rounded-full transition-all duration-300 ${ className={`h-full rounded-full transition-all duration-300 ${
usagePercentage > 90 ? 'bg-red-500' : usagePercentage > 90
usagePercentage > 75 ? 'bg-yellow-500' : ? "bg-red-500"
tierInfo.color.replace('text-', 'bg-') : usagePercentage > 75
? "bg-yellow-500"
: tierInfo.color.replace("text-", "bg-")
}`} }`}
style={{ style={{
width: `${Math.min(usagePercentage, 100)}%`, width: `${Math.min(usagePercentage, 100)}%`,
@ -173,4 +184,3 @@ export default function UserButton({ userData: initialUserData }: { userData: Us
</DropdownMenu> </DropdownMenu>
) )
} }

View File

@ -20,6 +20,7 @@ interface TerminalContextType {
createNewTerminal: (command?: string) => Promise<void> createNewTerminal: (command?: string) => Promise<void>
closeTerminal: (id: string) => void closeTerminal: (id: string) => void
deploy: (callback: () => void) => void deploy: (callback: () => void) => void
getAppExists: ((appName: string) => Promise<boolean>) | null
} }
const TerminalContext = createContext<TerminalContextType | undefined>( const TerminalContext = createContext<TerminalContextType | undefined>(
@ -35,6 +36,19 @@ export const TerminalProvider: React.FC<{ children: React.ReactNode }> = ({
>([]) >([])
const [activeTerminalId, setActiveTerminalId] = useState<string>("") const [activeTerminalId, setActiveTerminalId] = useState<string>("")
const [creatingTerminal, setCreatingTerminal] = useState<boolean>(false) const [creatingTerminal, setCreatingTerminal] = useState<boolean>(false)
const [isSocketReady, setIsSocketReady] = useState<boolean>(false)
// Listen for the "ready" signal from the socket
React.useEffect(() => {
if (socket) {
socket.on("ready", () => {
setIsSocketReady(true)
})
}
return () => {
if (socket) socket.off("ready")
}
}, [socket])
const createNewTerminal = async (command?: string): Promise<void> => { const createNewTerminal = async (command?: string): Promise<void> => {
if (!socket) return if (!socket) return
@ -78,6 +92,18 @@ export const TerminalProvider: React.FC<{ children: React.ReactNode }> = ({
}) })
} }
const getAppExists = async (appName: string): Promise<boolean> => {
console.log("Is there a socket: " + !!socket)
if (!socket) {
console.error("Couldn't check if app exists: No socket")
return false
}
const response: { exists: boolean } = await new Promise((resolve) =>
socket.emit("getAppExists", { appName }, resolve)
)
return response.exists
}
const value = { const value = {
terminals, terminals,
setTerminals, setTerminals,
@ -88,6 +114,7 @@ export const TerminalProvider: React.FC<{ children: React.ReactNode }> = ({
createNewTerminal, createNewTerminal,
closeTerminal, closeTerminal,
deploy, deploy,
getAppExists: isSocketReady ? getAppExists : null,
} }
return ( return (

View File

@ -38,6 +38,6 @@ export const projectTemplates: {
name: "PHP", name: "PHP",
description: "PHP development environment", description: "PHP development environment",
icon: "/project-icons/php.svg", icon: "/project-icons/php.svg",
disabled: false disabled: false,
}, },
] ]

View File

@ -1,7 +1,7 @@
export interface TemplateConfig { export interface TemplateConfig {
id: string id: string
name: string, name: string
runCommand: string, runCommand: string
fileStructure: { fileStructure: {
[key: string]: { [key: string]: {
purpose: string purpose: string
@ -25,67 +25,67 @@ export interface TemplateConfig {
fileStructure: { fileStructure: {
"src/": { "src/": {
purpose: "source", purpose: "source",
description: "Contains all React components and application logic" description: "Contains all React components and application logic",
}, },
"src/components/": { "src/components/": {
purpose: "components", purpose: "components",
description: "Reusable React components" description: "Reusable React components",
}, },
"src/lib/": { "src/lib/": {
purpose: "utilities", purpose: "utilities",
description: "Utility functions and shared code" description: "Utility functions and shared code",
}, },
"src/App.tsx": { "src/App.tsx": {
purpose: "entry", purpose: "entry",
description: "Main application component" description: "Main application component",
}, },
"src/index.tsx": { "src/index.tsx": {
purpose: "entry", purpose: "entry",
description: "Application entry point" description: "Application entry point",
}, },
"src/index.css": { "src/index.css": {
purpose: "styles", purpose: "styles",
description: "Global CSS styles" description: "Global CSS styles",
}, },
"public/": { "public/": {
purpose: "static", purpose: "static",
description: "Static assets and index.html" description: "Static assets and index.html",
}, },
"tsconfig.json": { "tsconfig.json": {
purpose: "config", purpose: "config",
description: "TypeScript configuration" description: "TypeScript configuration",
}, },
"vite.config.ts": { "vite.config.ts": {
purpose: "config", purpose: "config",
description: "Vite bundler configuration" description: "Vite bundler configuration",
}, },
"package.json": { "package.json": {
purpose: "config", purpose: "config",
description: "Project dependencies and scripts" description: "Project dependencies and scripts",
} },
}, },
conventions: [ conventions: [
"Use functional components with hooks", "Use functional components with hooks",
"Follow React naming conventions (PascalCase for components)", "Follow React naming conventions (PascalCase for components)",
"Keep components small and focused", "Keep components small and focused",
"Use TypeScript for type safety" "Use TypeScript for type safety",
], ],
dependencies: { dependencies: {
"@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-slot": "^1.1.0",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", clsx: "^2.1.1",
"lucide-react": "^0.441.0", "lucide-react": "^0.441.0",
"react": "^18.3.1", react: "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"tailwind-merge": "^2.5.2", "tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7" "tailwindcss-animate": "^1.0.7",
}, },
scripts: { scripts: {
"dev": "vite", dev: "vite",
"build": "tsc && vite build", build: "tsc && vite build",
"preview": "vite preview", preview: "vite preview",
} },
}, },
// Next.js template config // Next.js template config
nextjs: { nextjs: {
@ -95,71 +95,71 @@ export interface TemplateConfig {
fileStructure: { fileStructure: {
"pages/": { "pages/": {
purpose: "routing", purpose: "routing",
description: "Page components and API routes" description: "Page components and API routes",
}, },
"pages/api/": { "pages/api/": {
purpose: "api", purpose: "api",
description: "API route handlers" description: "API route handlers",
}, },
"pages/_app.tsx": { "pages/_app.tsx": {
purpose: "entry", purpose: "entry",
description: "Application wrapper component" description: "Application wrapper component",
}, },
"pages/index.tsx": { "pages/index.tsx": {
purpose: "page", purpose: "page",
description: "Homepage component" description: "Homepage component",
}, },
"public/": { "public/": {
purpose: "static", purpose: "static",
description: "Static assets and files" description: "Static assets and files",
}, },
"styles/": { "styles/": {
purpose: "styles", purpose: "styles",
description: "CSS modules and global styles" description: "CSS modules and global styles",
}, },
"styles/globals.css": { "styles/globals.css": {
purpose: "styles", purpose: "styles",
description: "Global CSS styles" description: "Global CSS styles",
}, },
"styles/Home.module.css": { "styles/Home.module.css": {
purpose: "styles", purpose: "styles",
description: "Homepage-specific styles" description: "Homepage-specific styles",
}, },
"next.config.js": { "next.config.js": {
purpose: "config", purpose: "config",
description: "Next.js configuration" description: "Next.js configuration",
}, },
"next-env.d.ts": { "next-env.d.ts": {
purpose: "types", purpose: "types",
description: "Next.js TypeScript declarations" description: "Next.js TypeScript declarations",
}, },
"tsconfig.json": { "tsconfig.json": {
purpose: "config", purpose: "config",
description: "TypeScript configuration" description: "TypeScript configuration",
}, },
"package.json": { "package.json": {
purpose: "config", purpose: "config",
description: "Project dependencies and scripts" description: "Project dependencies and scripts",
} },
}, },
conventions: [ conventions: [
"Use file-system based routing", "Use file-system based routing",
"Keep API routes in pages/api", "Keep API routes in pages/api",
"Use CSS Modules for component styles", "Use CSS Modules for component styles",
"Follow Next.js data fetching patterns" "Follow Next.js data fetching patterns",
], ],
dependencies: { dependencies: {
"next": "^14.1.0", next: "^14.1.0",
"react": "^18.2.0", react: "^18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"tailwindcss": "^3.4.1" tailwindcss: "^3.4.1",
}, },
scripts: { scripts: {
"dev": "next dev", dev: "next dev",
"build": "next build", build: "next build",
"start": "next start", start: "next start",
"lint": "next lint", lint: "next lint",
} },
}, },
// Streamlit template config // Streamlit template config
streamlit: { streamlit: {
@ -169,35 +169,35 @@ export interface TemplateConfig {
fileStructure: { fileStructure: {
"main.py": { "main.py": {
purpose: "entry", purpose: "entry",
description: "Main Streamlit application file" description: "Main Streamlit application file",
}, },
"requirements.txt": { "requirements.txt": {
purpose: "dependencies", purpose: "dependencies",
description: "Python package dependencies" description: "Python package dependencies",
}, },
"Procfile": { Procfile: {
purpose: "deployment", purpose: "deployment",
description: "Deployment configuration for hosting platforms" description: "Deployment configuration for hosting platforms",
}, },
"venv/": { "venv/": {
purpose: "environment", purpose: "environment",
description: "Python virtual environment directory" description: "Python virtual environment directory",
} },
}, },
conventions: [ conventions: [
"Use Streamlit components for UI", "Use Streamlit components for UI",
"Follow PEP 8 style guide", "Follow PEP 8 style guide",
"Keep dependencies in requirements.txt", "Keep dependencies in requirements.txt",
"Use virtual environment for isolation" "Use virtual environment for isolation",
], ],
dependencies: { dependencies: {
"streamlit": "^1.40.0", streamlit: "^1.40.0",
"altair": "^5.5.0" altair: "^5.5.0",
}, },
scripts: { scripts: {
"start": "streamlit run main.py", start: "streamlit run main.py",
"dev": "./venv/bin/streamlit run main.py --server.runOnSave true" dev: "./venv/bin/streamlit run main.py --server.runOnSave true",
} },
}, },
// HTML template config // HTML template config
vanillajs: { vanillajs: {
@ -207,43 +207,43 @@ export interface TemplateConfig {
fileStructure: { fileStructure: {
"index.html": { "index.html": {
purpose: "entry", purpose: "entry",
description: "Main HTML entry point" description: "Main HTML entry point",
}, },
"style.css": { "style.css": {
purpose: "styles", purpose: "styles",
description: "Global CSS styles" description: "Global CSS styles",
}, },
"script.js": { "script.js": {
purpose: "scripts", purpose: "scripts",
description: "JavaScript application logic" description: "JavaScript application logic",
}, },
"package.json": { "package.json": {
purpose: "config", purpose: "config",
description: "Project dependencies and scripts" description: "Project dependencies and scripts",
}, },
"package-lock.json": { "package-lock.json": {
purpose: "config", purpose: "config",
description: "Locked dependency versions" description: "Locked dependency versions",
}, },
"vite.config.js": { "vite.config.js": {
purpose: "config", purpose: "config",
description: "Vite bundler configuration" description: "Vite bundler configuration",
} },
}, },
conventions: [ conventions: [
"Use semantic HTML elements", "Use semantic HTML elements",
"Keep CSS modular and organized", "Keep CSS modular and organized",
"Write clean, modular JavaScript", "Write clean, modular JavaScript",
"Follow modern ES6+ practices" "Follow modern ES6+ practices",
], ],
dependencies: { dependencies: {
"vite": "^5.0.12" vite: "^5.0.12",
}, },
scripts: { scripts: {
"dev": "vite", dev: "vite",
"build": "vite build", build: "vite build",
"preview": "vite preview" preview: "vite preview",
} },
}, },
// PHP template config // PHP template config
php: { php: {
@ -253,38 +253,38 @@ export interface TemplateConfig {
fileStructure: { fileStructure: {
"index.php": { "index.php": {
purpose: "entry", purpose: "entry",
description: "Main PHP entry point" description: "Main PHP entry point",
}, },
"package.json": { "package.json": {
purpose: "config", purpose: "config",
description: "Frontend dependencies and scripts" description: "Frontend dependencies and scripts",
}, },
"package-lock.json": { "package-lock.json": {
purpose: "config", purpose: "config",
description: "Locked dependency versions" description: "Locked dependency versions",
}, },
"vite.config.js": { "vite.config.js": {
purpose: "config", purpose: "config",
description: "Vite configuration for frontend assets" description: "Vite configuration for frontend assets",
}, },
"node_modules/": { "node_modules/": {
purpose: "dependencies", purpose: "dependencies",
description: "Frontend dependency files" description: "Frontend dependency files",
} },
}, },
conventions: [ conventions: [
"Follow PSR-12 coding standards", "Follow PSR-12 coding standards",
"Use modern PHP 8+ features", "Use modern PHP 8+ features",
"Organize assets with Vite", "Organize assets with Vite",
"Keep PHP logic separate from presentation" "Keep PHP logic separate from presentation",
], ],
dependencies: { dependencies: {
"vite": "^5.0.0" vite: "^5.0.0",
}, },
scripts: { scripts: {
"dev": "vite", dev: "vite",
"build": "vite build", build: "vite build",
"preview": "vite preview" preview: "vite preview",
} },
} },
} }

View File

@ -54,6 +54,7 @@
"monaco-themes": "^0.4.4", "monaco-themes": "^0.4.4",
"next": "14.1.3", "next": "14.1.3",
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
"openai": "^4.73.1",
"posthog-js": "^1.147.0", "posthog-js": "^1.147.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18", "react-dom": "^18",
@ -7492,6 +7493,65 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/openai": {
"version": "4.73.1",
"resolved": "https://registry.npmjs.org/openai/-/openai-4.73.1.tgz",
"integrity": "sha512-nWImDJBcUsqrhy7yJScXB4+iqjzbUEgzfA3un/6UnHFdwWhjX24oztj69Ped/njABfOdLcO/F7CeWTI5dt8Xmg==",
"license": "Apache-2.0",
"dependencies": {
"@types/node": "^18.11.18",
"@types/node-fetch": "^2.6.4",
"abort-controller": "^3.0.0",
"agentkeepalive": "^4.2.1",
"form-data-encoder": "1.7.2",
"formdata-node": "^4.3.2",
"node-fetch": "^2.6.7"
},
"bin": {
"openai": "bin/cli"
},
"peerDependencies": {
"zod": "^3.23.8"
},
"peerDependenciesMeta": {
"zod": {
"optional": true
}
}
},
"node_modules/openai/node_modules/@types/node": {
"version": "18.19.67",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.67.tgz",
"integrity": "sha512-wI8uHusga+0ZugNp0Ol/3BqQfEcCCNfojtO6Oou9iVNGPTL6QNSdnUdqq85fRgIorLhLMuPIKpsN98QE9Nh+KQ==",
"license": "MIT",
"dependencies": {
"undici-types": "~5.26.4"
}
},
"node_modules/openai/node_modules/@types/node-fetch": {
"version": "2.6.12",
"resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.12.tgz",
"integrity": "sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==",
"license": "MIT",
"dependencies": {
"@types/node": "*",
"form-data": "^4.0.0"
}
},
"node_modules/openai/node_modules/form-data": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz",
"integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/ora": { "node_modules/ora": {
"version": "6.3.1", "version": "6.3.1",
"resolved": "https://registry.npmjs.org/ora/-/ora-6.3.1.tgz", "resolved": "https://registry.npmjs.org/ora/-/ora-6.3.1.tgz",

View File

@ -55,6 +55,7 @@
"monaco-themes": "^0.4.4", "monaco-themes": "^0.4.4",
"next": "14.1.3", "next": "14.1.3",
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
"openai": "^4.73.1",
"posthog-js": "^1.147.0", "posthog-js": "^1.147.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18", "react-dom": "^18",