Compare commits
37 Commits
refactor-s
...
ai-codegen
Author | SHA1 | Date | |
---|---|---|---|
a9c5db92ff | |||
2c9f130a37 | |||
fac1404e14 | |||
2317cf49e9 | |||
24332794f1 | |||
a8b8a25e4c | |||
88058ca710 | |||
7f6e2bf62d | |||
b48b08a274 | |||
b64913a8f3 | |||
0809eaca4e | |||
8b890fdffe | |||
224d190468 | |||
7ace8f569a | |||
a87a4b5160 | |||
e229dab826 | |||
3ad7e5d9bc | |||
935c314357 | |||
0b6085c57c | |||
87a74d40d6 | |||
aa554fa39d | |||
28e6e2f889 | |||
dc4be6392a | |||
3e891e6ab1 | |||
16e0c250d6 | |||
fcc7a836a6 | |||
09ab81f5bd | |||
5ba6bdba15 | |||
1479d25d49 | |||
1de980cdd6 | |||
c644b0054e | |||
33c8ed8b32 | |||
162da9f7ce | |||
af83b33f51 | |||
98eda3b080 | |||
67f3efa038 | |||
76f6e4b0bb |
58
backend/server/src/ConnectionManager.ts
Normal file
58
backend/server/src/ConnectionManager.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import { Socket } from "socket.io"
|
||||
|
||||
class Counter {
|
||||
private count: number = 0
|
||||
|
||||
increment() {
|
||||
this.count++
|
||||
}
|
||||
|
||||
decrement() {
|
||||
this.count = Math.max(0, this.count - 1)
|
||||
}
|
||||
|
||||
getValue(): number {
|
||||
return this.count
|
||||
}
|
||||
}
|
||||
|
||||
// Owner Connection Management
|
||||
export class ConnectionManager {
|
||||
// Counts how many times the owner is connected to a sandbox
|
||||
private ownerConnections: Record<string, Counter> = {}
|
||||
// Stores all sockets connected to a given sandbox
|
||||
private sockets: Record<string, Set<Socket>> = {}
|
||||
|
||||
// Checks if the owner of a sandbox is connected
|
||||
ownerIsConnected(sandboxId: string): boolean {
|
||||
return this.ownerConnections[sandboxId]?.getValue() > 0
|
||||
}
|
||||
|
||||
// Adds a connection for a sandbox
|
||||
addConnectionForSandbox(socket: Socket, sandboxId: string, isOwner: boolean) {
|
||||
this.sockets[sandboxId] ??= new Set()
|
||||
this.sockets[sandboxId].add(socket)
|
||||
|
||||
// If the connection is for the owner, increments the owner connection counter
|
||||
if (isOwner) {
|
||||
this.ownerConnections[sandboxId] ??= new Counter()
|
||||
this.ownerConnections[sandboxId].increment()
|
||||
}
|
||||
}
|
||||
|
||||
// Removes a connection for a sandbox
|
||||
removeConnectionForSandbox(socket: Socket, sandboxId: string, isOwner: boolean) {
|
||||
this.sockets[sandboxId]?.delete(socket)
|
||||
|
||||
// If the connection being removed is for the owner, decrements the owner connection counter
|
||||
if (isOwner) {
|
||||
this.ownerConnections[sandboxId]?.decrement()
|
||||
}
|
||||
}
|
||||
|
||||
// Returns the set of sockets connected to a given sandbox
|
||||
connectionsForSandbox(sandboxId: string): Set<Socket> {
|
||||
return this.sockets[sandboxId] ?? new Set();
|
||||
}
|
||||
|
||||
}
|
@ -4,12 +4,6 @@ import RemoteFileStorage from "./RemoteFileStorage"
|
||||
import { MAX_BODY_SIZE } from "./ratelimit"
|
||||
import { TFile, TFileData, TFolder } from "./types"
|
||||
|
||||
// Define the structure for sandbox files
|
||||
export type SandboxFiles = {
|
||||
files: (TFolder | TFile)[]
|
||||
fileData: TFileData[]
|
||||
}
|
||||
|
||||
// Convert list of paths to the hierchical file structure used by the editor
|
||||
function generateFileStructure(paths: string[]): (TFolder | TFile)[] {
|
||||
const root: TFolder = { id: "/", type: "folder", name: "/", children: [] }
|
||||
@ -52,20 +46,22 @@ function generateFileStructure(paths: string[]): (TFolder | TFile)[] {
|
||||
export class FileManager {
|
||||
private sandboxId: string
|
||||
private sandbox: Sandbox
|
||||
public sandboxFiles: SandboxFiles
|
||||
public files: (TFolder | TFile)[]
|
||||
public fileData: TFileData[]
|
||||
private fileWatchers: WatchHandle[] = []
|
||||
private dirName = "/home/user/project"
|
||||
private refreshFileList: (files: SandboxFiles) => void
|
||||
private refreshFileList: ((files: (TFolder | TFile)[]) => void) | null
|
||||
|
||||
// Constructor to initialize the FileManager
|
||||
constructor(
|
||||
sandboxId: string,
|
||||
sandbox: Sandbox,
|
||||
refreshFileList: (files: SandboxFiles) => void
|
||||
refreshFileList: ((files: (TFolder | TFile)[]) => void) | null
|
||||
) {
|
||||
this.sandboxId = sandboxId
|
||||
this.sandbox = sandbox
|
||||
this.sandboxFiles = { files: [], fileData: [] }
|
||||
this.files = []
|
||||
this.fileData = []
|
||||
this.refreshFileList = refreshFileList
|
||||
}
|
||||
|
||||
@ -110,16 +106,16 @@ export class FileManager {
|
||||
private async updateFileData(): Promise<TFileData[]> {
|
||||
const remotePaths = await RemoteFileStorage.getSandboxPaths(this.sandboxId)
|
||||
const localPaths = this.getLocalFileIds(remotePaths)
|
||||
this.sandboxFiles.fileData = await this.generateFileData(localPaths)
|
||||
return this.sandboxFiles.fileData
|
||||
this.fileData = await this.generateFileData(localPaths)
|
||||
return this.fileData
|
||||
}
|
||||
|
||||
// Update file structure
|
||||
private async updateFileStructure(): Promise<(TFolder | TFile)[]> {
|
||||
const remotePaths = await RemoteFileStorage.getSandboxPaths(this.sandboxId)
|
||||
const localPaths = this.getLocalFileIds(remotePaths)
|
||||
this.sandboxFiles.files = generateFileStructure(localPaths)
|
||||
return this.sandboxFiles.files
|
||||
this.files = generateFileStructure(localPaths)
|
||||
return this.files
|
||||
}
|
||||
|
||||
// Initialize the FileManager
|
||||
@ -130,9 +126,9 @@ export class FileManager {
|
||||
await this.updateFileData()
|
||||
|
||||
// Copy all files from the project to the container
|
||||
const promises = this.sandboxFiles.fileData.map(async (file) => {
|
||||
const promises = this.fileData.map(async (file) => {
|
||||
try {
|
||||
const filePath = path.join(this.dirName, file.id)
|
||||
const filePath = path.posix.join(this.dirName, file.id)
|
||||
const parentDirectory = path.dirname(filePath)
|
||||
if (!this.sandbox.files.exists(parentDirectory)) {
|
||||
await this.sandbox.files.makeDir(parentDirectory)
|
||||
@ -209,7 +205,7 @@ export class FileManager {
|
||||
// Handle file/directory creation event
|
||||
if (event.type === "create") {
|
||||
const folder = findFolderById(
|
||||
this.sandboxFiles.files,
|
||||
this.files,
|
||||
sandboxDirectory
|
||||
) as TFolder
|
||||
const isDir = await this.isDirectory(containerFilePath)
|
||||
@ -232,7 +228,7 @@ export class FileManager {
|
||||
folder.children.push(newItem)
|
||||
} else {
|
||||
// If folder doesn't exist, add the new item to the root
|
||||
this.sandboxFiles.files.push(newItem)
|
||||
this.files.push(newItem)
|
||||
}
|
||||
|
||||
if (!isDir) {
|
||||
@ -241,7 +237,7 @@ export class FileManager {
|
||||
)
|
||||
const fileContents =
|
||||
typeof fileData === "string" ? fileData : ""
|
||||
this.sandboxFiles.fileData.push({
|
||||
this.fileData.push({
|
||||
id: sandboxFilePath,
|
||||
data: fileContents,
|
||||
})
|
||||
@ -253,7 +249,7 @@ export class FileManager {
|
||||
// Handle file/directory removal or rename event
|
||||
else if (event.type === "remove" || event.type == "rename") {
|
||||
const folder = findFolderById(
|
||||
this.sandboxFiles.files,
|
||||
this.files,
|
||||
sandboxDirectory
|
||||
) as TFolder
|
||||
const isDir = await this.isDirectory(containerFilePath)
|
||||
@ -269,13 +265,13 @@ export class FileManager {
|
||||
)
|
||||
} else {
|
||||
// Remove from the root if it's not inside a folder
|
||||
this.sandboxFiles.files = this.sandboxFiles.files.filter(
|
||||
this.files = this.files.filter(
|
||||
(file: TFolder | TFile) => !isFileMatch(file)
|
||||
)
|
||||
}
|
||||
|
||||
// Also remove any corresponding file data
|
||||
this.sandboxFiles.fileData = this.sandboxFiles.fileData.filter(
|
||||
this.fileData = this.fileData.filter(
|
||||
(file: TFileData) => !isFileMatch(file)
|
||||
)
|
||||
|
||||
@ -285,10 +281,10 @@ export class FileManager {
|
||||
// Handle file write event
|
||||
else if (event.type === "write") {
|
||||
const folder = findFolderById(
|
||||
this.sandboxFiles.files,
|
||||
this.files,
|
||||
sandboxDirectory
|
||||
) as TFolder
|
||||
const fileToWrite = this.sandboxFiles.fileData.find(
|
||||
const fileToWrite = this.fileData.find(
|
||||
(file) => file.id === sandboxFilePath
|
||||
)
|
||||
|
||||
@ -308,7 +304,7 @@ export class FileManager {
|
||||
)
|
||||
const fileContents =
|
||||
typeof fileData === "string" ? fileData : ""
|
||||
this.sandboxFiles.fileData.push({
|
||||
this.fileData.push({
|
||||
id: sandboxFilePath,
|
||||
data: fileContents,
|
||||
})
|
||||
@ -318,7 +314,9 @@ export class FileManager {
|
||||
}
|
||||
|
||||
// Tell the client to reload the file list
|
||||
this.refreshFileList(this.sandboxFiles)
|
||||
if (event.type !== "chmod") {
|
||||
this.refreshFileList?.(this.files)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error handling ${event.type} event for ${event.name}:`,
|
||||
@ -350,7 +348,7 @@ export class FileManager {
|
||||
|
||||
// Get file content
|
||||
async getFile(fileId: string): Promise<string | undefined> {
|
||||
const file = this.sandboxFiles.fileData.find((f) => f.id === fileId)
|
||||
const file = this.fileData.find((f) => f.id === fileId)
|
||||
return file?.data
|
||||
}
|
||||
|
||||
@ -368,7 +366,7 @@ export class FileManager {
|
||||
throw new Error("File size too large. Please reduce the file size.")
|
||||
}
|
||||
await RemoteFileStorage.saveFile(this.getRemoteFileId(fileId), body)
|
||||
const file = this.sandboxFiles.fileData.find((f) => f.id === fileId)
|
||||
const file = this.fileData.find((f) => f.id === fileId)
|
||||
if (!file) return
|
||||
file.data = body
|
||||
|
||||
@ -381,9 +379,9 @@ export class FileManager {
|
||||
fileId: string,
|
||||
folderId: string
|
||||
): Promise<(TFolder | TFile)[]> {
|
||||
const fileData = this.sandboxFiles.fileData.find((f) => f.id === fileId)
|
||||
const file = this.sandboxFiles.files.find((f) => f.id === fileId)
|
||||
if (!fileData || !file) return this.sandboxFiles.files
|
||||
const fileData = this.fileData.find((f) => f.id === fileId)
|
||||
const file = this.files.find((f) => f.id === fileId)
|
||||
if (!fileData || !file) return this.files
|
||||
|
||||
const parts = fileId.split("/")
|
||||
const newFileId = folderId + "/" + parts.pop()
|
||||
@ -427,13 +425,13 @@ export class FileManager {
|
||||
await this.sandbox.files.write(path.posix.join(this.dirName, id), "")
|
||||
await this.fixPermissions()
|
||||
|
||||
this.sandboxFiles.files.push({
|
||||
this.files.push({
|
||||
id,
|
||||
name,
|
||||
type: "file",
|
||||
})
|
||||
|
||||
this.sandboxFiles.fileData.push({
|
||||
this.fileData.push({
|
||||
id,
|
||||
data: "",
|
||||
})
|
||||
@ -451,8 +449,8 @@ export class FileManager {
|
||||
|
||||
// Rename a file
|
||||
async renameFile(fileId: string, newName: string): Promise<void> {
|
||||
const fileData = this.sandboxFiles.fileData.find((f) => f.id === fileId)
|
||||
const file = this.sandboxFiles.files.find((f) => f.id === fileId)
|
||||
const fileData = this.fileData.find((f) => f.id === fileId)
|
||||
const file = this.files.find((f) => f.id === fileId)
|
||||
if (!fileData || !file) return
|
||||
|
||||
const parts = fileId.split("/")
|
||||
@ -468,11 +466,11 @@ export class FileManager {
|
||||
|
||||
// Delete a file
|
||||
async deleteFile(fileId: string): Promise<(TFolder | TFile)[]> {
|
||||
const file = this.sandboxFiles.fileData.find((f) => f.id === fileId)
|
||||
if (!file) return this.sandboxFiles.files
|
||||
const file = this.fileData.find((f) => f.id === fileId)
|
||||
if (!file) return this.files
|
||||
|
||||
await this.sandbox.files.remove(path.posix.join(this.dirName, fileId))
|
||||
this.sandboxFiles.fileData = this.sandboxFiles.fileData.filter(
|
||||
this.fileData = this.fileData.filter(
|
||||
(f) => f.id !== fileId
|
||||
)
|
||||
|
||||
@ -487,7 +485,7 @@ export class FileManager {
|
||||
await Promise.all(
|
||||
files.map(async (file) => {
|
||||
await this.sandbox.files.remove(path.posix.join(this.dirName, file))
|
||||
this.sandboxFiles.fileData = this.sandboxFiles.fileData.filter(
|
||||
this.fileData = this.fileData.filter(
|
||||
(f) => f.id !== file
|
||||
)
|
||||
await RemoteFileStorage.deleteFile(this.getRemoteFileId(file))
|
||||
|
243
backend/server/src/Sandbox.ts
Normal file
243
backend/server/src/Sandbox.ts
Normal file
@ -0,0 +1,243 @@
|
||||
import { Sandbox as E2BSandbox } from "e2b"
|
||||
import { Socket } from "socket.io"
|
||||
import { AIWorker } from "./AIWorker"
|
||||
import { CONTAINER_TIMEOUT } from "./constants"
|
||||
import { DokkuClient } from "./DokkuClient"
|
||||
import { FileManager } from "./FileManager"
|
||||
import {
|
||||
createFileRL,
|
||||
createFolderRL,
|
||||
deleteFileRL,
|
||||
renameFileRL,
|
||||
saveFileRL,
|
||||
} from "./ratelimit"
|
||||
import { SecureGitClient } from "./SecureGitClient"
|
||||
import { TerminalManager } from "./TerminalManager"
|
||||
import { TFile, TFolder } from "./types"
|
||||
import { LockManager } from "./utils"
|
||||
|
||||
const lockManager = new LockManager()
|
||||
|
||||
// Define a type for SocketHandler functions
|
||||
type SocketHandler<T = Record<string, any>> = (args: T) => any;
|
||||
|
||||
// Extract port number from a string
|
||||
function extractPortNumber(inputString: string): number | null {
|
||||
const cleanedString = inputString.replace(/\x1B\[[0-9;]*m/g, "")
|
||||
const regex = /http:\/\/localhost:(\d+)/
|
||||
const match = cleanedString.match(regex)
|
||||
return match ? parseInt(match[1]) : null
|
||||
}
|
||||
|
||||
type ServerContext = {
|
||||
aiWorker: AIWorker;
|
||||
dokkuClient: DokkuClient | null;
|
||||
gitClient: SecureGitClient | null;
|
||||
};
|
||||
|
||||
export class Sandbox {
|
||||
// Sandbox properties:
|
||||
sandboxId: string;
|
||||
fileManager: FileManager | null;
|
||||
terminalManager: TerminalManager | null;
|
||||
container: E2BSandbox | null;
|
||||
// Server context:
|
||||
dokkuClient: DokkuClient | null;
|
||||
gitClient: SecureGitClient | null;
|
||||
aiWorker: AIWorker;
|
||||
|
||||
constructor(sandboxId: string, { aiWorker, dokkuClient, gitClient }: ServerContext) {
|
||||
// Sandbox properties:
|
||||
this.sandboxId = sandboxId;
|
||||
this.fileManager = null;
|
||||
this.terminalManager = null;
|
||||
this.container = null;
|
||||
// Server context:
|
||||
this.aiWorker = aiWorker;
|
||||
this.dokkuClient = dokkuClient;
|
||||
this.gitClient = gitClient;
|
||||
}
|
||||
|
||||
// Initializes the container for the sandbox environment
|
||||
async initialize(
|
||||
fileWatchCallback: ((files: (TFolder | TFile)[]) => void) | undefined
|
||||
) {
|
||||
// Acquire a lock to ensure exclusive access to the sandbox environment
|
||||
await lockManager.acquireLock(this.sandboxId, async () => {
|
||||
// Check if a container already exists and is running
|
||||
if (this.container && await this.container.isRunning()) {
|
||||
console.log(`Found existing container ${this.sandboxId}`)
|
||||
} else {
|
||||
console.log("Creating container", this.sandboxId)
|
||||
// Create a new container with a specified timeout
|
||||
this.container = await E2BSandbox.create({
|
||||
timeoutMs: CONTAINER_TIMEOUT,
|
||||
})
|
||||
}
|
||||
})
|
||||
// Ensure a container was successfully created
|
||||
if (!this.container) throw new Error("Failed to create container")
|
||||
|
||||
// Initialize the terminal manager if it hasn't been set up yet
|
||||
if (!this.terminalManager) {
|
||||
this.terminalManager = new TerminalManager(this.container)
|
||||
console.log(`Terminal manager set up for ${this.sandboxId}`)
|
||||
}
|
||||
|
||||
// Initialize the file manager if it hasn't been set up yet
|
||||
if (!this.fileManager) {
|
||||
this.fileManager = new FileManager(
|
||||
this.sandboxId,
|
||||
this.container,
|
||||
fileWatchCallback ?? null
|
||||
)
|
||||
// Initialize the file manager and emit the initial files
|
||||
await this.fileManager.initialize()
|
||||
}
|
||||
}
|
||||
|
||||
// Called when the client disconnects from the Sandbox
|
||||
async disconnect() {
|
||||
// Close all terminals managed by the terminal manager
|
||||
await this.terminalManager?.closeAllTerminals()
|
||||
// This way the terminal manager will be set up again if we reconnect
|
||||
this.terminalManager = null;
|
||||
// Close all file watchers managed by the file manager
|
||||
await this.fileManager?.closeWatchers()
|
||||
// This way the file manager will be set up again if we reconnect
|
||||
this.fileManager = null;
|
||||
}
|
||||
|
||||
handlers(connection: { userId: string, isOwner: boolean, socket: Socket }) {
|
||||
|
||||
// Handle heartbeat from a socket connection
|
||||
const handleHeartbeat: SocketHandler = (_: any) => {
|
||||
// Only keep the sandbox alive if the owner is still connected
|
||||
if (connection.isOwner) {
|
||||
this.container?.setTimeout(CONTAINER_TIMEOUT)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle getting a file
|
||||
const handleGetFile: SocketHandler = ({ fileId }: any) => {
|
||||
return this.fileManager?.getFile(fileId)
|
||||
}
|
||||
|
||||
// Handle getting a folder
|
||||
const handleGetFolder: SocketHandler = ({ folderId }: any) => {
|
||||
return this.fileManager?.getFolder(folderId)
|
||||
}
|
||||
|
||||
// Handle saving a file
|
||||
const handleSaveFile: SocketHandler = async ({ fileId, body }: any) => {
|
||||
await saveFileRL.consume(connection.userId, 1);
|
||||
return this.fileManager?.saveFile(fileId, body)
|
||||
}
|
||||
|
||||
// Handle moving a file
|
||||
const handleMoveFile: SocketHandler = ({ fileId, folderId }: any) => {
|
||||
return this.fileManager?.moveFile(fileId, folderId)
|
||||
}
|
||||
|
||||
// Handle listing apps
|
||||
const handleListApps: SocketHandler = async (_: any) => {
|
||||
if (!this.dokkuClient) throw Error("Failed to retrieve apps list: No Dokku client")
|
||||
return { success: true, apps: await this.dokkuClient.listApps() }
|
||||
}
|
||||
|
||||
// Handle deploying code
|
||||
const handleDeploy: SocketHandler = async (_: any) => {
|
||||
if (!this.gitClient) throw Error("No git client")
|
||||
if (!this.fileManager) throw Error("No file manager")
|
||||
await this.gitClient.pushFiles(this.fileManager?.fileData, this.sandboxId)
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
// Handle creating a file
|
||||
const handleCreateFile: SocketHandler = async ({ name }: any) => {
|
||||
await createFileRL.consume(connection.userId, 1);
|
||||
return { "success": await this.fileManager?.createFile(name) }
|
||||
}
|
||||
|
||||
// Handle creating a folder
|
||||
const handleCreateFolder: SocketHandler = async ({ name }: any) => {
|
||||
await createFolderRL.consume(connection.userId, 1);
|
||||
return { "success": await this.fileManager?.createFolder(name) }
|
||||
}
|
||||
|
||||
// Handle renaming a file
|
||||
const handleRenameFile: SocketHandler = async ({ fileId, newName }: any) => {
|
||||
await renameFileRL.consume(connection.userId, 1)
|
||||
return this.fileManager?.renameFile(fileId, newName)
|
||||
}
|
||||
|
||||
// Handle deleting a file
|
||||
const handleDeleteFile: SocketHandler = async ({ fileId }: any) => {
|
||||
await deleteFileRL.consume(connection.userId, 1)
|
||||
return this.fileManager?.deleteFile(fileId)
|
||||
}
|
||||
|
||||
// Handle deleting a folder
|
||||
const handleDeleteFolder: SocketHandler = ({ folderId }: any) => {
|
||||
return this.fileManager?.deleteFolder(folderId)
|
||||
}
|
||||
|
||||
// Handle creating a terminal session
|
||||
const handleCreateTerminal: SocketHandler = async ({ id }: any) => {
|
||||
await lockManager.acquireLock(this.sandboxId, async () => {
|
||||
await this.terminalManager?.createTerminal(id, (responseString: string) => {
|
||||
connection.socket.emit("terminalResponse", { id, data: responseString })
|
||||
const port = extractPortNumber(responseString)
|
||||
if (port) {
|
||||
connection.socket.emit(
|
||||
"previewURL",
|
||||
"https://" + this.container?.getHost(port)
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Handle resizing a terminal
|
||||
const handleResizeTerminal: SocketHandler = ({ dimensions }: any) => {
|
||||
this.terminalManager?.resizeTerminal(dimensions)
|
||||
}
|
||||
|
||||
// Handle sending data to a terminal
|
||||
const handleTerminalData: SocketHandler = ({ id, data }: any) => {
|
||||
return this.terminalManager?.sendTerminalData(id, data)
|
||||
}
|
||||
|
||||
// Handle closing a terminal
|
||||
const handleCloseTerminal: SocketHandler = ({ id }: any) => {
|
||||
return this.terminalManager?.closeTerminal(id)
|
||||
}
|
||||
|
||||
// Handle generating code
|
||||
const handleGenerateCode: SocketHandler = ({ fileName, code, line, instructions }: any) => {
|
||||
return this.aiWorker.generateCode(connection.userId, fileName, code, line, instructions)
|
||||
}
|
||||
|
||||
return {
|
||||
"heartbeat": handleHeartbeat,
|
||||
"getFile": handleGetFile,
|
||||
"getFolder": handleGetFolder,
|
||||
"saveFile": handleSaveFile,
|
||||
"moveFile": handleMoveFile,
|
||||
"list": handleListApps,
|
||||
"deploy": handleDeploy,
|
||||
"createFile": handleCreateFile,
|
||||
"createFolder": handleCreateFolder,
|
||||
"renameFile": handleRenameFile,
|
||||
"deleteFile": handleDeleteFile,
|
||||
"deleteFolder": handleDeleteFolder,
|
||||
"createTerminal": handleCreateTerminal,
|
||||
"resizeTerminal": handleResizeTerminal,
|
||||
"terminalData": handleTerminalData,
|
||||
"closeTerminal": handleCloseTerminal,
|
||||
"generateCode": handleGenerateCode,
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
}
|
2
backend/server/src/constants.ts
Normal file
2
backend/server/src/constants.ts
Normal file
@ -0,0 +1,2 @@
|
||||
// The amount of time in ms that a container will stay alive without a hearbeat.
|
||||
export const CONTAINER_TIMEOUT = 120_000
|
@ -1,42 +1,39 @@
|
||||
import cors from "cors"
|
||||
import dotenv from "dotenv"
|
||||
import { Sandbox } from "e2b"
|
||||
import express, { Express } from "express"
|
||||
import fs from "fs"
|
||||
import { createServer } from "http"
|
||||
import { Server } from "socket.io"
|
||||
import { z } from "zod"
|
||||
import { Server, Socket } from "socket.io"
|
||||
import { AIWorker } from "./AIWorker"
|
||||
|
||||
import { ConnectionManager } from "./ConnectionManager"
|
||||
import { DokkuClient } from "./DokkuClient"
|
||||
import { FileManager, SandboxFiles } from "./FileManager"
|
||||
import {
|
||||
createFileRL,
|
||||
createFolderRL,
|
||||
deleteFileRL,
|
||||
renameFileRL,
|
||||
saveFileRL,
|
||||
} from "./ratelimit"
|
||||
import { Sandbox } from "./Sandbox"
|
||||
import { SecureGitClient } from "./SecureGitClient"
|
||||
import { TerminalManager } from "./TerminalManager"
|
||||
import { User } from "./types"
|
||||
import { LockManager } from "./utils"
|
||||
import { socketAuth } from "./socketAuth"; // Import the new socketAuth middleware
|
||||
import { TFile, TFolder } from "./types"
|
||||
|
||||
// Log errors and send a notification to the client
|
||||
export const handleErrors = (message: string, error: any, socket: Socket) => {
|
||||
console.error(message, error);
|
||||
socket.emit("error", `${message} ${error.message ?? error}`);
|
||||
};
|
||||
|
||||
// Handle uncaught exceptions
|
||||
process.on("uncaughtException", (error) => {
|
||||
console.error("Uncaught Exception:", error)
|
||||
// Do not exit the process
|
||||
// You can add additional logging or recovery logic here
|
||||
})
|
||||
|
||||
// Handle unhandled promise rejections
|
||||
process.on("unhandledRejection", (reason, promise) => {
|
||||
console.error("Unhandled Rejection at:", promise, "reason:", reason)
|
||||
// Do not exit the process
|
||||
// You can also handle the rejected promise here if needed
|
||||
})
|
||||
|
||||
// The amount of time in ms that a container will stay alive without a hearbeat.
|
||||
const CONTAINER_TIMEOUT = 120_000
|
||||
// Initialize containers and managers
|
||||
const connections = new ConnectionManager()
|
||||
const sandboxes: Record<string, Sandbox> = {}
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config()
|
||||
@ -48,118 +45,39 @@ app.use(cors())
|
||||
const httpServer = createServer(app)
|
||||
const io = new Server(httpServer, {
|
||||
cors: {
|
||||
origin: "*",
|
||||
origin: "*", // Allow connections from any origin
|
||||
},
|
||||
})
|
||||
|
||||
// Check if the sandbox owner is connected
|
||||
function isOwnerConnected(sandboxId: string): boolean {
|
||||
return (connections[sandboxId] ?? 0) > 0
|
||||
}
|
||||
|
||||
// Extract port number from a string
|
||||
function extractPortNumber(inputString: string): number | null {
|
||||
const cleanedString = inputString.replace(/\x1B\[[0-9;]*m/g, "")
|
||||
const regex = /http:\/\/localhost:(\d+)/
|
||||
const match = cleanedString.match(regex)
|
||||
return match ? parseInt(match[1]) : null
|
||||
}
|
||||
|
||||
// Initialize containers and managers
|
||||
const containers: Record<string, Sandbox> = {}
|
||||
const connections: Record<string, number> = {}
|
||||
const fileManagers: Record<string, FileManager> = {}
|
||||
const terminalManagers: Record<string, TerminalManager> = {}
|
||||
|
||||
// Middleware for socket authentication
|
||||
io.use(async (socket, next) => {
|
||||
// Define the schema for handshake query validation
|
||||
const handshakeSchema = z.object({
|
||||
userId: z.string(),
|
||||
sandboxId: z.string(),
|
||||
EIO: z.string(),
|
||||
transport: z.string(),
|
||||
})
|
||||
|
||||
const q = socket.handshake.query
|
||||
const parseQuery = handshakeSchema.safeParse(q)
|
||||
|
||||
// Check if the query is valid according to the schema
|
||||
if (!parseQuery.success) {
|
||||
next(new Error("Invalid request."))
|
||||
return
|
||||
}
|
||||
|
||||
const { sandboxId, userId } = parseQuery.data
|
||||
// Fetch user data from the database
|
||||
const dbUser = await fetch(
|
||||
`${process.env.DATABASE_WORKER_URL}/api/user?id=${userId}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `${process.env.WORKERS_KEY}`,
|
||||
},
|
||||
}
|
||||
)
|
||||
const dbUserJSON = (await dbUser.json()) as User
|
||||
|
||||
// Check if user data was retrieved successfully
|
||||
if (!dbUserJSON) {
|
||||
next(new Error("DB error."))
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the user owns the sandbox or has shared access
|
||||
const sandbox = dbUserJSON.sandbox.find((s) => s.id === sandboxId)
|
||||
const sharedSandboxes = dbUserJSON.usersToSandboxes.find(
|
||||
(uts) => uts.sandboxId === sandboxId
|
||||
)
|
||||
|
||||
// If user doesn't own or have shared access to the sandbox, deny access
|
||||
if (!sandbox && !sharedSandboxes) {
|
||||
next(new Error("Invalid credentials."))
|
||||
return
|
||||
}
|
||||
|
||||
// Set socket data with user information
|
||||
socket.data = {
|
||||
userId,
|
||||
sandboxId: sandboxId,
|
||||
isOwner: sandbox !== undefined,
|
||||
}
|
||||
|
||||
// Allow the connection
|
||||
next()
|
||||
})
|
||||
|
||||
// Initialize lock manager
|
||||
const lockManager = new LockManager()
|
||||
io.use(socketAuth) // Use the new socketAuth middleware
|
||||
|
||||
// Check for required environment variables
|
||||
if (!process.env.DOKKU_HOST)
|
||||
console.error("Environment variable DOKKU_HOST is not defined")
|
||||
console.warn("Environment variable DOKKU_HOST is not defined")
|
||||
if (!process.env.DOKKU_USERNAME)
|
||||
console.error("Environment variable DOKKU_USERNAME is not defined")
|
||||
console.warn("Environment variable DOKKU_USERNAME is not defined")
|
||||
if (!process.env.DOKKU_KEY)
|
||||
console.error("Environment variable DOKKU_KEY is not defined")
|
||||
console.warn("Environment variable DOKKU_KEY is not defined")
|
||||
|
||||
// Initialize Dokku client
|
||||
const client =
|
||||
const dokkuClient =
|
||||
process.env.DOKKU_HOST && process.env.DOKKU_KEY && process.env.DOKKU_USERNAME
|
||||
? new DokkuClient({
|
||||
host: process.env.DOKKU_HOST,
|
||||
username: process.env.DOKKU_USERNAME,
|
||||
privateKey: fs.readFileSync(process.env.DOKKU_KEY),
|
||||
})
|
||||
host: process.env.DOKKU_HOST,
|
||||
username: process.env.DOKKU_USERNAME,
|
||||
privateKey: fs.readFileSync(process.env.DOKKU_KEY),
|
||||
})
|
||||
: null
|
||||
client?.connect()
|
||||
dokkuClient?.connect()
|
||||
|
||||
// Initialize Git client used to deploy Dokku apps
|
||||
const git =
|
||||
const gitClient =
|
||||
process.env.DOKKU_HOST && process.env.DOKKU_KEY
|
||||
? new SecureGitClient(
|
||||
`dokku@${process.env.DOKKU_HOST}`,
|
||||
process.env.DOKKU_KEY
|
||||
)
|
||||
`dokku@${process.env.DOKKU_HOST}`,
|
||||
process.env.DOKKU_KEY
|
||||
)
|
||||
: null
|
||||
|
||||
// Add this near the top of the file, after other initializations
|
||||
@ -170,364 +88,95 @@ const aiWorker = new AIWorker(
|
||||
process.env.WORKERS_KEY!
|
||||
)
|
||||
|
||||
// Handle socket connections
|
||||
// Handle a client connecting to the server
|
||||
io.on("connection", async (socket) => {
|
||||
try {
|
||||
// This data comes is added by our authentication middleware
|
||||
const data = socket.data as {
|
||||
userId: string
|
||||
sandboxId: string
|
||||
isOwner: boolean
|
||||
}
|
||||
|
||||
// Handle connection based on user type (owner or not)
|
||||
if (data.isOwner) {
|
||||
connections[data.sandboxId] = (connections[data.sandboxId] ?? 0) + 1
|
||||
} else {
|
||||
if (!isOwnerConnected(data.sandboxId)) {
|
||||
socket.emit("disableAccess", "The sandbox owner is not connected.")
|
||||
return
|
||||
}
|
||||
// Register the connection
|
||||
connections.addConnectionForSandbox(socket, data.sandboxId, data.isOwner)
|
||||
|
||||
// Disable access unless the sandbox owner is connected
|
||||
if (!data.isOwner && !connections.ownerIsConnected(data.sandboxId)) {
|
||||
socket.emit("disableAccess", "The sandbox owner is not connected.")
|
||||
return
|
||||
}
|
||||
|
||||
// Create or retrieve container
|
||||
const createdContainer = await lockManager.acquireLock(
|
||||
data.sandboxId,
|
||||
async () => {
|
||||
try {
|
||||
// Start a new container if the container doesn't exist or it timed out.
|
||||
if (
|
||||
!containers[data.sandboxId] ||
|
||||
!(await containers[data.sandboxId].isRunning())
|
||||
) {
|
||||
containers[data.sandboxId] = await Sandbox.create({
|
||||
timeoutMs: CONTAINER_TIMEOUT,
|
||||
})
|
||||
console.log("Created container ", data.sandboxId)
|
||||
return true
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error(`Error creating container ${data.sandboxId}:`, e)
|
||||
socket.emit("error", `Error: container creation. ${e.message ?? e}`)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Function to send loaded event
|
||||
const sendLoadedEvent = (files: SandboxFiles) => {
|
||||
socket.emit("loaded", files.files)
|
||||
}
|
||||
|
||||
// Initialize file and terminal managers if container was created
|
||||
if (createdContainer) {
|
||||
fileManagers[data.sandboxId] = new FileManager(
|
||||
try {
|
||||
// Create or retrieve the sandbox manager for the given sandbox ID
|
||||
const sandbox = sandboxes[data.sandboxId] ?? new Sandbox(
|
||||
data.sandboxId,
|
||||
containers[data.sandboxId],
|
||||
sendLoadedEvent
|
||||
{
|
||||
aiWorker, dokkuClient, gitClient,
|
||||
}
|
||||
)
|
||||
terminalManagers[data.sandboxId] = new TerminalManager(
|
||||
containers[data.sandboxId]
|
||||
)
|
||||
console.log(`terminal manager set up for ${data.sandboxId}`)
|
||||
await fileManagers[data.sandboxId].initialize()
|
||||
}
|
||||
sandboxes[data.sandboxId] = sandbox
|
||||
|
||||
const fileManager = fileManagers[data.sandboxId]
|
||||
const terminalManager = terminalManagers[data.sandboxId]
|
||||
// This callback recieves an update when the file list changes, and notifies all relevant connections.
|
||||
const sendFileNotifications = (files: (TFolder | TFile)[]) => {
|
||||
connections.connectionsForSandbox(data.sandboxId).forEach((socket: Socket) => {
|
||||
socket.emit("loaded", files);
|
||||
});
|
||||
};
|
||||
|
||||
// Load file list from the file manager into the editor
|
||||
sendLoadedEvent(fileManager.sandboxFiles)
|
||||
// Initialize the sandbox container
|
||||
// The file manager and terminal managers will be set up if they have been closed
|
||||
await sandbox.initialize(sendFileNotifications)
|
||||
socket.emit("loaded", sandbox.fileManager?.files)
|
||||
|
||||
// Handle various socket events (heartbeat, file operations, terminal operations, etc.)
|
||||
socket.on("heartbeat", async () => {
|
||||
try {
|
||||
// This keeps the container alive for another CONTAINER_TIMEOUT seconds.
|
||||
// The E2B docs are unclear, but the timeout is relative to the time of this method call.
|
||||
await containers[data.sandboxId].setTimeout(CONTAINER_TIMEOUT)
|
||||
} catch (e: any) {
|
||||
console.error("Error setting timeout:", e)
|
||||
socket.emit("error", `Error: set timeout. ${e.message ?? e}`)
|
||||
}
|
||||
})
|
||||
|
||||
// Handle request to get file content
|
||||
socket.on("getFile", async (fileId: string, callback) => {
|
||||
try {
|
||||
const fileContent = await fileManager.getFile(fileId)
|
||||
callback(fileContent)
|
||||
} catch (e: any) {
|
||||
console.error("Error getting file:", e)
|
||||
socket.emit("error", `Error: get file. ${e.message ?? e}`)
|
||||
}
|
||||
})
|
||||
|
||||
// Handle request to get folder contents
|
||||
socket.on("getFolder", async (folderId: string, callback) => {
|
||||
try {
|
||||
const files = await fileManager.getFolder(folderId)
|
||||
callback(files)
|
||||
} catch (e: any) {
|
||||
console.error("Error getting folder:", e)
|
||||
socket.emit("error", `Error: get folder. ${e.message ?? e}`)
|
||||
}
|
||||
})
|
||||
|
||||
// Handle request to save file
|
||||
socket.on("saveFile", async (fileId: string, body: string) => {
|
||||
try {
|
||||
await saveFileRL.consume(data.userId, 1)
|
||||
await fileManager.saveFile(fileId, body)
|
||||
} catch (e: any) {
|
||||
console.error("Error saving file:", e)
|
||||
socket.emit("error", `Error: file saving. ${e.message ?? e}`)
|
||||
}
|
||||
})
|
||||
|
||||
// Handle request to move file
|
||||
socket.on(
|
||||
"moveFile",
|
||||
async (fileId: string, folderId: string, callback) => {
|
||||
try {
|
||||
const newFiles = await fileManager.moveFile(fileId, folderId)
|
||||
callback(newFiles)
|
||||
} catch (e: any) {
|
||||
console.error("Error moving file:", e)
|
||||
socket.emit("error", `Error: file moving. ${e.message ?? e}`)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
interface CallbackResponse {
|
||||
success: boolean
|
||||
apps?: string[]
|
||||
message?: string
|
||||
}
|
||||
|
||||
// Handle request to list apps
|
||||
socket.on(
|
||||
"list",
|
||||
async (callback: (response: CallbackResponse) => void) => {
|
||||
console.log("Retrieving apps list...")
|
||||
try {
|
||||
if (!client)
|
||||
throw Error("Failed to retrieve apps list: No Dokku client")
|
||||
callback({
|
||||
success: true,
|
||||
apps: await client.listApps(),
|
||||
})
|
||||
} catch (error) {
|
||||
callback({
|
||||
success: false,
|
||||
message: "Failed to retrieve apps list",
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Handle request to deploy project
|
||||
socket.on(
|
||||
"deploy",
|
||||
async (callback: (response: CallbackResponse) => void) => {
|
||||
try {
|
||||
// Push the project files to the Dokku server
|
||||
console.log("Deploying project ${data.sandboxId}...")
|
||||
if (!git) throw Error("Failed to retrieve apps list: No git client")
|
||||
// Remove the /project/[id]/ component of each file path:
|
||||
const fixedFilePaths = fileManager.sandboxFiles.fileData.map(
|
||||
(file) => {
|
||||
return {
|
||||
...file,
|
||||
id: file.id.split("/").slice(2).join("/"),
|
||||
}
|
||||
}
|
||||
)
|
||||
// Push all files to Dokku.
|
||||
await git.pushFiles(fixedFilePaths, data.sandboxId)
|
||||
callback({
|
||||
success: true,
|
||||
})
|
||||
} catch (error) {
|
||||
callback({
|
||||
success: false,
|
||||
message: "Failed to deploy project: " + error,
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Handle request to create a new file
|
||||
socket.on("createFile", async (name: string, callback) => {
|
||||
try {
|
||||
await createFileRL.consume(data.userId, 1)
|
||||
const success = await fileManager.createFile(name)
|
||||
callback({ success })
|
||||
} catch (e: any) {
|
||||
console.error("Error creating file:", e)
|
||||
socket.emit("error", `Error: file creation. ${e.message ?? e}`)
|
||||
}
|
||||
})
|
||||
|
||||
// Handle request to create a new folder
|
||||
socket.on("createFolder", async (name: string, callback) => {
|
||||
try {
|
||||
await createFolderRL.consume(data.userId, 1)
|
||||
await fileManager.createFolder(name)
|
||||
callback()
|
||||
} catch (e: any) {
|
||||
console.error("Error creating folder:", e)
|
||||
socket.emit("error", `Error: folder creation. ${e.message ?? e}`)
|
||||
}
|
||||
})
|
||||
|
||||
// Handle request to rename a file
|
||||
socket.on("renameFile", async (fileId: string, newName: string) => {
|
||||
try {
|
||||
await renameFileRL.consume(data.userId, 1)
|
||||
await fileManager.renameFile(fileId, newName)
|
||||
} catch (e: any) {
|
||||
console.error("Error renaming file:", e)
|
||||
socket.emit("error", `Error: file renaming. ${e.message ?? e}`)
|
||||
}
|
||||
})
|
||||
|
||||
// Handle request to delete a file
|
||||
socket.on("deleteFile", async (fileId: string, callback) => {
|
||||
try {
|
||||
await deleteFileRL.consume(data.userId, 1)
|
||||
const newFiles = await fileManager.deleteFile(fileId)
|
||||
callback(newFiles)
|
||||
} catch (e: any) {
|
||||
console.error("Error deleting file:", e)
|
||||
socket.emit("error", `Error: file deletion. ${e.message ?? e}`)
|
||||
}
|
||||
})
|
||||
|
||||
// Handle request to delete a folder
|
||||
socket.on("deleteFolder", async (folderId: string, callback) => {
|
||||
try {
|
||||
const newFiles = await fileManager.deleteFolder(folderId)
|
||||
callback(newFiles)
|
||||
} catch (e: any) {
|
||||
console.error("Error deleting folder:", e)
|
||||
socket.emit("error", `Error: folder deletion. ${e.message ?? e}`)
|
||||
}
|
||||
})
|
||||
|
||||
// Handle request to create a new terminal
|
||||
socket.on("createTerminal", async (id: string, callback) => {
|
||||
try {
|
||||
await lockManager.acquireLock(data.sandboxId, async () => {
|
||||
let terminalManager = terminalManagers[data.sandboxId]
|
||||
if (!terminalManager) {
|
||||
terminalManager = terminalManagers[data.sandboxId] =
|
||||
new TerminalManager(containers[data.sandboxId])
|
||||
// Register event handlers for the sandbox
|
||||
// For each event handler, listen on the socket for that event
|
||||
// Pass connection-specific information to the handlers
|
||||
Object.entries(sandbox.handlers({
|
||||
userId: data.userId,
|
||||
isOwner: data.isOwner,
|
||||
socket
|
||||
})).forEach(([event, handler]) => {
|
||||
socket.on(event, async (options: any, callback?: (response: any) => void) => {
|
||||
try {
|
||||
const result = await handler(options)
|
||||
callback?.(result);
|
||||
} catch (e: any) {
|
||||
handleErrors(`Error processing event "${event}":`, e, socket);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await terminalManager.createTerminal(id, (responseString: string) => {
|
||||
socket.emit("terminalResponse", { id, data: responseString })
|
||||
const port = extractPortNumber(responseString)
|
||||
if (port) {
|
||||
socket.emit(
|
||||
"previewURL",
|
||||
"https://" + containers[data.sandboxId].getHost(port)
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
callback()
|
||||
} catch (e: any) {
|
||||
console.error(`Error creating terminal ${id}:`, e)
|
||||
socket.emit("error", `Error: terminal creation. ${e.message ?? e}`)
|
||||
}
|
||||
})
|
||||
|
||||
// Handle request to resize terminal
|
||||
socket.on(
|
||||
"resizeTerminal",
|
||||
(dimensions: { cols: number; rows: number }) => {
|
||||
// Handle disconnection event
|
||||
socket.on("disconnect", async () => {
|
||||
try {
|
||||
terminalManager.resizeTerminal(dimensions)
|
||||
// Deregister the connection
|
||||
connections.removeConnectionForSandbox(socket, data.sandboxId, data.isOwner)
|
||||
|
||||
// If the owner has disconnected from all sockets, close open terminals and file watchers.o
|
||||
// The sandbox itself will timeout after the heartbeat stops.
|
||||
if (data.isOwner && !connections.ownerIsConnected(data.sandboxId)) {
|
||||
await sandbox.disconnect()
|
||||
socket.broadcast.emit(
|
||||
"disableAccess",
|
||||
"The sandbox owner has disconnected."
|
||||
)
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error("Error resizing terminal:", e)
|
||||
socket.emit("error", `Error: terminal resizing. ${e.message ?? e}`)
|
||||
handleErrors("Error disconnecting:", e, socket);
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
// Handle terminal input data
|
||||
socket.on("terminalData", async (id: string, data: string) => {
|
||||
try {
|
||||
await terminalManager.sendTerminalData(id, data)
|
||||
} catch (e: any) {
|
||||
console.error("Error writing to terminal:", e)
|
||||
socket.emit("error", `Error: writing to terminal. ${e.message ?? e}`)
|
||||
}
|
||||
})
|
||||
} catch (e: any) {
|
||||
handleErrors(`Error initializing sandbox ${data.sandboxId}:`, e, socket);
|
||||
}
|
||||
|
||||
// Handle request to close terminal
|
||||
socket.on("closeTerminal", async (id: string, callback) => {
|
||||
try {
|
||||
await terminalManager.closeTerminal(id)
|
||||
callback()
|
||||
} catch (e: any) {
|
||||
console.error("Error closing terminal:", e)
|
||||
socket.emit("error", `Error: closing terminal. ${e.message ?? e}`)
|
||||
}
|
||||
})
|
||||
|
||||
// Handle request to generate code
|
||||
socket.on(
|
||||
"generateCode",
|
||||
async (
|
||||
fileName: string,
|
||||
code: string,
|
||||
line: number,
|
||||
instructions: string,
|
||||
callback
|
||||
) => {
|
||||
try {
|
||||
const result = await aiWorker.generateCode(
|
||||
data.userId,
|
||||
fileName,
|
||||
code,
|
||||
line,
|
||||
instructions
|
||||
)
|
||||
callback(result)
|
||||
} catch (e: any) {
|
||||
console.error("Error generating code:", e)
|
||||
socket.emit("error", `Error: code generation. ${e.message ?? e}`)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Handle socket disconnection
|
||||
socket.on("disconnect", async () => {
|
||||
try {
|
||||
if (data.isOwner) {
|
||||
connections[data.sandboxId]--
|
||||
}
|
||||
|
||||
await terminalManager.closeAllTerminals()
|
||||
await fileManager.closeWatchers()
|
||||
|
||||
if (data.isOwner && connections[data.sandboxId] <= 0) {
|
||||
socket.broadcast.emit(
|
||||
"disableAccess",
|
||||
"The sandbox owner has disconnected."
|
||||
)
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.log("Error disconnecting:", e)
|
||||
socket.emit("error", `Error: disconnecting. ${e.message ?? e}`)
|
||||
}
|
||||
})
|
||||
} catch (e: any) {
|
||||
console.error("Error connecting:", e)
|
||||
socket.emit("error", `Error: connection. ${e.message ?? e}`)
|
||||
handleErrors("Error connecting:", e, socket);
|
||||
}
|
||||
})
|
||||
|
||||
// Start the server
|
||||
httpServer.listen(port, () => {
|
||||
console.log(`Server running on port ${port}`)
|
||||
})
|
||||
})
|
63
backend/server/src/socketAuth.ts
Normal file
63
backend/server/src/socketAuth.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import { Socket } from "socket.io"
|
||||
import { z } from "zod"
|
||||
import { User } from "./types"
|
||||
|
||||
// Middleware for socket authentication
|
||||
export const socketAuth = async (socket: Socket, next: Function) => {
|
||||
// Define the schema for handshake query validation
|
||||
const handshakeSchema = z.object({
|
||||
userId: z.string(),
|
||||
sandboxId: z.string(),
|
||||
EIO: z.string(),
|
||||
transport: z.string(),
|
||||
})
|
||||
|
||||
const q = socket.handshake.query
|
||||
const parseQuery = handshakeSchema.safeParse(q)
|
||||
|
||||
// Check if the query is valid according to the schema
|
||||
if (!parseQuery.success) {
|
||||
next(new Error("Invalid request."))
|
||||
return
|
||||
}
|
||||
|
||||
const { sandboxId, userId } = parseQuery.data
|
||||
// Fetch user data from the database
|
||||
const dbUser = await fetch(
|
||||
`${process.env.DATABASE_WORKER_URL}/api/user?id=${userId}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `${process.env.WORKERS_KEY}`,
|
||||
},
|
||||
}
|
||||
)
|
||||
const dbUserJSON = (await dbUser.json()) as User
|
||||
|
||||
// Check if user data was retrieved successfully
|
||||
if (!dbUserJSON) {
|
||||
next(new Error("DB error."))
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the user owns the sandbox or has shared access
|
||||
const sandbox = dbUserJSON.sandbox.find((s) => s.id === sandboxId)
|
||||
const sharedSandboxes = dbUserJSON.usersToSandboxes.find(
|
||||
(uts) => uts.sandboxId === sandboxId
|
||||
)
|
||||
|
||||
// If user doesn't own or have shared access to the sandbox, deny access
|
||||
if (!sandbox && !sharedSandboxes) {
|
||||
next(new Error("Invalid credentials."))
|
||||
return
|
||||
}
|
||||
|
||||
// Set socket data with user information
|
||||
socket.data = {
|
||||
userId,
|
||||
sandboxId: sandboxId,
|
||||
isOwner: sandbox !== undefined,
|
||||
}
|
||||
|
||||
// Allow the connection
|
||||
next()
|
||||
}
|
@ -68,3 +68,8 @@ export type R2FileBody = R2FileData & {
|
||||
json: Promise<any>
|
||||
blob: Promise<Blob>
|
||||
}
|
||||
export interface DokkuResponse {
|
||||
success: boolean
|
||||
apps?: string[]
|
||||
message?: string
|
||||
}
|
||||
|
@ -20,4 +20,4 @@ export class LockManager {
|
||||
}
|
||||
return await this.locks[key]
|
||||
}
|
||||
}
|
||||
}
|
@ -95,7 +95,7 @@ export default function Dashboard({
|
||||
</Button> */}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<a target="_blank" href="https://github.com/ishaan1013/sandbox">
|
||||
<a target="_blank" href="https://github.com/jamesmurdza/sandbox">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="justify-start w-full font-normal text-muted-foreground"
|
||||
|
@ -1,13 +1,10 @@
|
||||
import { Send, StopCircle } from "lucide-react"
|
||||
import { Send, StopCircle, Image as ImageIcon, Paperclip } from "lucide-react"
|
||||
import { Button } from "../../ui/button"
|
||||
|
||||
interface ChatInputProps {
|
||||
input: string
|
||||
setInput: (input: string) => void
|
||||
isGenerating: boolean
|
||||
handleSend: () => void
|
||||
handleStopGeneration: () => void
|
||||
}
|
||||
import { useEffect } from "react"
|
||||
import { TFile, TFolder } from "@/lib/types"
|
||||
import { ALLOWED_FILE_TYPES } from "./types"
|
||||
import { looksLikeCode } from "./lib/chatUtils"
|
||||
import { ChatInputProps } from "./types"
|
||||
|
||||
export default function ChatInput({
|
||||
input,
|
||||
@ -15,37 +12,228 @@ export default function ChatInput({
|
||||
isGenerating,
|
||||
handleSend,
|
||||
handleStopGeneration,
|
||||
onImageUpload,
|
||||
addContextTab,
|
||||
activeFileName,
|
||||
editorRef,
|
||||
lastCopiedRangeRef,
|
||||
contextTabs,
|
||||
onRemoveTab,
|
||||
textareaRef,
|
||||
}: ChatInputProps) {
|
||||
|
||||
// Auto-resize textarea as content changes
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto'
|
||||
textareaRef.current.style.height = textareaRef.current.scrollHeight + 'px'
|
||||
}
|
||||
}, [input])
|
||||
|
||||
// Handle keyboard events for sending messages
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
if (e.ctrlKey) {
|
||||
e.preventDefault()
|
||||
handleSend(true) // Send with full context
|
||||
} else if (!e.shiftKey && !isGenerating) {
|
||||
e.preventDefault()
|
||||
handleSend(false)
|
||||
}
|
||||
} else if (e.key === "Backspace" && input === "" && contextTabs.length > 0) {
|
||||
e.preventDefault()
|
||||
// Remove the last context tab
|
||||
const lastTab = contextTabs[contextTabs.length - 1]
|
||||
onRemoveTab(lastTab.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle paste events for image and code
|
||||
const handlePaste = async (e: React.ClipboardEvent) => {
|
||||
// Handle image paste
|
||||
const items = Array.from(e.clipboardData.items);
|
||||
for (const item of items) {
|
||||
if (item.type.startsWith('image/')) {
|
||||
e.preventDefault();
|
||||
|
||||
const file = item.getAsFile();
|
||||
if (!file) continue;
|
||||
|
||||
try {
|
||||
// Convert image to base64 string for context tab title and timestamp
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const base64String = reader.result as string;
|
||||
addContextTab(
|
||||
"image",
|
||||
`Image ${new Date().toLocaleTimeString('en-US', {
|
||||
hour12: true,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).replace(/(\d{2}):(\d{2})/, '$1:$2')}`,
|
||||
base64String
|
||||
);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} catch (error) {
|
||||
console.error('Error processing pasted image:', error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Get text from clipboard
|
||||
const text = e.clipboardData.getData('text');
|
||||
|
||||
// If text doesn't contain newlines or doesn't look like code, let it paste normally
|
||||
if (!text || !text.includes('\n') || !looksLikeCode(text)) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
const editor = editorRef.current;
|
||||
const currentSelection = editor?.getSelection();
|
||||
const lines = text.split('\n');
|
||||
|
||||
// TODO: FIX THIS: even when i paste the outside code, it shows the active file name,it works when no tabs are open, just does not work when the tab is open
|
||||
|
||||
// If selection exists in editor, use file name and line numbers
|
||||
if (currentSelection && !currentSelection.isEmpty()) {
|
||||
addContextTab(
|
||||
"code",
|
||||
`${activeFileName} (${currentSelection.startLineNumber}-${currentSelection.endLineNumber})`,
|
||||
text,
|
||||
{ start: currentSelection.startLineNumber, end: currentSelection.endLineNumber }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// If we have stored line range from a copy operation in the editor
|
||||
if (lastCopiedRangeRef.current) {
|
||||
const range = lastCopiedRangeRef.current;
|
||||
addContextTab(
|
||||
"code",
|
||||
`${activeFileName} (${range.startLine}-${range.endLine})`,
|
||||
text,
|
||||
{ start: range.startLine, end: range.endLine }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// For code pasted from outside the editor
|
||||
addContextTab(
|
||||
"code",
|
||||
`Pasted Code (1-${lines.length})`,
|
||||
text,
|
||||
{ start: 1, end: lines.length }
|
||||
);
|
||||
};
|
||||
|
||||
// Handle image upload from local machine via input
|
||||
const handleImageUpload = () => {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = 'image/*'
|
||||
input.onchange = (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0]
|
||||
if (file) onImageUpload(file)
|
||||
}
|
||||
input.click()
|
||||
}
|
||||
|
||||
// Helper function to flatten the file tree
|
||||
const getAllFiles = (items: (TFile | TFolder)[]): TFile[] => {
|
||||
return items.reduce((acc: TFile[], item) => {
|
||||
if (item.type === "file") {
|
||||
acc.push(item)
|
||||
} else {
|
||||
acc.push(...getAllFiles(item.children))
|
||||
}
|
||||
return acc
|
||||
}, [])
|
||||
}
|
||||
|
||||
// Handle file upload from local machine via input
|
||||
const handleFileUpload = () => {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = '.txt,.md,.csv,.json,.js,.ts,.html,.css,.pdf'
|
||||
input.onchange = (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0]
|
||||
if (file) {
|
||||
if (!(file.type in ALLOWED_FILE_TYPES)) {
|
||||
alert('Unsupported file type. Please upload text, code, or PDF files.')
|
||||
return
|
||||
}
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
addContextTab("file", file.name, reader.result as string)
|
||||
}
|
||||
reader.readAsText(file)
|
||||
}
|
||||
}
|
||||
input.click()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex space-x-2 min-w-0">
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyPress={(e) => e.key === "Enter" && !isGenerating && handleSend()}
|
||||
className="flex-grow p-2 border rounded-lg min-w-0 bg-input"
|
||||
placeholder="Type your message..."
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
{isGenerating ? (
|
||||
<Button
|
||||
onClick={handleStopGeneration}
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
>
|
||||
<StopCircle className="w-4 h-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
<div className="space-y-2">
|
||||
<div className="flex space-x-2 min-w-0">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
className="flex-grow p-2 border rounded-lg min-w-0 bg-input resize-none overflow-hidden"
|
||||
placeholder="Type your message..."
|
||||
disabled={isGenerating}
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
rows={1}
|
||||
/>
|
||||
{/* Render stop generation button */}
|
||||
{isGenerating ? (
|
||||
<Button
|
||||
onClick={handleStopGeneration}
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
>
|
||||
<StopCircle className="w-4 h-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => handleSend(false)}
|
||||
disabled={isGenerating}
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{/* Render file upload button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 sm:px-3"
|
||||
onClick={handleFileUpload}
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
<Paperclip className="h-3 w-3 sm:mr-1" />
|
||||
<span className="hidden sm:inline">File</span>
|
||||
</Button>
|
||||
)}
|
||||
{/* Render image upload button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 sm:px-3"
|
||||
onClick={handleImageUpload}
|
||||
>
|
||||
<ImageIcon className="h-3 w-3 sm:mr-1" />
|
||||
<span className="hidden sm:inline">Image</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -1,32 +1,29 @@
|
||||
import { Check, ChevronDown, ChevronUp, Copy, CornerUpLeft } from "lucide-react"
|
||||
import { Check, Copy, CornerUpLeft } from "lucide-react"
|
||||
import React, { useState } from "react"
|
||||
import ReactMarkdown from "react-markdown"
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"
|
||||
import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism"
|
||||
import remarkGfm from "remark-gfm"
|
||||
import { Button } from "../../ui/button"
|
||||
import { copyToClipboard, stringifyContent } from "./lib/chatUtils"
|
||||
|
||||
interface MessageProps {
|
||||
message: {
|
||||
role: "user" | "assistant"
|
||||
content: string
|
||||
context?: string
|
||||
}
|
||||
setContext: (context: string | null) => void
|
||||
setIsContextExpanded: (isExpanded: boolean) => void
|
||||
}
|
||||
import ContextTabs from "./ContextTabs"
|
||||
import { createMarkdownComponents } from './lib/markdownComponents'
|
||||
import { MessageProps } from "./types"
|
||||
|
||||
export default function ChatMessage({
|
||||
message,
|
||||
setContext,
|
||||
setIsContextExpanded,
|
||||
socket,
|
||||
}: MessageProps) {
|
||||
|
||||
// State for expanded message index
|
||||
const [expandedMessageIndex, setExpandedMessageIndex] = useState<
|
||||
number | null
|
||||
>(null)
|
||||
|
||||
// State for copied text
|
||||
const [copiedText, setCopiedText] = useState<string | null>(null)
|
||||
|
||||
// Render copy button for text content
|
||||
const renderCopyButton = (text: any) => (
|
||||
<Button
|
||||
onClick={() => copyToClipboard(stringifyContent(text), setCopiedText)}
|
||||
@ -42,12 +39,36 @@ export default function ChatMessage({
|
||||
</Button>
|
||||
)
|
||||
|
||||
// Set context for code when asking about code
|
||||
const askAboutCode = (code: any) => {
|
||||
const contextString = stringifyContent(code)
|
||||
setContext(`Regarding this code:\n${contextString}`)
|
||||
const newContext = `Regarding this code:\n${contextString}`
|
||||
|
||||
// Format timestamp to match chat message format (HH:MM PM)
|
||||
const timestamp = new Date().toLocaleTimeString('en-US', {
|
||||
hour12: true,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
|
||||
// Instead of replacing context, append to it
|
||||
if (message.role === "assistant") {
|
||||
// For assistant messages, create a new context tab with the response content and timestamp
|
||||
setContext(newContext, `AI Response (${timestamp})`, {
|
||||
start: 1,
|
||||
end: contextString.split('\n').length
|
||||
})
|
||||
} else {
|
||||
// For user messages, create a new context tab with the selected content and timestamp
|
||||
setContext(newContext, `User Chat (${timestamp})`, {
|
||||
start: 1,
|
||||
end: contextString.split('\n').length
|
||||
})
|
||||
}
|
||||
setIsContextExpanded(false)
|
||||
}
|
||||
|
||||
// Render markdown elements for code and text
|
||||
const renderMarkdownElement = (props: any) => {
|
||||
const { node, children } = props
|
||||
const content = stringifyContent(children)
|
||||
@ -65,6 +86,7 @@ export default function ChatMessage({
|
||||
<CornerUpLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{/* Render markdown element */}
|
||||
{React.createElement(
|
||||
node.tagName,
|
||||
{
|
||||
@ -79,6 +101,13 @@ export default function ChatMessage({
|
||||
)
|
||||
}
|
||||
|
||||
// Create markdown components
|
||||
const components = createMarkdownComponents(
|
||||
renderCopyButton,
|
||||
renderMarkdownElement,
|
||||
askAboutCode
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="text-left relative">
|
||||
<div
|
||||
@ -88,34 +117,19 @@ export default function ChatMessage({
|
||||
: "bg-transparent text-white"
|
||||
} max-w-full`}
|
||||
>
|
||||
{message.role === "user" && (
|
||||
<div className="absolute top-0 right-0 flex opacity-0 group-hover:opacity-30 transition-opacity">
|
||||
{renderCopyButton(message.content)}
|
||||
<Button
|
||||
onClick={() => askAboutCode(message.content)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="p-1 h-6"
|
||||
>
|
||||
<CornerUpLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{message.context && (
|
||||
{/* Render context tabs */}
|
||||
{message.role === "user" && message.context && (
|
||||
<div className="mb-2 bg-input rounded-lg">
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer"
|
||||
onClick={() =>
|
||||
setExpandedMessageIndex(expandedMessageIndex === 0 ? null : 0)
|
||||
}
|
||||
>
|
||||
<span className="text-sm text-gray-300">Context</span>
|
||||
{expandedMessageIndex === 0 ? (
|
||||
<ChevronUp size={16} />
|
||||
) : (
|
||||
<ChevronDown size={16} />
|
||||
)}
|
||||
</div>
|
||||
<ContextTabs
|
||||
socket={socket}
|
||||
activeFileName=""
|
||||
onAddFile={() => {}}
|
||||
contextTabs={parseContextToTabs(message.context)}
|
||||
onRemoveTab={() => {}}
|
||||
isExpanded={expandedMessageIndex === 0}
|
||||
onToggleExpand={() => setExpandedMessageIndex(expandedMessageIndex === 0 ? null : 0)}
|
||||
className="[&_div:first-child>div:first-child>div]:bg-[#0D0D0D] [&_button:first-child]:hidden [&_button:last-child]:hidden"
|
||||
/>
|
||||
{expandedMessageIndex === 0 && (
|
||||
<div className="relative">
|
||||
<div className="absolute top-0 right-0 flex p-1">
|
||||
@ -123,6 +137,7 @@ export default function ChatMessage({
|
||||
message.context.replace(/^Regarding this code:\n/, "")
|
||||
)}
|
||||
</div>
|
||||
{/* Render code textarea */}
|
||||
{(() => {
|
||||
const code = message.context.replace(
|
||||
/^Regarding this code:\n/,
|
||||
@ -136,7 +151,10 @@ export default function ChatMessage({
|
||||
value={code}
|
||||
onChange={(e) => {
|
||||
const updatedContext = `Regarding this code:\n${e.target.value}`
|
||||
setContext(updatedContext)
|
||||
setContext(updatedContext, "Selected Content", {
|
||||
start: 1,
|
||||
end: e.target.value.split('\n').length
|
||||
})
|
||||
}}
|
||||
className="w-full p-2 bg-[#1e1e1e] text-white font-mono text-sm rounded"
|
||||
rows={code.split("\n").length}
|
||||
@ -153,67 +171,25 @@ export default function ChatMessage({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Render copy and ask about code buttons */}
|
||||
{message.role === "user" && (
|
||||
<div className="absolute top-0 right-0 p-1 flex opacity-40">
|
||||
{renderCopyButton(message.content)}
|
||||
<Button
|
||||
onClick={() => askAboutCode(message.content)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="p-1 h-6"
|
||||
>
|
||||
<CornerUpLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{/* Render markdown content */}
|
||||
{message.role === "assistant" ? (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
code({ node, className, children, ...props }) {
|
||||
const match = /language-(\w+)/.exec(className || "")
|
||||
return match ? (
|
||||
<div className="relative border border-input rounded-md my-4">
|
||||
<div className="absolute top-0 left-0 px-2 py-1 text-xs font-semibold text-gray-200 bg-#1e1e1e rounded-tl">
|
||||
{match[1]}
|
||||
</div>
|
||||
<div className="absolute top-0 right-0 flex">
|
||||
{renderCopyButton(children)}
|
||||
<Button
|
||||
onClick={() => askAboutCode(children)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="p-1 h-6"
|
||||
>
|
||||
<CornerUpLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="pt-6">
|
||||
<SyntaxHighlighter
|
||||
style={vscDarkPlus as any}
|
||||
language={match[1]}
|
||||
PreTag="div"
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
{stringifyContent(children)}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
},
|
||||
p: renderMarkdownElement,
|
||||
h1: renderMarkdownElement,
|
||||
h2: renderMarkdownElement,
|
||||
h3: renderMarkdownElement,
|
||||
h4: renderMarkdownElement,
|
||||
h5: renderMarkdownElement,
|
||||
h6: renderMarkdownElement,
|
||||
ul: (props) => (
|
||||
<ul className="list-disc pl-6 mb-4 space-y-2">
|
||||
{props.children}
|
||||
</ul>
|
||||
),
|
||||
ol: (props) => (
|
||||
<ol className="list-decimal pl-6 mb-4 space-y-2">
|
||||
{props.children}
|
||||
</ol>
|
||||
),
|
||||
}}
|
||||
components={components}
|
||||
>
|
||||
{message.content}
|
||||
</ReactMarkdown>
|
||||
@ -224,3 +200,27 @@ export default function ChatMessage({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Parse context to tabs for context tabs component
|
||||
function parseContextToTabs(context: string) {
|
||||
const sections = context.split(/(?=File |Code from )/)
|
||||
return sections.map((section, index) => {
|
||||
const lines = section.trim().split('\n')
|
||||
const titleLine = lines[0]
|
||||
let content = lines.slice(1).join('\n').trim()
|
||||
|
||||
// Remove code block markers for display
|
||||
content = content.replace(/^```[\w-]*\n/, '').replace(/\n```$/, '')
|
||||
|
||||
// Determine if the context is a file or code
|
||||
const isFile = titleLine.startsWith('File ')
|
||||
const name = titleLine.replace(/^(File |Code from )/, '').replace(':', '')
|
||||
|
||||
return {
|
||||
id: `context-${index}`,
|
||||
type: isFile ? "file" as const : "code" as const,
|
||||
name: name,
|
||||
content: content
|
||||
}
|
||||
}).filter(tab => tab.content.length > 0)
|
||||
}
|
||||
|
@ -1,60 +0,0 @@
|
||||
import { ChevronDown, ChevronUp, X } from "lucide-react"
|
||||
|
||||
interface ContextDisplayProps {
|
||||
context: string | null
|
||||
isContextExpanded: boolean
|
||||
setIsContextExpanded: (isExpanded: boolean) => void
|
||||
setContext: (context: string | null) => void
|
||||
}
|
||||
|
||||
export default function ContextDisplay({
|
||||
context,
|
||||
isContextExpanded,
|
||||
setIsContextExpanded,
|
||||
setContext,
|
||||
}: ContextDisplayProps) {
|
||||
if (!context) return null
|
||||
|
||||
return (
|
||||
<div className="mb-2 bg-input p-2 rounded-lg">
|
||||
<div className="flex justify-between items-center">
|
||||
<div
|
||||
className="flex-grow cursor-pointer"
|
||||
onClick={() => setIsContextExpanded(!isContextExpanded)}
|
||||
>
|
||||
<span className="text-sm text-gray-300">Context</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{isContextExpanded ? (
|
||||
<ChevronUp
|
||||
size={16}
|
||||
className="cursor-pointer"
|
||||
onClick={() => setIsContextExpanded(false)}
|
||||
/>
|
||||
) : (
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className="cursor-pointer"
|
||||
onClick={() => setIsContextExpanded(true)}
|
||||
/>
|
||||
)}
|
||||
<X
|
||||
size={16}
|
||||
className="ml-2 cursor-pointer text-gray-400 hover:text-gray-200"
|
||||
onClick={() => setContext(null)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{isContextExpanded && (
|
||||
<textarea
|
||||
value={context.replace(/^Regarding this code:\n/, "")}
|
||||
onChange={(e) =>
|
||||
setContext(`Regarding this code:\n${e.target.value}`)
|
||||
}
|
||||
className="w-full mt-2 p-2 bg-#1e1e1e text-white rounded"
|
||||
rows={5}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
172
frontend/components/editor/AIChat/ContextTabs.tsx
Normal file
172
frontend/components/editor/AIChat/ContextTabs.tsx
Normal file
@ -0,0 +1,172 @@
|
||||
import { Plus, X, Image as ImageIcon, FileText } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { Button } from "../../ui/button"
|
||||
import { TFile, TFolder } from "@/lib/types"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { ContextTab } from "./types"
|
||||
import { ContextTabsProps } from "./types"
|
||||
// Ignore certain folders and files from the file tree
|
||||
import { ignoredFiles, ignoredFolders } from "./lib/ignored-paths"
|
||||
|
||||
export default function ContextTabs({
|
||||
contextTabs,
|
||||
onRemoveTab,
|
||||
className,
|
||||
files = [],
|
||||
onFileSelect,
|
||||
}: ContextTabsProps & { className?: string }) {
|
||||
|
||||
// State for preview tab
|
||||
const [previewTab, setPreviewTab] = useState<ContextTab | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
|
||||
// Allow preview for images and code selections from editor
|
||||
const togglePreview = (tab: ContextTab) => {
|
||||
if (!tab.lineRange && tab.type !== "image") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Toggle preview for images and code selections from editor
|
||||
if (previewTab?.id === tab.id) {
|
||||
setPreviewTab(null)
|
||||
} else {
|
||||
setPreviewTab(tab)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove tab from context when clicking on X
|
||||
const handleRemoveTab = (id: string) => {
|
||||
if (previewTab?.id === id) {
|
||||
setPreviewTab(null)
|
||||
}
|
||||
onRemoveTab(id)
|
||||
}
|
||||
|
||||
// Get all files from the file tree to search for context
|
||||
const getAllFiles = (items: (TFile | TFolder)[]): TFile[] => {
|
||||
return items.reduce((acc: TFile[], item) => {
|
||||
// Add file if it's not ignored
|
||||
if (item.type === "file" && !ignoredFiles.some((pattern: string) =>
|
||||
item.name.endsWith(pattern.replace('*', '')) || item.name === pattern
|
||||
)) {
|
||||
acc.push(item)
|
||||
// Add all files from folder if it's not ignored
|
||||
} else if (item.type === "folder" && !ignoredFolders.some((folder: string) => folder === item.name)) {
|
||||
acc.push(...getAllFiles(item.children))
|
||||
}
|
||||
return acc
|
||||
}, [])
|
||||
}
|
||||
|
||||
// Get all files from the file tree to search for context when adding context
|
||||
const allFiles = getAllFiles(files)
|
||||
const filteredFiles = allFiles.filter(file =>
|
||||
file.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={`border-none ${className || ''}`}>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-1 overflow-hidden mb-2 flex-wrap">
|
||||
{/* Add context tab button */}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
{/* Add context tab popover */}
|
||||
<PopoverContent className="w-64 p-2">
|
||||
<div className="flex gap-2 mb-2">
|
||||
<Input
|
||||
placeholder="Search files..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-[200px] overflow-y-auto">
|
||||
{filteredFiles.map((file) => (
|
||||
<Button
|
||||
key={file.id}
|
||||
variant="ghost"
|
||||
className="w-full justify-start text-sm mb-1"
|
||||
onClick={() => onFileSelect?.(file)}
|
||||
>
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
{file.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{/* Add context tab button */}
|
||||
{contextTabs.length === 0 && (
|
||||
<div className="flex items-center gap-1 px-2 rounded">
|
||||
<span className="text-sm text-muted-foreground">Add Context</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Render context tabs */}
|
||||
{contextTabs.map((tab) => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className="flex items-center gap-1 px-2 bg-input rounded text-sm cursor-pointer hover:bg-muted"
|
||||
onClick={() => togglePreview(tab)}
|
||||
>
|
||||
{tab.type === "image" && <ImageIcon className="h-3 w-3" />}
|
||||
<span>{tab.name}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-4 w-4"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleRemoveTab(tab.id)
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Preview Section */}
|
||||
{previewTab && (
|
||||
<div className="p-2 bg-input rounded-md max-h-[200px] overflow-auto mb-2">
|
||||
{previewTab.type === "image" ? (
|
||||
<img
|
||||
src={previewTab.content}
|
||||
alt={previewTab.name}
|
||||
className="max-w-full h-auto"
|
||||
/>
|
||||
) : previewTab.lineRange && (
|
||||
<>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
Lines {previewTab.lineRange.start}-{previewTab.lineRange.end}
|
||||
</div>
|
||||
<pre className="text-xs font-mono whitespace-pre-wrap">
|
||||
{previewTab.content}
|
||||
</pre>
|
||||
</>
|
||||
)}
|
||||
{/* Render file context tab */}
|
||||
{previewTab.type === "file" && (
|
||||
<pre className="text-xs font-mono whitespace-pre-wrap">
|
||||
{previewTab.content}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -3,37 +3,47 @@ import { useEffect, useRef, useState } from "react"
|
||||
import LoadingDots from "../../ui/LoadingDots"
|
||||
import ChatInput from "./ChatInput"
|
||||
import ChatMessage from "./ChatMessage"
|
||||
import ContextDisplay from "./ContextDisplay"
|
||||
import ContextTabs from "./ContextTabs"
|
||||
import { handleSend, handleStopGeneration } from "./lib/chatUtils"
|
||||
|
||||
interface Message {
|
||||
role: "user" | "assistant"
|
||||
content: string
|
||||
context?: string
|
||||
}
|
||||
import { nanoid } from 'nanoid'
|
||||
import { TFile } from "@/lib/types"
|
||||
import { useSocket } from "@/context/SocketContext"
|
||||
import { Message, ContextTab, AIChatProps } from './types'
|
||||
|
||||
export default function AIChat({
|
||||
activeFileContent,
|
||||
activeFileName,
|
||||
onClose,
|
||||
}: {
|
||||
activeFileContent: string
|
||||
activeFileName: string
|
||||
onClose: () => void
|
||||
}) {
|
||||
editorRef,
|
||||
lastCopiedRangeRef,
|
||||
files,
|
||||
}: AIChatProps) {
|
||||
// Initialize socket and messages
|
||||
const { socket } = useSocket()
|
||||
const [messages, setMessages] = useState<Message[]>([])
|
||||
|
||||
// Initialize input and state for generating messages
|
||||
const [input, setInput] = useState("")
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
|
||||
// Initialize chat container ref and abort controller ref
|
||||
const chatContainerRef = useRef<HTMLDivElement>(null)
|
||||
const abortControllerRef = useRef<AbortController | null>(null)
|
||||
const [context, setContext] = useState<string | null>(null)
|
||||
|
||||
// Initialize context tabs and state for expanding context
|
||||
const [contextTabs, setContextTabs] = useState<ContextTab[]>([])
|
||||
const [isContextExpanded, setIsContextExpanded] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
// Initialize textarea ref
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
// Scroll to bottom of chat when messages change
|
||||
useEffect(() => {
|
||||
scrollToBottom()
|
||||
}, [messages])
|
||||
|
||||
// Scroll to bottom of chat when messages change
|
||||
const scrollToBottom = () => {
|
||||
if (chatContainerRef.current) {
|
||||
setTimeout(() => {
|
||||
@ -45,6 +55,84 @@ export default function AIChat({
|
||||
}
|
||||
}
|
||||
|
||||
// Add context tab to context tabs
|
||||
const addContextTab = (type: string, name: string, content: string, lineRange?: { start: number; end: number }) => {
|
||||
const newTab = {
|
||||
id: nanoid(),
|
||||
type: type as "file" | "code" | "image",
|
||||
name,
|
||||
content,
|
||||
lineRange
|
||||
}
|
||||
setContextTabs(prev => [...prev, newTab])
|
||||
}
|
||||
|
||||
// Remove context tab from context tabs
|
||||
const removeContextTab = (id: string) => {
|
||||
setContextTabs(prev => prev.filter(tab => tab.id !== id))
|
||||
}
|
||||
|
||||
// Add file to context tabs
|
||||
const handleAddFile = (tab: ContextTab) => {
|
||||
setContextTabs(prev => [...prev, tab])
|
||||
}
|
||||
|
||||
// Format code content to remove starting and ending code block markers if they exist
|
||||
const formatCodeContent = (content: string) => {
|
||||
return content.replace(/^```[\w-]*\n/, '').replace(/\n```$/, '')
|
||||
}
|
||||
|
||||
// Get combined context from context tabs
|
||||
const getCombinedContext = () => {
|
||||
if (contextTabs.length === 0) return ''
|
||||
|
||||
return contextTabs.map(tab => {
|
||||
if (tab.type === 'file') {
|
||||
const fileExt = tab.name.split('.').pop() || 'txt'
|
||||
const cleanContent = formatCodeContent(tab.content)
|
||||
return `File ${tab.name}:\n\`\`\`${fileExt}\n${cleanContent}\n\`\`\``
|
||||
} else if (tab.type === 'code') {
|
||||
const cleanContent = formatCodeContent(tab.content)
|
||||
return `Code from ${tab.name}:\n\`\`\`typescript\n${cleanContent}\n\`\`\``
|
||||
}
|
||||
return `${tab.name}:\n${tab.content}`
|
||||
}).join('\n\n')
|
||||
}
|
||||
|
||||
// Handle sending message with context
|
||||
const handleSendWithContext = () => {
|
||||
const combinedContext = getCombinedContext()
|
||||
handleSend(
|
||||
input,
|
||||
combinedContext,
|
||||
messages,
|
||||
setMessages,
|
||||
setInput,
|
||||
setIsContextExpanded,
|
||||
setIsGenerating,
|
||||
setIsLoading,
|
||||
abortControllerRef,
|
||||
activeFileContent
|
||||
)
|
||||
// Clear context tabs after sending
|
||||
setContextTabs([])
|
||||
}
|
||||
|
||||
// Set context for the chat
|
||||
const setContext = (
|
||||
context: string | null,
|
||||
name: string,
|
||||
range?: { start: number, end: number }
|
||||
) => {
|
||||
if (!context) {
|
||||
setContextTabs([])
|
||||
return
|
||||
}
|
||||
|
||||
// Always add a new tab instead of updating existing ones
|
||||
addContextTab('code', name, context, range)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen w-full">
|
||||
<div className="flex justify-between items-center p-2 border-b">
|
||||
@ -68,41 +156,65 @@ export default function AIChat({
|
||||
className="flex-grow overflow-y-auto p-4 space-y-4"
|
||||
>
|
||||
{messages.map((message, messageIndex) => (
|
||||
// Render chat message component for each message
|
||||
<ChatMessage
|
||||
key={messageIndex}
|
||||
message={message}
|
||||
setContext={setContext}
|
||||
setIsContextExpanded={setIsContextExpanded}
|
||||
socket={socket}
|
||||
/>
|
||||
))}
|
||||
{isLoading && <LoadingDots />}
|
||||
</div>
|
||||
<div className="p-4 border-t mb-14">
|
||||
<ContextDisplay
|
||||
context={context}
|
||||
isContextExpanded={isContextExpanded}
|
||||
setIsContextExpanded={setIsContextExpanded}
|
||||
setContext={setContext}
|
||||
{/* Render context tabs component */}
|
||||
<ContextTabs
|
||||
activeFileName={activeFileName}
|
||||
onAddFile={handleAddFile}
|
||||
contextTabs={contextTabs}
|
||||
onRemoveTab={removeContextTab}
|
||||
isExpanded={isContextExpanded}
|
||||
onToggleExpand={() => setIsContextExpanded(!isContextExpanded)}
|
||||
files={files}
|
||||
socket={socket}
|
||||
onFileSelect={(file: TFile) => {
|
||||
socket?.emit("getFile", { fileId: file.id }, (response: string) => {
|
||||
const fileExt = file.name.split('.').pop() || 'txt'
|
||||
const formattedContent = `\`\`\`${fileExt}\n${response}\n\`\`\``
|
||||
addContextTab('file', file.name, formattedContent)
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.focus()
|
||||
}
|
||||
})
|
||||
}}
|
||||
/>
|
||||
{/* Render chat input component */}
|
||||
<ChatInput
|
||||
textareaRef={textareaRef}
|
||||
addContextTab={addContextTab}
|
||||
editorRef={editorRef}
|
||||
input={input}
|
||||
setInput={setInput}
|
||||
isGenerating={isGenerating}
|
||||
handleSend={() =>
|
||||
handleSend(
|
||||
input,
|
||||
context,
|
||||
messages,
|
||||
setMessages,
|
||||
setInput,
|
||||
setIsContextExpanded,
|
||||
setIsGenerating,
|
||||
setIsLoading,
|
||||
abortControllerRef,
|
||||
activeFileContent
|
||||
)
|
||||
}
|
||||
handleSend={handleSendWithContext}
|
||||
handleStopGeneration={() => handleStopGeneration(abortControllerRef)}
|
||||
onImageUpload={(file) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
if (e.target?.result) {
|
||||
addContextTab("image", file.name, e.target.result as string)
|
||||
}
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}}
|
||||
lastCopiedRangeRef={lastCopiedRangeRef}
|
||||
activeFileName={activeFileName}
|
||||
contextTabs={contextTabs.map(tab => ({
|
||||
...tab,
|
||||
title: tab.id
|
||||
}))}
|
||||
onRemoveTab={removeContextTab}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,30 +1,39 @@
|
||||
import React from "react"
|
||||
|
||||
// Stringify content for chat message component
|
||||
export const stringifyContent = (
|
||||
content: any,
|
||||
seen = new WeakSet()
|
||||
): string => {
|
||||
// Stringify content if it's a string
|
||||
if (typeof content === "string") {
|
||||
return content
|
||||
}
|
||||
// Stringify content if it's null
|
||||
if (content === null) {
|
||||
return "null"
|
||||
}
|
||||
// Stringify content if it's undefined
|
||||
if (content === undefined) {
|
||||
return "undefined"
|
||||
}
|
||||
// Stringify content if it's a number or boolean
|
||||
if (typeof content === "number" || typeof content === "boolean") {
|
||||
return content.toString()
|
||||
}
|
||||
// Stringify content if it's a function
|
||||
if (typeof content === "function") {
|
||||
return content.toString()
|
||||
}
|
||||
// Stringify content if it's a symbol
|
||||
if (typeof content === "symbol") {
|
||||
return content.toString()
|
||||
}
|
||||
// Stringify content if it's a bigint
|
||||
if (typeof content === "bigint") {
|
||||
return content.toString() + "n"
|
||||
}
|
||||
// Stringify content if it's a valid React element
|
||||
if (React.isValidElement(content)) {
|
||||
return React.Children.toArray(
|
||||
(content as React.ReactElement).props.children
|
||||
@ -32,11 +41,13 @@ export const stringifyContent = (
|
||||
.map((child) => stringifyContent(child, seen))
|
||||
.join("")
|
||||
}
|
||||
// Stringify content if it's an array
|
||||
if (Array.isArray(content)) {
|
||||
return (
|
||||
"[" + content.map((item) => stringifyContent(item, seen)).join(", ") + "]"
|
||||
)
|
||||
}
|
||||
// Stringify content if it's an object
|
||||
if (typeof content === "object") {
|
||||
if (seen.has(content)) {
|
||||
return "[Circular]"
|
||||
@ -51,19 +62,23 @@ export const stringifyContent = (
|
||||
return Object.prototype.toString.call(content)
|
||||
}
|
||||
}
|
||||
// Stringify content if it's a primitive value
|
||||
return String(content)
|
||||
}
|
||||
|
||||
// Copy to clipboard for chat message component
|
||||
export const copyToClipboard = (
|
||||
text: string,
|
||||
setCopiedText: (text: string | null) => void
|
||||
) => {
|
||||
// Copy text to clipboard for chat message component
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopiedText(text)
|
||||
setTimeout(() => setCopiedText(null), 2000)
|
||||
})
|
||||
}
|
||||
|
||||
// Handle send for chat message component
|
||||
export const handleSend = async (
|
||||
input: string,
|
||||
context: string | null,
|
||||
@ -76,14 +91,26 @@ export const handleSend = async (
|
||||
abortControllerRef: React.MutableRefObject<AbortController | null>,
|
||||
activeFileContent: string
|
||||
) => {
|
||||
if (input.trim() === "" && !context) return
|
||||
// Return if input is empty and context is null
|
||||
if (input.trim() === "" && !context) return
|
||||
|
||||
const newMessage = {
|
||||
// Get timestamp for chat message component
|
||||
const timestamp = new Date().toLocaleTimeString('en-US', {
|
||||
hour12: true,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).replace(/(\d{2}):(\d{2})/, '$1:$2')
|
||||
|
||||
// Create user message for chat message component
|
||||
const userMessage = {
|
||||
role: "user" as const,
|
||||
content: input,
|
||||
context: context || undefined,
|
||||
timestamp: timestamp
|
||||
}
|
||||
const updatedMessages = [...messages, newMessage]
|
||||
|
||||
// Update messages for chat message component
|
||||
const updatedMessages = [...messages, userMessage]
|
||||
setMessages(updatedMessages)
|
||||
setInput("")
|
||||
setIsContextExpanded(false)
|
||||
@ -93,11 +120,13 @@ export const handleSend = async (
|
||||
abortControllerRef.current = new AbortController()
|
||||
|
||||
try {
|
||||
// Create anthropic messages for chat message component
|
||||
const anthropicMessages = updatedMessages.map((msg) => ({
|
||||
role: msg.role === "user" ? "human" : "assistant",
|
||||
content: msg.content,
|
||||
}))
|
||||
|
||||
// Fetch AI response for chat message component
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_AI_WORKER_URL}/api`,
|
||||
{
|
||||
@ -114,20 +143,24 @@ export const handleSend = async (
|
||||
}
|
||||
)
|
||||
|
||||
// Throw error if response is not ok
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to get AI response")
|
||||
}
|
||||
|
||||
// Get reader for chat message component
|
||||
const reader = response.body?.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
const assistantMessage = { role: "assistant" as const, content: "" }
|
||||
setMessages([...updatedMessages, assistantMessage])
|
||||
setIsLoading(false)
|
||||
|
||||
// Initialize buffer for chat message component
|
||||
let buffer = ""
|
||||
const updateInterval = 100
|
||||
let lastUpdateTime = Date.now()
|
||||
|
||||
// Read response from reader for chat message component
|
||||
if (reader) {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
@ -146,6 +179,7 @@ export const handleSend = async (
|
||||
}
|
||||
}
|
||||
|
||||
// Update messages for chat message component
|
||||
setMessages((prev) => {
|
||||
const updatedMessages = [...prev]
|
||||
const lastMessage = updatedMessages[updatedMessages.length - 1]
|
||||
@ -154,6 +188,7 @@ export const handleSend = async (
|
||||
})
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Handle abort error for chat message component
|
||||
if (error.name === "AbortError") {
|
||||
console.log("Generation aborted")
|
||||
} else {
|
||||
@ -171,6 +206,7 @@ export const handleSend = async (
|
||||
}
|
||||
}
|
||||
|
||||
// Handle stop generation for chat message component
|
||||
export const handleStopGeneration = (
|
||||
abortControllerRef: React.MutableRefObject<AbortController | null>
|
||||
) => {
|
||||
@ -178,3 +214,22 @@ export const handleStopGeneration = (
|
||||
abortControllerRef.current.abort()
|
||||
}
|
||||
}
|
||||
|
||||
// Check if text looks like code for chat message component
|
||||
export const looksLikeCode = (text: string): boolean => {
|
||||
const codeIndicators = [
|
||||
/^import\s+/m, // import statements
|
||||
/^function\s+/m, // function declarations
|
||||
/^class\s+/m, // class declarations
|
||||
/^const\s+/m, // const declarations
|
||||
/^let\s+/m, // let declarations
|
||||
/^var\s+/m, // var declarations
|
||||
/[{}\[\]();]/, // common code syntax
|
||||
/^\s*\/\//m, // comments
|
||||
/^\s*\/\*/m, // multi-line comments
|
||||
/=>/, // arrow functions
|
||||
/^export\s+/m, // export statements
|
||||
];
|
||||
|
||||
return codeIndicators.some(pattern => pattern.test(text));
|
||||
};
|
||||
|
102
frontend/components/editor/AIChat/lib/ignored-paths.ts
Normal file
102
frontend/components/editor/AIChat/lib/ignored-paths.ts
Normal file
@ -0,0 +1,102 @@
|
||||
// Ignore certain folders and files from the file tree
|
||||
|
||||
export const ignoredFolders = [
|
||||
// Package managers
|
||||
'node_modules',
|
||||
'venv',
|
||||
'.env',
|
||||
'env',
|
||||
'.venv',
|
||||
'virtualenv',
|
||||
'pip-wheel-metadata',
|
||||
|
||||
// Build outputs
|
||||
'.next',
|
||||
'dist',
|
||||
'build',
|
||||
'out',
|
||||
'__pycache__',
|
||||
'.webpack',
|
||||
'.serverless',
|
||||
'storybook-static',
|
||||
|
||||
// Version control
|
||||
'.git',
|
||||
'.svn',
|
||||
'.hg', // Mercurial
|
||||
|
||||
// Cache and temp files
|
||||
'.cache',
|
||||
'coverage',
|
||||
'tmp',
|
||||
'.temp',
|
||||
'.npm',
|
||||
'.pnpm',
|
||||
'.yarn',
|
||||
'.eslintcache',
|
||||
'.stylelintcache',
|
||||
|
||||
// IDE specific
|
||||
'.idea',
|
||||
'.vscode',
|
||||
'.vs',
|
||||
'.sublime',
|
||||
|
||||
// Framework specific
|
||||
'.streamlit',
|
||||
'.next',
|
||||
'static',
|
||||
'.pytest_cache',
|
||||
'.nuxt',
|
||||
'.docusaurus',
|
||||
'.remix',
|
||||
'.parcel-cache',
|
||||
'public/build', // Remix/Rails
|
||||
'.turbo', // Turborepo
|
||||
|
||||
// Logs
|
||||
'logs',
|
||||
'*.log',
|
||||
'npm-debug.log*',
|
||||
'yarn-debug.log*',
|
||||
'yarn-error.log*',
|
||||
'pnpm-debug.log*',
|
||||
] as const;
|
||||
|
||||
export const ignoredFiles = [
|
||||
'.DS_Store',
|
||||
'.env.local',
|
||||
'.env.development',
|
||||
'.env.production',
|
||||
'.env.test',
|
||||
'.env*.local',
|
||||
'.gitignore',
|
||||
'.npmrc',
|
||||
'.yarnrc',
|
||||
'.editorconfig',
|
||||
'.prettierrc',
|
||||
'.eslintrc',
|
||||
'.browserslistrc',
|
||||
'tsconfig.tsbuildinfo',
|
||||
'*.pyc',
|
||||
'*.pyo',
|
||||
'*.pyd',
|
||||
'*.so',
|
||||
'*.dll',
|
||||
'*.dylib',
|
||||
'*.class',
|
||||
'*.exe',
|
||||
'package-lock.json',
|
||||
'yarn.lock',
|
||||
'pnpm-lock.yaml',
|
||||
'composer.lock',
|
||||
'poetry.lock',
|
||||
'Gemfile.lock',
|
||||
'*.min.js',
|
||||
'*.min.css',
|
||||
'*.map',
|
||||
'*.chunk.*',
|
||||
'*.hot-update.*',
|
||||
'.vercel',
|
||||
'.netlify'
|
||||
] as const;
|
79
frontend/components/editor/AIChat/lib/markdownComponents.tsx
Normal file
79
frontend/components/editor/AIChat/lib/markdownComponents.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
import { Components } from "react-markdown"
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"
|
||||
import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism"
|
||||
import { Button } from "../../../ui/button"
|
||||
import { CornerUpLeft } from "lucide-react"
|
||||
import { stringifyContent } from "./chatUtils"
|
||||
|
||||
// Create markdown components for chat message component
|
||||
export const createMarkdownComponents = (
|
||||
renderCopyButton: (text: any) => JSX.Element,
|
||||
renderMarkdownElement: (props: any) => JSX.Element,
|
||||
askAboutCode: (code: any) => void
|
||||
): Components => ({
|
||||
code: ({ node, className, children, ...props }: {
|
||||
node?: import('hast').Element,
|
||||
className?: string,
|
||||
children?: React.ReactNode,
|
||||
[key: string]: any,
|
||||
}) => {
|
||||
const match = /language-(\w+)/.exec(className || "")
|
||||
|
||||
return match ? (
|
||||
<div className="relative border border-input rounded-md my-4">
|
||||
<div className="absolute top-0 left-0 px-2 py-1 text-xs font-semibold text-gray-200 bg-#1e1e1e rounded-tl">
|
||||
{match[1]}
|
||||
</div>
|
||||
<div className="absolute top-0 right-0 flex">
|
||||
{renderCopyButton(children)}
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
askAboutCode(children)
|
||||
}}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="p-1 h-6"
|
||||
>
|
||||
<CornerUpLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="pt-6">
|
||||
<SyntaxHighlighter
|
||||
style={vscDarkPlus as any}
|
||||
language={match[1]}
|
||||
PreTag="div"
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
{stringifyContent(children)}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<code className={className} {...props}>{children}</code>
|
||||
)
|
||||
},
|
||||
// Render markdown elements
|
||||
p: ({ node, children, ...props }) => renderMarkdownElement({ node, children, ...props }),
|
||||
h1: ({ node, children, ...props }) => renderMarkdownElement({ node, children, ...props }),
|
||||
h2: ({ node, children, ...props }) => renderMarkdownElement({ node, children, ...props }),
|
||||
h3: ({ node, children, ...props }) => renderMarkdownElement({ node, children, ...props }),
|
||||
h4: ({ node, children, ...props }) => renderMarkdownElement({ node, children, ...props }),
|
||||
h5: ({ node, children, ...props }) => renderMarkdownElement({ node, children, ...props }),
|
||||
h6: ({ node, children, ...props }) => renderMarkdownElement({ node, children, ...props }),
|
||||
ul: (props) => (
|
||||
<ul className="list-disc pl-6 mb-4 space-y-2">
|
||||
{props.children}
|
||||
</ul>
|
||||
),
|
||||
ol: (props) => (
|
||||
<ol className="list-decimal pl-6 mb-4 space-y-2">
|
||||
{props.children}
|
||||
</ol>
|
||||
),
|
||||
})
|
93
frontend/components/editor/AIChat/types/index.ts
Normal file
93
frontend/components/editor/AIChat/types/index.ts
Normal file
@ -0,0 +1,93 @@
|
||||
import * as monaco from 'monaco-editor'
|
||||
import { TFile, TFolder } from "@/lib/types"
|
||||
import { Socket } from 'socket.io-client';
|
||||
|
||||
// Allowed file types for context tabs
|
||||
export const ALLOWED_FILE_TYPES = {
|
||||
// Text files
|
||||
'text/plain': true,
|
||||
'text/markdown': true,
|
||||
'text/csv': true,
|
||||
// Code files
|
||||
'application/json': true,
|
||||
'text/javascript': true,
|
||||
'text/typescript': true,
|
||||
'text/html': true,
|
||||
'text/css': true,
|
||||
// Documents
|
||||
'application/pdf': true,
|
||||
// Images
|
||||
'image/jpeg': true,
|
||||
'image/png': true,
|
||||
'image/gif': true,
|
||||
'image/webp': true,
|
||||
'image/svg+xml': true,
|
||||
} as const;
|
||||
|
||||
// Message interface
|
||||
export interface Message {
|
||||
role: "user" | "assistant"
|
||||
content: string
|
||||
context?: string
|
||||
}
|
||||
|
||||
// Context tab interface
|
||||
export interface ContextTab {
|
||||
id: string
|
||||
type: "file" | "code" | "image"
|
||||
name: string
|
||||
content: string
|
||||
lineRange?: { start: number; end: number }
|
||||
}
|
||||
|
||||
// AIChat props interface
|
||||
export interface AIChatProps {
|
||||
activeFileContent: string
|
||||
activeFileName: string
|
||||
onClose: () => void
|
||||
editorRef: React.MutableRefObject<monaco.editor.IStandaloneCodeEditor | undefined>
|
||||
lastCopiedRangeRef: React.MutableRefObject<{ startLine: number; endLine: number } | null>
|
||||
files: (TFile | TFolder)[]
|
||||
}
|
||||
|
||||
// Chat input props interface
|
||||
export interface ChatInputProps {
|
||||
input: string
|
||||
setInput: (input: string) => void
|
||||
isGenerating: boolean
|
||||
handleSend: (useFullContext?: boolean) => void
|
||||
handleStopGeneration: () => void
|
||||
onImageUpload: (file: File) => void
|
||||
addContextTab: (type: string, title: string, content: string, lineRange?: { start: number, end: number }) => void
|
||||
activeFileName?: string
|
||||
editorRef: React.MutableRefObject<monaco.editor.IStandaloneCodeEditor | undefined>
|
||||
lastCopiedRangeRef: React.MutableRefObject<{ startLine: number; endLine: number } | null>
|
||||
contextTabs: { id: string; type: string; title: string; content: string; lineRange?: { start: number; end: number } }[]
|
||||
onRemoveTab: (id: string) => void
|
||||
textareaRef: React.RefObject<HTMLTextAreaElement>
|
||||
}
|
||||
|
||||
// Chat message props interface
|
||||
export interface MessageProps {
|
||||
message: {
|
||||
role: "user" | "assistant"
|
||||
content: string
|
||||
context?: string
|
||||
}
|
||||
setContext: (context: string | null, name: string, range?: { start: number, end: number }) => void
|
||||
setIsContextExpanded: (isExpanded: boolean) => void
|
||||
socket: Socket | null
|
||||
}
|
||||
|
||||
// Context tabs props interface
|
||||
export interface ContextTabsProps {
|
||||
activeFileName: string
|
||||
onAddFile: (tab: ContextTab) => void
|
||||
contextTabs: ContextTab[]
|
||||
onRemoveTab: (id: string) => void
|
||||
isExpanded: boolean
|
||||
onToggleExpand: () => void
|
||||
files?: (TFile | TFolder)[]
|
||||
onFileSelect?: (file: TFile) => void
|
||||
socket: Socket | null
|
||||
}
|
@ -68,10 +68,12 @@ export default function GenerateInput({
|
||||
setCurrentPrompt(input)
|
||||
socket.emit(
|
||||
"generateCode",
|
||||
data.fileName,
|
||||
data.code,
|
||||
data.line,
|
||||
regenerate ? currentPrompt : input,
|
||||
{
|
||||
fileName: data.fileName,
|
||||
code: data.code,
|
||||
line: data.line,
|
||||
instructions: regenerate ? currentPrompt : input
|
||||
},
|
||||
(res: { response: string; success: boolean }) => {
|
||||
console.log("Generated code", res.response, res.success)
|
||||
// if (!res.success) {
|
||||
|
@ -107,7 +107,6 @@ export default function CodeEditor({
|
||||
|
||||
// Editor state
|
||||
const [editorLanguage, setEditorLanguage] = useState("plaintext")
|
||||
console.log("editor language: ",editorLanguage)
|
||||
const [cursorLine, setCursorLine] = useState(0)
|
||||
const [editorRef, setEditorRef] =
|
||||
useState<monaco.editor.IStandaloneCodeEditor>()
|
||||
@ -173,6 +172,9 @@ export default function CodeEditor({
|
||||
const editorPanelRef = useRef<ImperativePanelHandle>(null)
|
||||
const previewWindowRef = useRef<{ refreshIframe: () => void }>(null)
|
||||
|
||||
// Ref to store the last copied range in the editor to be used in the AIChat component
|
||||
const lastCopiedRangeRef = useRef<{ startLine: number; endLine: number } | null>(null);
|
||||
|
||||
const debouncedSetIsSelected = useRef(
|
||||
debounce((value: boolean) => {
|
||||
setIsSelected(value)
|
||||
@ -207,7 +209,7 @@ export default function CodeEditor({
|
||||
)
|
||||
const fetchFileContent = (fileId: string): Promise<string> => {
|
||||
return new Promise((resolve) => {
|
||||
socket?.emit("getFile", fileId, (content: string) => {
|
||||
socket?.emit("getFile", { fileId }, (content: string) => {
|
||||
resolve(content)
|
||||
})
|
||||
})
|
||||
@ -257,6 +259,17 @@ export default function CodeEditor({
|
||||
updatedOptions
|
||||
)
|
||||
}
|
||||
|
||||
// Store the last copied range in the editor to be used in the AIChat component
|
||||
editor.onDidChangeCursorSelection((e) => {
|
||||
const selection = editor.getSelection();
|
||||
if (selection) {
|
||||
lastCopiedRangeRef.current = {
|
||||
startLine: selection.startLineNumber,
|
||||
endLine: selection.endLineNumber
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Call the function with your file structure
|
||||
@ -532,7 +545,7 @@ export default function CodeEditor({
|
||||
)
|
||||
console.log(`Saving file...${activeFileId}`)
|
||||
console.log(`Saving file...${content}`)
|
||||
socket?.emit("saveFile", activeFileId, content)
|
||||
socket?.emit("saveFile", { fileId: activeFileId, body: content })
|
||||
}
|
||||
}, Number(process.env.FILE_SAVE_DEBOUNCE_DELAY) || 1000),
|
||||
[socket, fileContents]
|
||||
@ -649,7 +662,7 @@ export default function CodeEditor({
|
||||
|
||||
// Socket event listener effect
|
||||
useEffect(() => {
|
||||
const onConnect = () => {}
|
||||
const onConnect = () => { }
|
||||
|
||||
const onDisconnect = () => {
|
||||
setTerminals([])
|
||||
@ -715,7 +728,7 @@ export default function CodeEditor({
|
||||
|
||||
// Debounced function to get file content
|
||||
const debouncedGetFile = (tabId: any, callback: any) => {
|
||||
socket?.emit("getFile", tabId, callback)
|
||||
socket?.emit("getFile", { fileId: tabId }, callback)
|
||||
} // 300ms debounce delay, adjust as needed
|
||||
|
||||
const selectFile = (tab: TTab) => {
|
||||
@ -777,8 +790,8 @@ export default function CodeEditor({
|
||||
? numTabs === 1
|
||||
? null
|
||||
: index < numTabs - 1
|
||||
? tabs[index + 1].id
|
||||
: tabs[index - 1].id
|
||||
? tabs[index + 1].id
|
||||
: tabs[index - 1].id
|
||||
: activeFileId
|
||||
|
||||
setTabs((prev) => prev.filter((t) => t.id !== id))
|
||||
@ -835,7 +848,7 @@ export default function CodeEditor({
|
||||
return false
|
||||
}
|
||||
|
||||
socket?.emit("renameFile", id, newName)
|
||||
socket?.emit("renameFile", { fileId: id, newName })
|
||||
setTabs((prev) =>
|
||||
prev.map((tab) => (tab.id === id ? { ...tab, name: newName } : tab))
|
||||
)
|
||||
@ -844,7 +857,7 @@ export default function CodeEditor({
|
||||
}
|
||||
|
||||
const handleDeleteFile = (file: TFile) => {
|
||||
socket?.emit("deleteFile", file.id, (response: (TFolder | TFile)[]) => {
|
||||
socket?.emit("deleteFile", { fileId: file.id }, (response: (TFolder | TFile)[]) => {
|
||||
setFiles(response)
|
||||
})
|
||||
closeTab(file.id)
|
||||
@ -854,11 +867,11 @@ export default function CodeEditor({
|
||||
setDeletingFolderId(folder.id)
|
||||
console.log("deleting folder", folder.id)
|
||||
|
||||
socket?.emit("getFolder", folder.id, (response: string[]) =>
|
||||
socket?.emit("getFolder", { folderId: folder.id }, (response: string[]) =>
|
||||
closeTabs(response)
|
||||
)
|
||||
|
||||
socket?.emit("deleteFolder", folder.id, (response: (TFolder | TFile)[]) => {
|
||||
socket?.emit("deleteFolder", { folderId: folder.id }, (response: (TFolder | TFile)[]) => {
|
||||
setFiles(response)
|
||||
setDeletingFolderId("")
|
||||
})
|
||||
@ -902,7 +915,7 @@ export default function CodeEditor({
|
||||
<DisableAccessModal
|
||||
message={disableAccess.message}
|
||||
open={disableAccess.isDisabled}
|
||||
setOpen={() => {}}
|
||||
setOpen={() => { }}
|
||||
/>
|
||||
<Loading />
|
||||
</>
|
||||
@ -944,8 +957,8 @@ export default function CodeEditor({
|
||||
code:
|
||||
(isSelected && editorRef?.getSelection()
|
||||
? editorRef
|
||||
?.getModel()
|
||||
?.getValueInRange(editorRef?.getSelection()!)
|
||||
?.getModel()
|
||||
?.getValueInRange(editorRef?.getSelection()!)
|
||||
: editorRef?.getValue()) ?? "",
|
||||
line: generate.line,
|
||||
}}
|
||||
@ -1029,6 +1042,8 @@ export default function CodeEditor({
|
||||
setFiles={setFiles}
|
||||
addNew={(name, type) => addNew(name, type, setFiles, sandboxData)}
|
||||
deletingFolderId={deletingFolderId}
|
||||
toggleAIChat={toggleAIChat}
|
||||
isAIChatOpen={isAIChatOpen}
|
||||
/>
|
||||
{/* Outer ResizablePanelGroup for main layout */}
|
||||
<ResizablePanelGroup
|
||||
@ -1075,62 +1090,62 @@ export default function CodeEditor({
|
||||
</div>
|
||||
</>
|
||||
) : // Note clerk.loaded is required here due to a bug: https://github.com/clerk/javascript/issues/1643
|
||||
clerk.loaded ? (
|
||||
<>
|
||||
{provider && userInfo ? (
|
||||
<Cursors yProvider={provider} userInfo={userInfo} />
|
||||
) : null}
|
||||
<Editor
|
||||
height="100%"
|
||||
language={editorLanguage}
|
||||
beforeMount={handleEditorWillMount}
|
||||
onMount={handleEditorMount}
|
||||
onChange={(value) => {
|
||||
// If the new content is different from the cached content, update it
|
||||
if (value !== fileContents[activeFileId]) {
|
||||
setActiveFileContent(value ?? "") // Update the active file content
|
||||
// Mark the file as unsaved by setting 'saved' to false
|
||||
setTabs((prev) =>
|
||||
prev.map((tab) =>
|
||||
tab.id === activeFileId
|
||||
? { ...tab, saved: false }
|
||||
: tab
|
||||
clerk.loaded ? (
|
||||
<>
|
||||
{provider && userInfo ? (
|
||||
<Cursors yProvider={provider} userInfo={userInfo} />
|
||||
) : null}
|
||||
<Editor
|
||||
height="100%"
|
||||
language={editorLanguage}
|
||||
beforeMount={handleEditorWillMount}
|
||||
onMount={handleEditorMount}
|
||||
onChange={(value) => {
|
||||
// If the new content is different from the cached content, update it
|
||||
if (value !== fileContents[activeFileId]) {
|
||||
setActiveFileContent(value ?? "") // Update the active file content
|
||||
// Mark the file as unsaved by setting 'saved' to false
|
||||
setTabs((prev) =>
|
||||
prev.map((tab) =>
|
||||
tab.id === activeFileId
|
||||
? { ...tab, saved: false }
|
||||
: tab
|
||||
)
|
||||
)
|
||||
)
|
||||
} else {
|
||||
// If the content matches the cached content, mark the file as saved
|
||||
setTabs((prev) =>
|
||||
prev.map((tab) =>
|
||||
tab.id === activeFileId
|
||||
? { ...tab, saved: true }
|
||||
: tab
|
||||
} else {
|
||||
// If the content matches the cached content, mark the file as saved
|
||||
setTabs((prev) =>
|
||||
prev.map((tab) =>
|
||||
tab.id === activeFileId
|
||||
? { ...tab, saved: true }
|
||||
: tab
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}}
|
||||
options={{
|
||||
tabSize: 2,
|
||||
minimap: {
|
||||
enabled: false,
|
||||
},
|
||||
padding: {
|
||||
bottom: 4,
|
||||
top: 4,
|
||||
},
|
||||
scrollBeyondLastLine: false,
|
||||
fixedOverflowWidgets: true,
|
||||
fontFamily: "var(--font-geist-mono)",
|
||||
}}
|
||||
theme={theme === "light" ? "vs" : "vs-dark"}
|
||||
value={activeFileContent}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-xl font-medium text-muted-foreground/50 select-none">
|
||||
<Loader2 className="animate-spin w-6 h-6 mr-3" />
|
||||
Waiting for Clerk to load...
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
}}
|
||||
options={{
|
||||
tabSize: 2,
|
||||
minimap: {
|
||||
enabled: false,
|
||||
},
|
||||
padding: {
|
||||
bottom: 4,
|
||||
top: 4,
|
||||
},
|
||||
scrollBeyondLastLine: false,
|
||||
fixedOverflowWidgets: true,
|
||||
fontFamily: "var(--font-geist-mono)",
|
||||
}}
|
||||
theme={theme === "light" ? "vs" : "vs-dark"}
|
||||
value={activeFileContent}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-xl font-medium text-muted-foreground/50 select-none">
|
||||
<Loader2 className="animate-spin w-6 h-6 mr-3" />
|
||||
Waiting for Clerk to load...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
@ -1140,10 +1155,10 @@ export default function CodeEditor({
|
||||
isAIChatOpen && isHorizontalLayout
|
||||
? "horizontal"
|
||||
: isAIChatOpen
|
||||
? "vertical"
|
||||
: isHorizontalLayout
|
||||
? "horizontal"
|
||||
: "vertical"
|
||||
? "vertical"
|
||||
: isHorizontalLayout
|
||||
? "horizontal"
|
||||
: "vertical"
|
||||
}
|
||||
>
|
||||
<ResizablePanel
|
||||
@ -1218,6 +1233,9 @@ export default function CodeEditor({
|
||||
"No file selected"
|
||||
}
|
||||
onClose={toggleAIChat}
|
||||
editorRef={{ current: editorRef }}
|
||||
lastCopiedRangeRef={lastCopiedRangeRef}
|
||||
files={files}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
</>
|
||||
|
@ -10,7 +10,7 @@ import New from "./new"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { sortFileExplorer } from "@/lib/utils"
|
||||
import { cn, sortFileExplorer } from "@/lib/utils"
|
||||
import {
|
||||
dropTargetForElements,
|
||||
monitorForElements,
|
||||
@ -27,6 +27,8 @@ export default function Sidebar({
|
||||
setFiles,
|
||||
addNew,
|
||||
deletingFolderId,
|
||||
toggleAIChat,
|
||||
isAIChatOpen,
|
||||
}: {
|
||||
sandboxData: Sandbox
|
||||
files: (TFile | TFolder)[]
|
||||
@ -43,6 +45,8 @@ export default function Sidebar({
|
||||
setFiles: (files: (TFile | TFolder)[]) => void
|
||||
addNew: (name: string, type: "file" | "folder") => void
|
||||
deletingFolderId: string
|
||||
toggleAIChat: () => void
|
||||
isAIChatOpen: boolean
|
||||
}) {
|
||||
const ref = useRef(null) // drop target
|
||||
|
||||
@ -87,8 +91,10 @@ export default function Sidebar({
|
||||
setMovingId(fileId)
|
||||
socket.emit(
|
||||
"moveFile",
|
||||
fileId,
|
||||
folderId,
|
||||
{
|
||||
fileId,
|
||||
folderId
|
||||
},
|
||||
(response: (TFolder | TFile)[]) => {
|
||||
setFiles(response)
|
||||
setMovingId("")
|
||||
@ -186,7 +192,7 @@ export default function Sidebar({
|
||||
style={{ opacity: 1 }}
|
||||
>
|
||||
<Sparkles className="h-4 w-4 mr-2 text-indigo-500 opacity-70" />
|
||||
Copilot
|
||||
AI Editor
|
||||
<div className="ml-auto">
|
||||
<kbd className="pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground">
|
||||
<span className="text-xs">⌘</span>G
|
||||
@ -195,12 +201,24 @@ export default function Sidebar({
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start text-sm text-muted-foreground font-normal h-8 px-2 mb-2"
|
||||
disabled
|
||||
aria-disabled="true"
|
||||
className={cn(
|
||||
"w-full justify-start text-sm font-normal h-8 px-2 mb-2 border-t",
|
||||
isAIChatOpen
|
||||
? "bg-muted-foreground/25 text-foreground"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
onClick={toggleAIChat}
|
||||
aria-disabled={false}
|
||||
style={{ opacity: 1 }}
|
||||
>
|
||||
<MessageSquareMore className="h-4 w-4 mr-2 text-indigo-500 opacity-70" />
|
||||
<MessageSquareMore
|
||||
className={cn(
|
||||
"h-4 w-4 mr-2",
|
||||
isAIChatOpen
|
||||
? "text-indigo-500"
|
||||
: "text-indigo-500 opacity-70"
|
||||
)}
|
||||
/>
|
||||
AI Chat
|
||||
<div className="ml-auto">
|
||||
<kbd className="pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground">
|
||||
|
@ -27,7 +27,7 @@ export default function New({
|
||||
if (type === "file") {
|
||||
socket.emit(
|
||||
"createFile",
|
||||
name,
|
||||
{ name },
|
||||
({ success }: { success: boolean }) => {
|
||||
if (success) {
|
||||
addNew(name, type)
|
||||
@ -35,7 +35,7 @@ export default function New({
|
||||
}
|
||||
)
|
||||
} else {
|
||||
socket.emit("createFolder", name, () => {
|
||||
socket.emit("createFolder", { name }, () => {
|
||||
addNew(name, type)
|
||||
})
|
||||
}
|
||||
|
@ -65,12 +65,12 @@ export default function EditorTerminal({
|
||||
}
|
||||
|
||||
const disposableOnData = term.onData((data) => {
|
||||
socket.emit("terminalData", id, data)
|
||||
socket.emit("terminalData", { id, data })
|
||||
})
|
||||
|
||||
const disposableOnResize = term.onResize((dimensions) => {
|
||||
fitAddonRef.current?.fit()
|
||||
socket.emit("terminalResize", dimensions)
|
||||
socket.emit("terminalResize", { dimensions })
|
||||
})
|
||||
const resizeObserver = new ResizeObserver(
|
||||
debounce((entries) => {
|
||||
|
@ -22,7 +22,7 @@ export default function Landing() {
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button variant="outline" size="icon" asChild>
|
||||
<a href="https://www.x.com/ishaandey_" target="_blank">
|
||||
<a href="https://x.com/gitwitdev" target="_blank">
|
||||
<svg
|
||||
width="1200"
|
||||
height="1227"
|
||||
@ -54,7 +54,7 @@ export default function Landing() {
|
||||
<CustomButton>Go To App</CustomButton>
|
||||
</Link>
|
||||
<a
|
||||
href="https://github.com/ishaan1013/sandbox"
|
||||
href="https://github.com/jamesmurdza/sandbox"
|
||||
target="_blank"
|
||||
className="group h-9 px-4 py-2 inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
|
@ -63,7 +63,7 @@ export const TerminalProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
terminals,
|
||||
setTerminals,
|
||||
setActiveTerminalId,
|
||||
setClosingTerminal: () => {},
|
||||
setClosingTerminal: () => { },
|
||||
socket,
|
||||
activeTerminalId,
|
||||
})
|
||||
@ -73,7 +73,7 @@ export const TerminalProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
const deploy = (callback: () => void) => {
|
||||
if (!socket) console.error("Couldn't deploy: No socket")
|
||||
console.log("Deploying...")
|
||||
socket?.emit("deploy", () => {
|
||||
socket?.emit("deploy", {}, () => {
|
||||
callback()
|
||||
})
|
||||
}
|
||||
|
@ -32,9 +32,9 @@ export const createTerminal = ({
|
||||
setActiveTerminalId(id)
|
||||
|
||||
setTimeout(() => {
|
||||
socket.emit("createTerminal", id, () => {
|
||||
socket.emit("createTerminal", { id }, () => {
|
||||
setCreatingTerminal(false)
|
||||
if (command) socket.emit("terminalData", id, command + "\n")
|
||||
if (command) socket.emit("terminalData", { id, data: command + "\n" })
|
||||
})
|
||||
}, 1000)
|
||||
}
|
||||
@ -75,7 +75,7 @@ export const closeTerminal = ({
|
||||
|
||||
setClosingTerminal(term.id)
|
||||
|
||||
socket.emit("closeTerminal", term.id, () => {
|
||||
socket.emit("closeTerminal", { id: term.id }, () => {
|
||||
setClosingTerminal("")
|
||||
|
||||
const nextId =
|
||||
@ -83,8 +83,8 @@ export const closeTerminal = ({
|
||||
? numTerminals === 1
|
||||
? null
|
||||
: index < numTerminals - 1
|
||||
? terminals[index + 1].id
|
||||
: terminals[index - 1].id
|
||||
? terminals[index + 1].id
|
||||
: terminals[index - 1].id
|
||||
: activeTerminalId
|
||||
|
||||
setTerminals((prev) => prev.filter((t) => t.id !== term.id))
|
||||
|
@ -73,7 +73,11 @@ function mapModule(module: string): monaco.languages.typescript.ModuleKind {
|
||||
)
|
||||
}
|
||||
|
||||
function mapJSX(jsx: string): monaco.languages.typescript.JsxEmit {
|
||||
function mapJSX(jsx: string | undefined): monaco.languages.typescript.JsxEmit {
|
||||
if (!jsx || typeof jsx !== 'string') {
|
||||
return monaco.languages.typescript.JsxEmit.React // Default value
|
||||
}
|
||||
|
||||
const jsxMap: { [key: string]: monaco.languages.typescript.JsxEmit } = {
|
||||
preserve: monaco.languages.typescript.JsxEmit.Preserve,
|
||||
react: monaco.languages.typescript.JsxEmit.React,
|
||||
|
6
frontend/package-lock.json
generated
6
frontend/package-lock.json
generated
@ -37,6 +37,7 @@
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"embla-carousel": "^8.3.0",
|
||||
"embla-carousel-react": "^8.3.0",
|
||||
"embla-carousel-wheel-gestures": "^8.0.1",
|
||||
"framer-motion": "^11.2.3",
|
||||
@ -3128,12 +3129,14 @@
|
||||
"node_modules/embla-carousel": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.3.0.tgz",
|
||||
"integrity": "sha512-Ve8dhI4w28qBqR8J+aMtv7rLK89r1ZA5HocwFz6uMB/i5EiC7bGI7y+AM80yAVUJw3qqaZYK7clmZMUR8kM3UA=="
|
||||
"integrity": "sha512-Ve8dhI4w28qBqR8J+aMtv7rLK89r1ZA5HocwFz6uMB/i5EiC7bGI7y+AM80yAVUJw3qqaZYK7clmZMUR8kM3UA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/embla-carousel-react": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.3.0.tgz",
|
||||
"integrity": "sha512-P1FlinFDcIvggcErRjNuVqnUR8anyo8vLMIH8Rthgofw7Nj8qTguCa2QjFAbzxAUTQTPNNjNL7yt0BGGinVdFw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"embla-carousel": "8.3.0",
|
||||
"embla-carousel-reactive-utils": "8.3.0"
|
||||
@ -3154,6 +3157,7 @@
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/embla-carousel-wheel-gestures/-/embla-carousel-wheel-gestures-8.0.1.tgz",
|
||||
"integrity": "sha512-LMAnruDqDmsjL6UoQD65aLotpmfO49Fsr3H0bMi7I+BH6jbv9OJiE61kN56daKsVtCQEt0SU1MrJslbhtgF3yQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"wheel-gestures": "^2.2.5"
|
||||
},
|
||||
|
@ -38,6 +38,7 @@
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"embla-carousel": "^8.3.0",
|
||||
"embla-carousel-react": "^8.3.0",
|
||||
"embla-carousel-wheel-gestures": "^8.0.1",
|
||||
"framer-motion": "^11.2.3",
|
||||
|
1669
package-lock.json
generated
1669
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,8 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"@radix-ui/react-popover": "^1.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.15"
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
// Import necessary modules
|
||||
import { io, Socket } from "socket.io-client";
|
||||
import dotenv from "dotenv";
|
||||
import { io, Socket } from "socket.io-client";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
@ -21,7 +21,7 @@ socketRef.on("connect", async () => {
|
||||
console.log("Connected to the server");
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
socketRef.emit("list", (response: CallbackResponse) => {
|
||||
socketRef.emit("list", {}, (response: CallbackResponse) => {
|
||||
if (response.success) {
|
||||
console.log("List of apps:", response.apps);
|
||||
} else {
|
||||
@ -29,7 +29,7 @@ socketRef.on("connect", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
socketRef.emit("deploy", (response: CallbackResponse) => {
|
||||
socketRef.emit("deploy", {}, (response: CallbackResponse) => {
|
||||
if (response.success) {
|
||||
console.log("It worked!");
|
||||
} else {
|
||||
|
Reference in New Issue
Block a user