Compare commits
170 Commits
fix-files-
...
new-schema
Author | SHA1 | Date | |
---|---|---|---|
0828209455 | |||
94ca5b2c9f | |||
2a58d0a5e3 | |||
30c9da559f | |||
2262adca74 | |||
b486d22111 | |||
81399cd351 | |||
96812027d0 | |||
85abbbdb0b | |||
3db3fbc490 | |||
c6c01101f1 | |||
9c6067dcd9 | |||
9c98e41ebb | |||
c669babb2f | |||
474102aa14 | |||
5a63ab7265 | |||
5633727bdb | |||
39911e9ef2 | |||
f35330ba4f | |||
9197050ca3 | |||
ebb270911b | |||
60c5345753 | |||
95154af074 | |||
7ed2d14435 | |||
198b59aa55 | |||
f6077ed516 | |||
684ee20a03 | |||
e658a84a9b | |||
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 | |||
52aa9d92fd | |||
63aa2b1e09 | |||
3b93090d97 | |||
0dc9a4ae96 | |||
5b72799046 | |||
e6642514a3 | |||
bf5bc8b1cf | |||
107cd3ddcc | |||
009d4d5164 | |||
e2d237fe09 | |||
9546d4ad7b | |||
eb4e34cf10 | |||
68964c2c8f | |||
7563df85a7 | |||
06070a6b2f | |||
f299f2ee3d | |||
4f7a4a5312 | |||
6fb1364d6f | |||
3f8e27d969 | |||
305939c15c | |||
2897b908fd | |||
7951221310 | |||
c2156021f7 | |||
428d2366ff | |||
a08848148b | |||
fae09d2b6d | |||
a459da6e6f | |||
4221d7d09a | |||
ae38a77759 | |||
fe0adb8e84 | |||
7722c533a4 | |||
54706314ea | |||
ce4137d697 | |||
cc8e0ce187 | |||
ad9457b157 | |||
1416c225a2 | |||
77265dde28 | |||
9e13db2020 | |||
751d9a3005 | |||
cc4a5307cd | |||
ab7ee17145 | |||
bfc687a3e6 | |||
930519515c | |||
3c4e8ec2de | |||
82c6a12883 | |||
6e14f676cf | |||
fa5d1e9a57 | |||
33fc082217 | |||
1365fecb08 | |||
dd59608d73 | |||
a0183451ad | |||
eabc9fa2f6 | |||
62e282da63 | |||
f192d9f3ab | |||
b6569550fc | |||
f863f2f763 | |||
6ea86afc70 | |||
41dbd4a1da | |||
08fccdd506 | |||
cf6888e3d3 | |||
229b489c1e | |||
8ae166fef4 | |||
645ff5b119 | |||
7e48faa1b5 | |||
9d06808137 | |||
63f3b082d5 | |||
8e3a6d1aa6 | |||
023b3bdc5e | |||
01fb3ab921 | |||
13be78dee8 | |||
7a00d24ab9 | |||
69b1287349 | |||
09b3cf1862 | |||
f4c79bbb07 | |||
55fde2f648 | |||
0f619ccb7d | |||
b7230f1bc4 | |||
af45df28d5 | |||
c2a23fcbcb | |||
0f7eb9a856 | |||
0a99eda5ec | |||
c5b197f41c | |||
70cfb5dc3f | |||
6bfff62513 | |||
0b7cc51c6e | |||
a353863523 | |||
c94678c430 | |||
585dcb469e | |||
2f88ff6d58 | |||
0509716f34 | |||
06118e98e9 | |||
4ebd6dea96 | |||
8921cd83bb | |||
45097e0f20 | |||
62e6d64a52 | |||
0c6b2b0dfb | |||
31d74ddc2d | |||
62311faf51 | |||
208d17879f | |||
0067dc8c0c | |||
4fe749daf2 | |||
b01934bd20 | |||
a1990a189c | |||
bf79893dfa | |||
47324f15bf | |||
7149925539 | |||
665e36603f | |||
0679f99bb7 | |||
2065814aaa | |||
1502047bf2 | |||
bbd47db467 | |||
a15c1f15f5 | |||
f1a65106b0 | |||
5726cecb22 |
6
.prettierrc
Normal file
6
.prettierrc
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"tabWidth": 2,
|
||||
"semi": false,
|
||||
"singleQuote": false,
|
||||
"insertFinalNewline": true
|
||||
}
|
8
.vscode/settings.json
vendored
Normal file
8
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"editor.formatOnSave": true,
|
||||
"editor.formatOnSaveMode": "file",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll": "explicit",
|
||||
"source.organizeImports": "explicit"
|
||||
}
|
||||
}
|
1
LICENSE
1
LICENSE
@ -1,6 +1,7 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Ishaan Dey
|
||||
Copyright (c) 2024 GitWit, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
296
README.md
296
README.md
@ -1,103 +1,247 @@
|
||||
# Sandbox 📦🪄
|
||||
# GitWit Sandbox 📦🪄
|
||||
|
||||
<img width="1799" alt="Screenshot 2024-05-31 at 8 33 56 AM" src="https://github.com/ishaan1013/sandbox/assets/69771365/3f73d7c0-f82a-4997-b01e-eaa043e95113">
|
||||

|
||||
|
||||
Sandbox is an open-source cloud-based code editing environment with custom AI code autocompletion and real-time collaboration.
|
||||
Sandbox is an open-source cloud-based code editing environment with custom AI code generation, live preview, real-time collaboration and AI chat.
|
||||
|
||||
Check out the [Twitter thread](https://x.com/ishaandey_/status/1796338262002573526) with the demo video!
|
||||
|
||||
Check out this [guide](https://dev.to/jamesmurdza/how-to-setup-ishaan1013sandbox-locally-503p) made by [@jamesmurdza](https://x.com/jamesmurdza) on setting it up locally!
|
||||
For the latest updates, join our Discord server: [discord.gitwit.dev](https://discord.gitwit.dev/).
|
||||
|
||||
## Running Locally
|
||||
|
||||
### Frontend
|
||||
Notes:
|
||||
|
||||
Install dependencies
|
||||
- Double check that whatever you change "SUPERDUPERSECRET" to, it's the same in all config files.
|
||||
|
||||
### 0. Requirements
|
||||
|
||||
The application uses NodeJS for the backend, NextJS for the frontend and Cloudflare workers for additional backend tasks.
|
||||
|
||||
Needed accounts to set up:
|
||||
|
||||
- [Clerk](https://clerk.com/): Used for user authentication.
|
||||
- [Liveblocks](https://liveblocks.io/): Used for collaborative editing.
|
||||
- [E2B](https://e2b.dev/): Used for the terminals and live preview.
|
||||
- [Cloudflare](https://www.cloudflare.com/): Used for relational data storage (D2) and file storage (R2).
|
||||
|
||||
A quick overview of the tech before we start: The deployment uses a **NextJS** app for the frontend and an **ExpressJS** server on the backend. Presumably that's because NextJS integrates well with Clerk middleware but not with Socket.io.
|
||||
|
||||
### 1. Initial setup
|
||||
|
||||
No surprise in the first step:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
git clone https://github.com/jamesmurdza/sandbox
|
||||
cd sandbox
|
||||
```
|
||||
|
||||
Add the required environment variables in `.env` (example file provided in `.env.example`). You will need to make an account on [Clerk](https://clerk.com/) and [Liveblocks](https://liveblocks.io/) to get API keys.
|
||||
Run `npm install` in:
|
||||
|
||||
Then, run in development mode
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
/frontend
|
||||
/backend/database
|
||||
/backend/storage
|
||||
/backend/server
|
||||
/backend/ai
|
||||
```
|
||||
|
||||
### Backend
|
||||
### 2. Adding Clerk
|
||||
|
||||
The backend consists of a primary Express and Socket.io server, and 3 Cloudflare Workers microservices for the D1 database, R2 storage, and Workers AI. The D1 database also contains a [service binding](https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/) to the R2 storage worker. Each open sandbox instantiates a secure Linux sandboxes on E2B, which is used for the terminal and live preview.
|
||||
Setup the Clerk account.
|
||||
Get the API keys from Clerk.
|
||||
|
||||
You will need to make an account on [E2B](https://e2b.dev/) to get an API key.
|
||||
Update `/frontend/.env`:
|
||||
|
||||
#### Socket.io server
|
||||
|
||||
Install dependencies
|
||||
|
||||
```bash
|
||||
cd backend/server
|
||||
npm install
|
||||
```
|
||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY='🔑'
|
||||
CLERK_SECRET_KEY='🔑'
|
||||
```
|
||||
|
||||
Add the required environment variables in `.env` (example file provided in `.env.example`)
|
||||
### 3. Deploying the storage bucket
|
||||
|
||||
Project files will be stored in the `projects/<project-id>` directory. The middleware contains basic authorization logic for connecting to the server.
|
||||
Go to Cloudflare.
|
||||
Create and name an R2 storage bucket in the control panel.
|
||||
Copy the account ID of one domain.
|
||||
|
||||
Run in development mode
|
||||
Update `/backend/storage/src/wrangler.toml`:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
account_id = '🔑'
|
||||
bucket_name = '🔑'
|
||||
key = 'SUPERDUPERSECRET'
|
||||
```
|
||||
|
||||
This directory is dockerized, so feel free to deploy a container on any platform of your choice! I chose not to deploy this project for public access due to costs & safety, but deploying your own for personal use should be no problem.
|
||||
In the `/backend/storage/src` directory:
|
||||
|
||||
#### Cloudflare Workers (Database, Storage, AI)
|
||||
|
||||
Directories:
|
||||
|
||||
- `/backend/database`: D1 database
|
||||
- `/backend/storage`: R2 storage
|
||||
- `/backend/ai`: Workers AI
|
||||
|
||||
Install dependencies
|
||||
|
||||
```bash
|
||||
cd backend/database
|
||||
npm install
|
||||
|
||||
cd ../storage
|
||||
npm install
|
||||
|
||||
cd ../ai
|
||||
npm install
|
||||
```
|
||||
|
||||
Read the [documentation](https://developers.cloudflare.com/workers/) to learn more about workers.
|
||||
|
||||
For each directory, add the required environment variables in `wrangler.toml` (example file provided in `wrangler.example.toml`). For the AI worker, you can define any value you want for the `CF_AI_KEY` -- set this in other `.env` files to authorize access.
|
||||
|
||||
Run in development mode
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Deploy to Cloudflare with [Wrangler](https://developers.cloudflare.com/workers/wrangler/install-and-update/)
|
||||
|
||||
```bash
|
||||
npx wrangler deploy
|
||||
```
|
||||
|
||||
---
|
||||
### 4. Deploying the database
|
||||
|
||||
Create a database:
|
||||
|
||||
```
|
||||
npx wrangler d1 create sandbox-database
|
||||
```
|
||||
|
||||
Use the output for the next setp.
|
||||
|
||||
Update `/backend/database/src/wrangler.toml`:
|
||||
|
||||
```
|
||||
database_name = '🔑'
|
||||
database_id = '🔑'
|
||||
KEY = 'SUPERDUPERSECRET'
|
||||
STORAGE_WORKER_URL = 'https://storage.🍎.workers.dev'
|
||||
```
|
||||
|
||||
In the `/backend/database/src` directory:
|
||||
|
||||
```
|
||||
npx wrangler deploy
|
||||
```
|
||||
|
||||
### 5. Applying the database schema
|
||||
|
||||
Delete the `/backend/database/drizzle/meta` directory.
|
||||
|
||||
In the `/backend/database/` directory:
|
||||
|
||||
```
|
||||
npm run generate
|
||||
npx wrangler d1 execute sandbox-database --remote --file=./drizzle/0000_🍏_🍐.sql
|
||||
```
|
||||
|
||||
### 6. Configuring the server
|
||||
|
||||
Update `/backend/server/.env`:
|
||||
|
||||
```
|
||||
DATABASE_WORKER_URL='https://database.🍎.workers.dev'
|
||||
STORAGE_WORKER_URL='https://storage.🍎.workers.dev'
|
||||
WORKERS_KEY='SUPERDUPERSECRET'
|
||||
```
|
||||
|
||||
### 7. Adding Liveblocks
|
||||
|
||||
Setup the Liveblocks account.
|
||||
|
||||
Update `/frontend/.env`:
|
||||
|
||||
```
|
||||
NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY='🔑'
|
||||
LIVEBLOCKS_SECRET_KEY='🔑'
|
||||
```
|
||||
|
||||
### 8. Adding E2B
|
||||
|
||||
Setup the E2B account.
|
||||
|
||||
Update `/backend/server/.env`:
|
||||
|
||||
```
|
||||
E2B_API_KEY='🔑'
|
||||
```
|
||||
|
||||
### 9. Adding AI code generation
|
||||
|
||||
In the `/backend/ai` directory:
|
||||
|
||||
```
|
||||
npx wrangler deploy
|
||||
```
|
||||
|
||||
Update `/backend/server/.env`:
|
||||
|
||||
```
|
||||
AI_WORKER_URL='https://ai.🍎.workers.dev'
|
||||
```
|
||||
|
||||
### 10. Configuring the frontend
|
||||
|
||||
Update `/frontend/.env`:
|
||||
|
||||
```
|
||||
NEXT_PUBLIC_DATABASE_WORKER_URL='https://database.🍎.workers.dev'
|
||||
NEXT_PUBLIC_STORAGE_WORKER_URL='https://storage.🍎.workers.dev'
|
||||
NEXT_PUBLIC_WORKERS_KEY='SUPERDUPERSECRET'
|
||||
```
|
||||
|
||||
### 11. Running the IDE
|
||||
|
||||
Run `npm run dev` simultaneously in:
|
||||
|
||||
```
|
||||
/frontend
|
||||
/backend/server
|
||||
```
|
||||
|
||||
## Setting up Deployments
|
||||
|
||||
The steps above do not include steps to setup [Dokku](https://github.com/dokku/dokku), which is required for deployments.
|
||||
|
||||
**Note:** This is completely optional to set up if you just want to run GitWit Sandbox.
|
||||
|
||||
Setting up deployments first requires a separate domain (such as gitwit.app, which we use).
|
||||
|
||||
We then deploy Dokku on a separate server, according to this guide: https://dev.to/jamesmurdza/host-your-own-paas-platform-as-a-service-on-amazon-web-services-3f0d
|
||||
|
||||
The Sandbox platform connects to the Dokku server via SSH, using SSH keys specifically generated for this connection. The SSH key is stored on the Sandbox server, and the following environment variables are set in /backend/server/.env:
|
||||
|
||||
```bash
|
||||
DOKKU_HOST=
|
||||
DOKKU_USERNAME=
|
||||
DOKKU_KEY=
|
||||
```
|
||||
|
||||
## Creating Custom Templates
|
||||
|
||||
Anyone can contribute a custom template for integration in Sandbox. Since Sandbox is built on E2B, there is no limitation to what langauge or runtime a Sandbox can use.
|
||||
|
||||
Currently there are four templates:
|
||||
- [jamesmurdza/dokku-reactjs-template](https://github.com/jamesmurdza/dokku-reactjs-template)
|
||||
- [jamesmurdza/dokku-vanillajs-template](https://github.com/jamesmurdza/dokku-vanillajs-template)
|
||||
- [jamesmurdza/dokku-nextjs-template](https://github.com/jamesmurdza/dokku-nextjs-template)
|
||||
- [jamesmurdza/dokku-streamlit-template](https://github.com/jamesmurdza/dokku-streamlit-template)
|
||||
|
||||
To create your own template, you can fork one of the above templates or start with a new blank repository. The template should have at least an `e2b.Dockerfile`, which is used by E2B to create the development environment. Optionally, a `Dockerfile` can be added which will be used to create the project build when it is deployed.
|
||||
|
||||
To test the template, you must have an [E2B account](https://e2b.dev/) and the [E2B CLI tools](https://e2b.dev/docs/cli) installed. Then, in the Terminal, run:
|
||||
|
||||
```
|
||||
e2b auth login
|
||||
```
|
||||
|
||||
Then, navigate to your template directory and run the following command where **TEMPLATENAME** is the name of your template:
|
||||
|
||||
```
|
||||
e2b template build -d e2b.Dockerfile -n TEMPLATENAME
|
||||
```
|
||||
|
||||
Finally, to test your template run:
|
||||
|
||||
```
|
||||
e2b sandbox spawn TEMPLATENAME
|
||||
cd project
|
||||
```
|
||||
|
||||
You will see a URL in the form of `https://xxxxxxxxxxxxxxxxxxx.e2b-staging.com`.
|
||||
|
||||
Now, run the command to start your development server.
|
||||
|
||||
To see the running server, visit the public url `https://<PORT>-xxxxxxxxxxxxxxxxxxx.e2b-staging.com`.
|
||||
|
||||
If you've done this and it works, let us know and we'll add your template to Sandbox! Please reach out to us [on Discord](https://discord.gitwit.dev/) with any questions or to submit your working template.
|
||||
|
||||
Note: In the future, we will add a way to specify the command triggered by the "Run" button (e.g. "npm run dev").
|
||||
|
||||
For more information, see:
|
||||
- [Custom E2B Sandboxes](https://e2b.dev/docs/sandbox-template)
|
||||
- [Dokku Builders](https://dokku.com/docs/deployment/builders/builder-management/)
|
||||
|
||||
## Contributing
|
||||
|
||||
Thanks for your interest in contributing! Review this section before submitting your first pull request. If you need any help, feel free to reach out to [@ishaandey\_](https://x.com/ishaandey_).
|
||||
|
||||
Please prioritize existing issues, but feel free to contribute new issues if you have ideas for a feature or bug that you think would be useful.
|
||||
Thanks for your interest in contributing! Review this section before submitting your first pull request. If you need any help, feel free contact us [on Discord](https://discord.gitwit.dev/).
|
||||
|
||||
### Structure
|
||||
|
||||
@ -116,13 +260,13 @@ backend/
|
||||
└── ai
|
||||
```
|
||||
|
||||
| Path | Description |
|
||||
| ------------------ | -------------------------------------------------------------------------- |
|
||||
| `frontend` | The Next.js application for the frontend. |
|
||||
| `backend/server` | The Express websocket server. |
|
||||
| `backend/database` | API for interfacing with the D1 database (SQLite). |
|
||||
| Path | Description |
|
||||
| ------------------ | ------------------------------------------------------------ |
|
||||
| `frontend` | The Next.js application for the frontend. |
|
||||
| `backend/server` | The Express websocket server. |
|
||||
| `backend/database` | API for interfacing with the D1 database (SQLite). |
|
||||
| `backend/storage` | API for interfacing with R2 storage. Service-bound to `/backend/database`. |
|
||||
| `backend/ai` | API for making requests to Workers AI . |
|
||||
| `backend/ai` | API for making requests to Workers AI . |
|
||||
|
||||
### Development
|
||||
|
||||
@ -151,11 +295,15 @@ It should be in the form `category(scope or module): message` in your commit mes
|
||||
|
||||
- `feat / feature`: all changes that introduce completely new code or new
|
||||
features
|
||||
|
||||
- `fix`: changes that fix a bug (ideally you will additionally reference an
|
||||
issue if present)
|
||||
|
||||
- `refactor`: any code related change that is not a fix nor a feature
|
||||
|
||||
- `docs`: changing existing or creating new documentation (i.e. README, docs for
|
||||
usage of a lib or cli usage)
|
||||
|
||||
- `chore`: all changes to the repository that do not fit into any of the above
|
||||
categories
|
||||
|
||||
|
@ -1,5 +0,0 @@
|
||||
{
|
||||
"tabWidth": 2,
|
||||
"semi": false,
|
||||
"singleQuote": false
|
||||
}
|
216
backend/ai/package-lock.json
generated
216
backend/ai/package-lock.json
generated
@ -7,6 +7,9 @@
|
||||
"": {
|
||||
"name": "ai",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.27.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/vitest-pool-workers": "^0.1.0",
|
||||
"@cloudflare/workers-types": "^4.20240512.0",
|
||||
@ -15,6 +18,28 @@
|
||||
"wrangler": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.27.2.tgz",
|
||||
"integrity": "sha512-Q6gOx4fyHQ+NCSaVeXEKFZfoFWCR3ctUA+sK5oGB7RKUkzUvK64aYM7v1T9ekJKwn8TwRq6IGjqS31n9PbjCIA==",
|
||||
"dependencies": {
|
||||
"@types/node": "^18.11.18",
|
||||
"@types/node-fetch": "^2.6.4",
|
||||
"abort-controller": "^3.0.0",
|
||||
"agentkeepalive": "^4.2.1",
|
||||
"form-data-encoder": "1.7.2",
|
||||
"formdata-node": "^4.3.2",
|
||||
"node-fetch": "^2.6.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk/node_modules/@types/node": {
|
||||
"version": "18.19.50",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.50.tgz",
|
||||
"integrity": "sha512-xonK+NRrMBRtkL1hVCc3G+uXtjh1Al4opBLjqVmipe5ZAaBYWW6cNAiBVZ1BvmkBhep698rP3UM3aRAdSALuhg==",
|
||||
"dependencies": {
|
||||
"undici-types": "~5.26.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@cloudflare/kv-asset-handler": {
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.3.2.tgz",
|
||||
@ -861,11 +886,19 @@
|
||||
"version": "20.12.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.11.tgz",
|
||||
"integrity": "sha512-vDg9PZ/zi+Nqp6boSOT7plNuthRugEKixDv5sFTIpkE89MmNtEArAShI4mxuX2+UrLEe9pxC1vm2cjm9YlWbJw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~5.26.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node-fetch": {
|
||||
"version": "2.6.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.11.tgz",
|
||||
"integrity": "sha512-24xFj9R5+rfQJLRyM56qh+wnVSYhyXC2tkoBndtY0U+vubqNsYXGjufB2nn8Q6gt0LrARwL6UBtMCSVCwl4B1g==",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"form-data": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node-forge": {
|
||||
"version": "1.3.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.11.tgz",
|
||||
@ -944,6 +977,17 @@
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/abort-controller": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
|
||||
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
|
||||
"dependencies": {
|
||||
"event-target-shim": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.5"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.11.3",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz",
|
||||
@ -965,6 +1009,17 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agentkeepalive": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.5.0.tgz",
|
||||
"integrity": "sha512-5GG/5IbQQpC9FpkRGsSvZI5QYeSCzlJHdpBQntCsuTOxhKD8lqKhrleg2Yi7yvMIf82Ycmmqln9U8V9qwEiJew==",
|
||||
"dependencies": {
|
||||
"humanize-ms": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
||||
@ -1008,6 +1063,11 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
@ -1141,6 +1201,17 @@
|
||||
"integrity": "sha512-a3KdPAANPbNE4ZUv9h6LckSl9zLsYOP4MBmhIPkRaeyybt+r4UghLvq+xw/YwUcC1gqylCkL4rdVs3Lwupjm4Q==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-12.0.0.tgz",
|
||||
@ -1214,6 +1285,14 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/devalue": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/devalue/-/devalue-4.3.3.tgz",
|
||||
@ -1287,6 +1366,14 @@
|
||||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/event-target-shim": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
|
||||
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/execa": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz",
|
||||
@ -1334,6 +1421,36 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
|
||||
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data-encoder": {
|
||||
"version": "1.7.2",
|
||||
"resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz",
|
||||
"integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A=="
|
||||
},
|
||||
"node_modules/formdata-node": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz",
|
||||
"integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==",
|
||||
"dependencies": {
|
||||
"node-domexception": "1.0.0",
|
||||
"web-streams-polyfill": "4.0.0-beta.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.20"
|
||||
}
|
||||
},
|
||||
"node_modules/fs.realpath": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||
@ -1452,6 +1569,14 @@
|
||||
"node": ">=16.17.0"
|
||||
}
|
||||
},
|
||||
"node_modules/humanize-ms": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz",
|
||||
"integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==",
|
||||
"dependencies": {
|
||||
"ms": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/inflight": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||
@ -1610,6 +1735,25 @@
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mimic-fn": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz",
|
||||
@ -1675,8 +1819,7 @@
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
||||
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
|
||||
"dev": true
|
||||
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
|
||||
},
|
||||
"node_modules/mustache": {
|
||||
"version": "4.2.0",
|
||||
@ -1705,6 +1848,43 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/node-domexception": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
|
||||
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/jimmywarting"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://paypal.me/jimmywarting"
|
||||
}
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=10.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||
"dependencies": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "4.x || >=6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"encoding": "^0.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"encoding": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/node-forge": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz",
|
||||
@ -2222,6 +2402,11 @@
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
|
||||
},
|
||||
"node_modules/ts-json-schema-generator": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/ts-json-schema-generator/-/ts-json-schema-generator-1.5.1.tgz",
|
||||
@ -2292,8 +2477,7 @@
|
||||
"node_modules/undici-types": {
|
||||
"version": "5.26.5",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
|
||||
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
|
||||
"dev": true
|
||||
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "5.2.11",
|
||||
@ -2827,6 +3011,28 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/web-streams-polyfill": {
|
||||
"version": "4.0.0-beta.3",
|
||||
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz",
|
||||
"integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||
"dependencies": {
|
||||
"tr46": "~0.0.3",
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
@ -1,19 +1,22 @@
|
||||
{
|
||||
"name": "ai",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"deploy": "wrangler deploy",
|
||||
"dev": "wrangler dev",
|
||||
"start": "wrangler dev",
|
||||
"test": "vitest",
|
||||
"cf-typegen": "wrangler types"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/vitest-pool-workers": "^0.1.0",
|
||||
"@cloudflare/workers-types": "^4.20240512.0",
|
||||
"typescript": "^5.0.4",
|
||||
"vitest": "1.3.0",
|
||||
"wrangler": "^3.0.0"
|
||||
}
|
||||
}
|
||||
"name": "ai",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"deploy": "wrangler deploy",
|
||||
"dev": "wrangler dev",
|
||||
"start": "wrangler dev",
|
||||
"test": "vitest",
|
||||
"cf-typegen": "wrangler types"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/vitest-pool-workers": "^0.1.0",
|
||||
"@cloudflare/workers-types": "^4.20240512.0",
|
||||
"typescript": "^5.0.4",
|
||||
"vitest": "1.3.0",
|
||||
"wrangler": "^3.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.27.2"
|
||||
}
|
||||
}
|
@ -1,43 +1,128 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { MessageParam } from "@anthropic-ai/sdk/src/resources/messages.js"
|
||||
|
||||
export interface Env {
|
||||
AI: any
|
||||
ANTHROPIC_API_KEY: string
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request, env): Promise<Response> {
|
||||
if (request.method !== "GET") {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
// Handle CORS preflight requests
|
||||
if (request.method === "OPTIONS") {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (request.method !== "GET" && request.method !== "POST") {
|
||||
return new Response("Method Not Allowed", { status: 405 })
|
||||
}
|
||||
|
||||
const url = new URL(request.url)
|
||||
const fileName = url.searchParams.get("fileName")
|
||||
const instructions = url.searchParams.get("instructions")
|
||||
const line = url.searchParams.get("line")
|
||||
const code = url.searchParams.get("code")
|
||||
let body
|
||||
let isEditCodeWidget = false
|
||||
if (request.method === "POST") {
|
||||
body = (await request.json()) as {
|
||||
messages: unknown
|
||||
context: unknown
|
||||
activeFileContent: string
|
||||
}
|
||||
} else {
|
||||
const url = new URL(request.url)
|
||||
const fileName = url.searchParams.get("fileName") || ""
|
||||
const code = url.searchParams.get("code") || ""
|
||||
const line = url.searchParams.get("line") || ""
|
||||
const instructions = url.searchParams.get("instructions") || ""
|
||||
|
||||
const response = await env.AI.run("@cf/meta/llama-3-8b-instruct", {
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content:
|
||||
"You are an expert coding assistant. You read code from a file, and you suggest new code to add to the file. You may be given instructions on what to generate, which you should follow. You should generate code that is CORRECT, efficient, and follows best practices. You may generate multiple lines of code if necessary. When you generate code, you should ONLY return the code, and nothing else. You MUST NOT include backticks in the code you generate.",
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: `The file is called ${fileName}.`,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: `Here are my instructions on what to generate: ${instructions}.`,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: `Suggest me code to insert at line ${line} in my file. Give only the code, and NOTHING else. DO NOT include backticks in your response. My code file content is as follows
|
||||
|
||||
${code}`,
|
||||
},
|
||||
],
|
||||
})
|
||||
body = {
|
||||
messages: [{ role: "human", content: instructions }],
|
||||
context: `File: ${fileName}\nLine: ${line}\nCode:\n${code}`,
|
||||
activeFileContent: code,
|
||||
}
|
||||
isEditCodeWidget = true
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify(response))
|
||||
const messages = body.messages
|
||||
const context = body.context
|
||||
const activeFileContent = body.activeFileContent
|
||||
|
||||
if (!Array.isArray(messages) || messages.length === 0) {
|
||||
return new Response("Invalid or empty messages", { status: 400 })
|
||||
}
|
||||
|
||||
let systemMessage
|
||||
if (isEditCodeWidget) {
|
||||
systemMessage = `You are an AI code editor. Your task is to modify the given code based on the user's instructions. Only output the modified code, without any explanations or markdown formatting. The code should be a direct replacement for the existing code.
|
||||
|
||||
Context:
|
||||
${context}
|
||||
|
||||
Active File Content:
|
||||
${activeFileContent}
|
||||
|
||||
Instructions: ${messages[0].content}
|
||||
|
||||
Respond only with the modified code that can directly replace the existing code.`
|
||||
} else {
|
||||
systemMessage = `You are an intelligent programming assistant. Please respond to the following request concisely. If your response includes code, please format it using triple backticks (\`\`\`) with the appropriate language identifier. For example:
|
||||
|
||||
\`\`\`python
|
||||
print("Hello, World!")
|
||||
\`\`\`
|
||||
|
||||
Provide a clear and concise explanation along with any code snippets. Keep your response brief and to the point.
|
||||
|
||||
${context ? `Context:\n${context}\n` : ""}
|
||||
${activeFileContent ? `Active File Content:\n${activeFileContent}\n` : ""}`
|
||||
}
|
||||
|
||||
const anthropicMessages = messages.map((msg) => ({
|
||||
role: msg.role === "human" ? "user" : "assistant",
|
||||
content: msg.content,
|
||||
})) as MessageParam[]
|
||||
|
||||
try {
|
||||
const anthropic = new Anthropic({ apiKey: env.ANTHROPIC_API_KEY })
|
||||
|
||||
const stream = await anthropic.messages.create({
|
||||
model: "claude-3-5-sonnet-20240620",
|
||||
max_tokens: 1024,
|
||||
system: systemMessage,
|
||||
messages: anthropicMessages,
|
||||
stream: true,
|
||||
})
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
const streamResponse = new ReadableStream({
|
||||
async start(controller) {
|
||||
for await (const chunk of stream) {
|
||||
if (
|
||||
chunk.type === "content_block_delta" &&
|
||||
chunk.delta.type === "text_delta"
|
||||
) {
|
||||
const bytes = encoder.encode(chunk.delta.text)
|
||||
controller.enqueue(bytes)
|
||||
}
|
||||
}
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(streamResponse, {
|
||||
headers: {
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Error:", error)
|
||||
return new Response("Internal Server Error", { status: 500 })
|
||||
}
|
||||
},
|
||||
} satisfies ExportedHandler<Env>
|
||||
}
|
||||
|
@ -1,25 +1,30 @@
|
||||
// test/index.spec.ts
|
||||
import { env, createExecutionContext, waitOnExecutionContext, SELF } from 'cloudflare:test';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import worker from '../src/index';
|
||||
import {
|
||||
createExecutionContext,
|
||||
env,
|
||||
SELF,
|
||||
waitOnExecutionContext,
|
||||
} from "cloudflare:test"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import worker from "../src/index"
|
||||
|
||||
// For now, you'll need to do something like this to get a correctly-typed
|
||||
// `Request` to pass to `worker.fetch()`.
|
||||
const IncomingRequest = Request<unknown, IncomingRequestCfProperties>;
|
||||
const IncomingRequest = Request<unknown, IncomingRequestCfProperties>
|
||||
|
||||
describe('Hello World worker', () => {
|
||||
it('responds with Hello World! (unit style)', async () => {
|
||||
const request = new IncomingRequest('http://example.com');
|
||||
describe("Hello World worker", () => {
|
||||
it("responds with Hello World! (unit style)", async () => {
|
||||
const request = new IncomingRequest("http://example.com")
|
||||
// Create an empty context to pass to `worker.fetch()`.
|
||||
const ctx = createExecutionContext();
|
||||
const response = await worker.fetch(request, env, ctx);
|
||||
const ctx = createExecutionContext()
|
||||
const response = await worker.fetch(request, env, ctx)
|
||||
// Wait for all `Promise`s passed to `ctx.waitUntil()` to settle before running test assertions
|
||||
await waitOnExecutionContext(ctx);
|
||||
expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`);
|
||||
});
|
||||
await waitOnExecutionContext(ctx)
|
||||
expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`)
|
||||
})
|
||||
|
||||
it('responds with Hello World! (integration style)', async () => {
|
||||
const response = await SELF.fetch('https://example.com');
|
||||
expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`);
|
||||
});
|
||||
});
|
||||
it("responds with Hello World! (integration style)", async () => {
|
||||
const response = await SELF.fetch("https://example.com")
|
||||
expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`)
|
||||
})
|
||||
})
|
||||
|
@ -1,11 +1,11 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": [
|
||||
"@cloudflare/workers-types/experimental",
|
||||
"@cloudflare/vitest-pool-workers"
|
||||
]
|
||||
},
|
||||
"include": ["./**/*.ts", "../src/env.d.ts"],
|
||||
"exclude": []
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": [
|
||||
"@cloudflare/workers-types/experimental",
|
||||
"@cloudflare/vitest-pool-workers"
|
||||
]
|
||||
},
|
||||
"include": ["./**/*.ts", "../src/env.d.ts"],
|
||||
"exclude": []
|
||||
}
|
||||
|
@ -12,7 +12,9 @@
|
||||
|
||||
/* Language and Environment */
|
||||
"target": "es2021" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
|
||||
"lib": ["es2021"] /* Specify a set of bundled library declaration files that describe the target runtime environment. */,
|
||||
"lib": [
|
||||
"es2021"
|
||||
] /* Specify a set of bundled library declaration files that describe the target runtime environment. */,
|
||||
"jsx": "react" /* Specify what JSX code is generated. */,
|
||||
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
|
||||
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
|
||||
|
@ -1,11 +1,11 @@
|
||||
import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config";
|
||||
import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config"
|
||||
|
||||
export default defineWorkersConfig({
|
||||
test: {
|
||||
poolOptions: {
|
||||
workers: {
|
||||
wrangler: { configPath: "./wrangler.toml" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
test: {
|
||||
poolOptions: {
|
||||
workers: {
|
||||
wrangler: { configPath: "./wrangler.toml" },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
3
backend/ai/worker-configuration.d.ts
vendored
3
backend/ai/worker-configuration.d.ts
vendored
@ -1,4 +1,3 @@
|
||||
// Generated by Wrangler
|
||||
// After adding bindings to `wrangler.toml`, regenerate this interface via `npm run cf-typegen`
|
||||
interface Env {
|
||||
}
|
||||
interface Env {}
|
||||
|
@ -5,3 +5,6 @@ compatibility_flags = ["nodejs_compat"]
|
||||
|
||||
[ai]
|
||||
binding = "AI"
|
||||
|
||||
[vars]
|
||||
ANTHROPIC_API_KEY = ""
|
||||
|
@ -1,5 +0,0 @@
|
||||
{
|
||||
"tabWidth": 2,
|
||||
"semi": false,
|
||||
"singleQuote": false
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import type { Config } from "drizzle-kit";
|
||||
import type { Config } from "drizzle-kit"
|
||||
|
||||
export default process.env.LOCAL_DB_PATH
|
||||
? ({
|
||||
@ -16,4 +16,4 @@ export default process.env.LOCAL_DB_PATH
|
||||
wranglerConfigPath: "wrangler.toml",
|
||||
dbName: "d1-sandbox",
|
||||
},
|
||||
} satisfies Config);
|
||||
} satisfies Config)
|
||||
|
@ -1,7 +1,7 @@
|
||||
{
|
||||
"version": "5",
|
||||
"dialect": "sqlite",
|
||||
"id": "6570ba20-a672-400c-8147-7ba533784918",
|
||||
"id": "afe10bff-362b-402c-bdb5-038341692f35",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"tables": {
|
||||
"sandbox": {
|
||||
@ -35,12 +35,36 @@
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "CURRENT_TIMESTAMP"
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"likeCount": {
|
||||
"name": "likeCount",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"viewCount": {
|
||||
"name": "viewCount",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
@ -93,6 +117,43 @@
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"avatarUrl": {
|
||||
"name": "avatarUrl",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "CURRENT_TIMESTAMP"
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"generations": {
|
||||
"name": "generations",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
@ -102,6 +163,13 @@
|
||||
"id"
|
||||
],
|
||||
"isUnique": true
|
||||
},
|
||||
"user_username_unique": {
|
||||
"name": "user_username_unique",
|
||||
"columns": [
|
||||
"username"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
@ -124,6 +192,13 @@
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sharedOn": {
|
||||
"name": "sharedOn",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
|
@ -1,8 +1,8 @@
|
||||
{
|
||||
"version": "5",
|
||||
"dialect": "sqlite",
|
||||
"id": "9f64104a-4954-40c0-8155-17755ea0a243",
|
||||
"prevId": "6570ba20-a672-400c-8147-7ba533784918",
|
||||
"id": "e570d5ac-700d-4e62-8a46-482b21ae1fe1",
|
||||
"prevId": "afe10bff-362b-402c-bdb5-038341692f35",
|
||||
"tables": {
|
||||
"sandbox": {
|
||||
"name": "sandbox",
|
||||
@ -35,12 +35,36 @@
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "CURRENT_TIMESTAMP"
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"likeCount": {
|
||||
"name": "likeCount",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"viewCount": {
|
||||
"name": "viewCount",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
@ -94,12 +118,35 @@
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"avatarUrl": {
|
||||
"name": "avatarUrl",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "CURRENT_TIMESTAMP"
|
||||
},
|
||||
"generations": {
|
||||
"name": "generations",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
@ -109,6 +156,13 @@
|
||||
"id"
|
||||
],
|
||||
"isUnique": true
|
||||
},
|
||||
"user_username_unique": {
|
||||
"name": "user_username_unique",
|
||||
"columns": [
|
||||
"username"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
@ -131,6 +185,13 @@
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sharedOn": {
|
||||
"name": "sharedOn",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
|
@ -1,168 +0,0 @@
|
||||
{
|
||||
"version": "5",
|
||||
"dialect": "sqlite",
|
||||
"id": "5baf10d6-7697-42ba-a11a-ee4c7bd7e91e",
|
||||
"prevId": "9f64104a-4954-40c0-8155-17755ea0a243",
|
||||
"tables": {
|
||||
"sandbox": {
|
||||
"name": "sandbox",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"visibility": {
|
||||
"name": "visibility",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"sandbox_id_unique": {
|
||||
"name": "sandbox_id_unique",
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"sandbox_user_id_user_id_fk": {
|
||||
"name": "sandbox_user_id_user_id_fk",
|
||||
"tableFrom": "sandbox",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"user": {
|
||||
"name": "user",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"user_id_unique": {
|
||||
"name": "user_id_unique",
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"users_to_sandboxes": {
|
||||
"name": "users_to_sandboxes",
|
||||
"columns": {
|
||||
"userId": {
|
||||
"name": "userId",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sandboxId": {
|
||||
"name": "sandboxId",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"users_to_sandboxes_userId_user_id_fk": {
|
||||
"name": "users_to_sandboxes_userId_user_id_fk",
|
||||
"tableFrom": "users_to_sandboxes",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"userId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"users_to_sandboxes_sandboxId_sandbox_id_fk": {
|
||||
"name": "users_to_sandboxes_sandboxId_sandbox_id_fk",
|
||||
"tableFrom": "users_to_sandboxes",
|
||||
"tableTo": "sandbox",
|
||||
"columnsFrom": [
|
||||
"sandboxId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
}
|
||||
}
|
@ -1,175 +0,0 @@
|
||||
{
|
||||
"version": "5",
|
||||
"dialect": "sqlite",
|
||||
"id": "37e38b82-1494-4818-8c26-b9024cce3fa9",
|
||||
"prevId": "5baf10d6-7697-42ba-a11a-ee4c7bd7e91e",
|
||||
"tables": {
|
||||
"sandbox": {
|
||||
"name": "sandbox",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"visibility": {
|
||||
"name": "visibility",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"sandbox_id_unique": {
|
||||
"name": "sandbox_id_unique",
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"sandbox_user_id_user_id_fk": {
|
||||
"name": "sandbox_user_id_user_id_fk",
|
||||
"tableFrom": "sandbox",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"user": {
|
||||
"name": "user",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"user_id_unique": {
|
||||
"name": "user_id_unique",
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"users_to_sandboxes": {
|
||||
"name": "users_to_sandboxes",
|
||||
"columns": {
|
||||
"userId": {
|
||||
"name": "userId",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sandboxId": {
|
||||
"name": "sandboxId",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"users_to_sandboxes_userId_user_id_fk": {
|
||||
"name": "users_to_sandboxes_userId_user_id_fk",
|
||||
"tableFrom": "users_to_sandboxes",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"userId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"users_to_sandboxes_sandboxId_sandbox_id_fk": {
|
||||
"name": "users_to_sandboxes_sandboxId_sandbox_id_fk",
|
||||
"tableFrom": "users_to_sandboxes",
|
||||
"tableTo": "sandbox",
|
||||
"columnsFrom": [
|
||||
"sandboxId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
}
|
||||
}
|
@ -5,50 +5,29 @@
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "5",
|
||||
"when": 1714540200800,
|
||||
"tag": "0000_big_rogue",
|
||||
"when": 1731288423588,
|
||||
"tag": "0000_cuddly_patriot",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "5",
|
||||
"when": 1714541190588,
|
||||
"tag": "0001_empty_black_knight",
|
||||
"when": 1731290863632,
|
||||
"tag": "0001_opposite_newton_destine",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "5",
|
||||
"when": 1714541209173,
|
||||
"tag": "0002_sour_ego",
|
||||
"when": 1731296235880,
|
||||
"tag": "0002_rainy_fantastic_four",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "5",
|
||||
"when": 1714541233589,
|
||||
"tag": "0003_pale_overlord",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "5",
|
||||
"when": 1714565073180,
|
||||
"tag": "0004_cuddly_wolf_cub",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "5",
|
||||
"when": 1714950365718,
|
||||
"tag": "0005_last_the_twelve",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "5",
|
||||
"when": 1716432225404,
|
||||
"tag": "0006_lively_mattie_franklin",
|
||||
"when": 1731297339306,
|
||||
"tag": "0003_lying_snowbird",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
9950
backend/database/package-lock.json
generated
9950
backend/database/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,32 +1,32 @@
|
||||
{
|
||||
"name": "database",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"deploy": "wrangler deploy",
|
||||
"dev": "wrangler dev",
|
||||
"start": "wrangler dev",
|
||||
"test": "vitest",
|
||||
"generate": "drizzle-kit generate:sqlite --schema=src/schema.ts",
|
||||
"up": "drizzle-kit up:sqlite --schema=src/schema.ts",
|
||||
"db:studio": "cross-env LOCAL_DB_PATH=$(find .wrangler/state/v3/d1/miniflare-D1DatabaseObject -type f -name '*.sqlite' -print -quit) drizzle-kit studio"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/vitest-pool-workers": "^0.1.0",
|
||||
"@cloudflare/workers-types": "^4.20240405.0",
|
||||
"@types/itty-router-extras": "^0.4.3",
|
||||
"drizzle-kit": "^0.20.17",
|
||||
"typescript": "^5.0.4",
|
||||
"vitest": "1.3.0",
|
||||
"wrangler": "^3.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"better-sqlite3": "^9.5.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"drizzle-orm": "^0.30.8",
|
||||
"itty-router": "^5.0.16",
|
||||
"itty-router-extras": "^0.4.6",
|
||||
"zod": "^3.22.4"
|
||||
}
|
||||
"name": "database",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"deploy": "wrangler deploy",
|
||||
"dev": "wrangler dev",
|
||||
"start": "wrangler dev",
|
||||
"test": "vitest",
|
||||
"generate": "drizzle-kit generate:sqlite --schema=src/schema.ts",
|
||||
"up": "drizzle-kit up:sqlite --schema=src/schema.ts",
|
||||
"db:studio": "cross-env LOCAL_DB_PATH=$(find .wrangler/state/v3/d1/miniflare-D1DatabaseObject -type f -name '*.sqlite' -print -quit) drizzle-kit studio"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/vitest-pool-workers": "^0.1.0",
|
||||
"@cloudflare/workers-types": "^4.20240405.0",
|
||||
"@types/itty-router-extras": "^0.4.3",
|
||||
"drizzle-kit": "^0.20.17",
|
||||
"typescript": "^5.0.4",
|
||||
"vitest": "1.3.0",
|
||||
"wrangler": "^3.86.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"better-sqlite3": "^9.5.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"drizzle-orm": "^0.30.8",
|
||||
"itty-router": "^5.0.16",
|
||||
"itty-router-extras": "^0.4.6",
|
||||
"zod": "^3.22.4"
|
||||
}
|
||||
}
|
||||
|
@ -1,11 +1,11 @@
|
||||
// import type { DrizzleD1Database } from "drizzle-orm/d1";
|
||||
import { drizzle } from "drizzle-orm/d1"
|
||||
import { json } from "itty-router-extras"
|
||||
import { ZodError, z } from "zod"
|
||||
import { z } from "zod"
|
||||
|
||||
import { user, sandbox, usersToSandboxes } from "./schema"
|
||||
import * as schema from "./schema"
|
||||
import { and, eq, sql } from "drizzle-orm"
|
||||
import * as schema from "./schema"
|
||||
import { sandbox, user, usersToSandboxes } from "./schema"
|
||||
|
||||
export interface Env {
|
||||
DB: D1Database
|
||||
@ -169,6 +169,7 @@ export default {
|
||||
name: sb.name,
|
||||
type: sb.type,
|
||||
author: sb.author.name,
|
||||
authorAvatarUrl: sb.author.avatarUrl,
|
||||
sharedOn: r.sharedOn,
|
||||
}
|
||||
})
|
||||
@ -282,14 +283,26 @@ export default {
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
email: z.string().email(),
|
||||
username: z.string(),
|
||||
avatarUrl: z.string().optional(),
|
||||
createdAt: z.string().optional(),
|
||||
generations: z.number().optional(),
|
||||
})
|
||||
|
||||
const body = await request.json()
|
||||
const { id, name, email } = userSchema.parse(body)
|
||||
const { id, name, email, username, avatarUrl, createdAt, generations } = userSchema.parse(body)
|
||||
|
||||
const res = await db
|
||||
.insert(user)
|
||||
.values({ id, name, email })
|
||||
.values({
|
||||
id,
|
||||
name,
|
||||
email,
|
||||
username,
|
||||
avatarUrl,
|
||||
createdAt: createdAt ? new Date(createdAt) : new Date(),
|
||||
generations,
|
||||
})
|
||||
.returning()
|
||||
.get()
|
||||
return json({ res })
|
||||
@ -303,6 +316,20 @@ export default {
|
||||
} else {
|
||||
return methodNotAllowed
|
||||
}
|
||||
} else if (path === "/api/user/check-username") {
|
||||
if (method === "GET") {
|
||||
const params = url.searchParams
|
||||
const username = params.get("username")
|
||||
|
||||
if (!username) return invalidRequest
|
||||
|
||||
const exists = await db.query.user.findFirst({
|
||||
where: (user, { eq }) => eq(user.username, username)
|
||||
})
|
||||
|
||||
return json({ exists: !!exists })
|
||||
}
|
||||
return methodNotAllowed
|
||||
} else return notFound
|
||||
},
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { relations, sql } from "drizzle-orm";
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
import { relations } from "drizzle-orm"
|
||||
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"
|
||||
import { sql } from "drizzle-orm"
|
||||
|
||||
export const user = sqliteTable("user", {
|
||||
id: text("id")
|
||||
@ -9,16 +10,19 @@ export const user = sqliteTable("user", {
|
||||
.unique(),
|
||||
name: text("name").notNull(),
|
||||
email: text("email").notNull(),
|
||||
image: text("image"),
|
||||
username: text("username").notNull().unique(),
|
||||
avatarUrl: text("avatarUrl"),
|
||||
createdAt: integer("createdAt", { mode: "timestamp_ms" })
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
generations: integer("generations").default(0),
|
||||
});
|
||||
})
|
||||
|
||||
export type User = typeof user.$inferSelect;
|
||||
export type User = typeof user.$inferSelect
|
||||
|
||||
export const userRelations = relations(user, ({ many }) => ({
|
||||
sandbox: many(sandbox),
|
||||
usersToSandboxes: many(usersToSandboxes),
|
||||
}));
|
||||
}))
|
||||
|
||||
export const sandbox = sqliteTable("sandbox", {
|
||||
id: text("id")
|
||||
@ -28,13 +32,16 @@ export const sandbox = sqliteTable("sandbox", {
|
||||
name: text("name").notNull(),
|
||||
type: text("type").notNull(),
|
||||
visibility: text("visibility", { enum: ["public", "private"] }),
|
||||
createdAt: integer("createdAt", { mode: "timestamp_ms" }),
|
||||
createdAt: integer("createdAt", { mode: "timestamp_ms" })
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id),
|
||||
});
|
||||
likeCount: integer("likeCount").default(0),
|
||||
viewCount: integer("viewCount").default(0),
|
||||
})
|
||||
|
||||
export type Sandbox = typeof sandbox.$inferSelect;
|
||||
export type Sandbox = typeof sandbox.$inferSelect
|
||||
|
||||
export const sandboxRelations = relations(sandbox, ({ one, many }) => ({
|
||||
author: one(user, {
|
||||
@ -42,7 +49,7 @@ export const sandboxRelations = relations(sandbox, ({ one, many }) => ({
|
||||
references: [user.id],
|
||||
}),
|
||||
usersToSandboxes: many(usersToSandboxes),
|
||||
}));
|
||||
}))
|
||||
|
||||
export const usersToSandboxes = sqliteTable("users_to_sandboxes", {
|
||||
userId: text("userId")
|
||||
@ -52,15 +59,18 @@ export const usersToSandboxes = sqliteTable("users_to_sandboxes", {
|
||||
.notNull()
|
||||
.references(() => sandbox.id),
|
||||
sharedOn: integer("sharedOn", { mode: "timestamp_ms" }),
|
||||
});
|
||||
})
|
||||
|
||||
export const usersToSandboxesRelations = relations(usersToSandboxes, ({ one }) => ({
|
||||
group: one(sandbox, {
|
||||
fields: [usersToSandboxes.sandboxId],
|
||||
references: [sandbox.id],
|
||||
}),
|
||||
user: one(user, {
|
||||
fields: [usersToSandboxes.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
export const usersToSandboxesRelations = relations(
|
||||
usersToSandboxes,
|
||||
({ one }) => ({
|
||||
group: one(sandbox, {
|
||||
fields: [usersToSandboxes.sandboxId],
|
||||
references: [sandbox.id],
|
||||
}),
|
||||
user: one(user, {
|
||||
fields: [usersToSandboxes.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
@ -1,25 +1,30 @@
|
||||
// test/index.spec.ts
|
||||
import { env, createExecutionContext, waitOnExecutionContext, SELF } from "cloudflare:test";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import worker from "../src/index";
|
||||
import {
|
||||
createExecutionContext,
|
||||
env,
|
||||
SELF,
|
||||
waitOnExecutionContext,
|
||||
} from "cloudflare:test"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import worker from "../src/index"
|
||||
|
||||
// For now, you'll need to do something like this to get a correctly-typed
|
||||
// `Request` to pass to `worker.fetch()`.
|
||||
const IncomingRequest = Request<unknown, IncomingRequestCfProperties>;
|
||||
const IncomingRequest = Request<unknown, IncomingRequestCfProperties>
|
||||
|
||||
describe("Hello World worker", () => {
|
||||
it("responds with Hello World! (unit style)", async () => {
|
||||
const request = new IncomingRequest("http://example.com");
|
||||
// Create an empty context to pass to `worker.fetch()`.
|
||||
const ctx = createExecutionContext();
|
||||
const response = await worker.fetch(request, env, ctx);
|
||||
// Wait for all `Promise`s passed to `ctx.waitUntil()` to settle before running test assertions
|
||||
await waitOnExecutionContext(ctx);
|
||||
expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`);
|
||||
});
|
||||
it("responds with Hello World! (unit style)", async () => {
|
||||
const request = new IncomingRequest("http://example.com")
|
||||
// Create an empty context to pass to `worker.fetch()`.
|
||||
const ctx = createExecutionContext()
|
||||
const response = await worker.fetch(request, env, ctx)
|
||||
// Wait for all `Promise`s passed to `ctx.waitUntil()` to settle before running test assertions
|
||||
await waitOnExecutionContext(ctx)
|
||||
expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`)
|
||||
})
|
||||
|
||||
it("responds with Hello World! (integration style)", async () => {
|
||||
const response = await SELF.fetch("https://example.com");
|
||||
expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`);
|
||||
});
|
||||
});
|
||||
it("responds with Hello World! (integration style)", async () => {
|
||||
const response = await SELF.fetch("https://example.com")
|
||||
expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`)
|
||||
})
|
||||
})
|
||||
|
@ -1,11 +1,11 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": [
|
||||
"@cloudflare/workers-types/experimental",
|
||||
"@cloudflare/vitest-pool-workers"
|
||||
]
|
||||
},
|
||||
"include": ["./**/*.ts", "../src/env.d.ts"],
|
||||
"exclude": []
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": [
|
||||
"@cloudflare/workers-types/experimental",
|
||||
"@cloudflare/vitest-pool-workers"
|
||||
]
|
||||
},
|
||||
"include": ["./**/*.ts", "../src/env.d.ts"],
|
||||
"exclude": []
|
||||
}
|
||||
|
@ -12,7 +12,9 @@
|
||||
|
||||
/* Language and Environment */
|
||||
"target": "es2021" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
|
||||
"lib": ["es2021"] /* Specify a set of bundled library declaration files that describe the target runtime environment. */,
|
||||
"lib": [
|
||||
"es2021"
|
||||
] /* Specify a set of bundled library declaration files that describe the target runtime environment. */,
|
||||
"jsx": "react" /* Specify what JSX code is generated. */,
|
||||
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
|
||||
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
|
||||
|
@ -1,11 +1,11 @@
|
||||
import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config";
|
||||
import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config"
|
||||
|
||||
export default defineWorkersConfig({
|
||||
test: {
|
||||
poolOptions: {
|
||||
workers: {
|
||||
wrangler: { configPath: "./wrangler.toml" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
test: {
|
||||
poolOptions: {
|
||||
workers: {
|
||||
wrangler: { configPath: "./wrangler.toml" },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
@ -7,6 +7,7 @@ PORT=4000
|
||||
WORKERS_KEY=
|
||||
DATABASE_WORKER_URL=
|
||||
STORAGE_WORKER_URL=
|
||||
AI_WORKER_URL=
|
||||
E2B_API_KEY=
|
||||
DOKKU_HOST=
|
||||
DOKKU_USERNAME=
|
||||
|
@ -1,5 +1,7 @@
|
||||
{
|
||||
"watch": ["src"],
|
||||
"watch": [
|
||||
"src"
|
||||
],
|
||||
"ext": "ts",
|
||||
"exec": "concurrently \"npx tsc --watch\" \"ts-node src/index.ts\""
|
||||
}
|
106
backend/server/package-lock.json
generated
106
backend/server/package-lock.json
generated
@ -12,7 +12,7 @@
|
||||
"concurrently": "^8.2.2",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"e2b": "^0.16.1",
|
||||
"e2b": "^0.16.2-beta.47",
|
||||
"express": "^4.19.2",
|
||||
"rate-limiter-flexible": "^5.0.3",
|
||||
"simple-git": "^3.25.0",
|
||||
@ -41,6 +41,28 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@bufbuild/protobuf": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.0.tgz",
|
||||
"integrity": "sha512-QDdVFLoN93Zjg36NoQPZfsVH9tZew7wKDKyV5qRdj8ntT4wQCOradQjRaTdwMhWUYsgKsvCINKKm87FdEk96Ag=="
|
||||
},
|
||||
"node_modules/@connectrpc/connect": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@connectrpc/connect/-/connect-1.4.0.tgz",
|
||||
"integrity": "sha512-vZeOkKaAjyV4+RH3+rJZIfDFJAfr+7fyYr6sLDKbYX3uuTVszhFe9/YKf5DNqrDb5cKdKVlYkGn6DTDqMitAnA==",
|
||||
"peerDependencies": {
|
||||
"@bufbuild/protobuf": "^1.4.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@connectrpc/connect-web": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@connectrpc/connect-web/-/connect-web-1.4.0.tgz",
|
||||
"integrity": "sha512-13aO4psFbbm7rdOFGV0De2Za64DY/acMspgloDlcOKzLPPs0yZkhp1OOzAQeiAIr7BM/VOHIA3p8mF0inxCYTA==",
|
||||
"peerDependencies": {
|
||||
"@bufbuild/protobuf": "^1.4.2",
|
||||
"@connectrpc/connect": "1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@cspotcode/source-map-support": {
|
||||
"version": "0.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
|
||||
@ -443,6 +465,7 @@
|
||||
"integrity": "sha512-4T53u4PdgsXqKaIctwF8ifXlRTTmEPJ8iEPWFdGZvcf7sbwYo6FKFEX9eNNAnzFZ7EzJAQ3CJeOtCRA4rDp7Pw==",
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"node-gyp-build": "^4.3.0"
|
||||
},
|
||||
@ -564,6 +587,11 @@
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
|
||||
},
|
||||
"node_modules/compare-versions": {
|
||||
"version": "6.1.1",
|
||||
"resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.1.tgz",
|
||||
"integrity": "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg=="
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
@ -737,23 +765,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/e2b": {
|
||||
"version": "0.16.2",
|
||||
"resolved": "https://registry.npmjs.org/e2b/-/e2b-0.16.2.tgz",
|
||||
"integrity": "sha512-xKmVK4ipgVQPJ/uyyrfH9LnaawERRWt8U2UZhdhGfzdL/QU/OpBjuhoIbFCv1Uy6qXV4nIiJ6Nw4MBC4HmXf1g==",
|
||||
"version": "0.16.2-beta.47",
|
||||
"resolved": "https://registry.npmjs.org/e2b/-/e2b-0.16.2-beta.47.tgz",
|
||||
"integrity": "sha512-tMPDYLMD+8+JyLPrsWft3NHBhK5YKOFOXzKMwpOKR5KvXOkd1silkArDwplmBUzN/eG/uRzWdtHZs9mHUQ5b9g==",
|
||||
"dependencies": {
|
||||
"isomorphic-ws": "^5.0.0",
|
||||
"normalize-path": "^3.0.0",
|
||||
"openapi-typescript-fetch": "^1.1.3",
|
||||
"path-browserify": "^1.0.1",
|
||||
"platform": "^1.3.6",
|
||||
"ws": "^8.15.1"
|
||||
"@bufbuild/protobuf": "^1.10.0",
|
||||
"@connectrpc/connect": "^1.4.0",
|
||||
"@connectrpc/connect-web": "^1.4.0",
|
||||
"compare-versions": "^6.1.0",
|
||||
"openapi-fetch": "^0.9.7",
|
||||
"platform": "^1.3.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"bufferutil": "^4.0.8",
|
||||
"utf-8-validate": "^6.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
@ -1195,14 +1219,6 @@
|
||||
"node": ">=0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/isomorphic-ws": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-5.0.0.tgz",
|
||||
"integrity": "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==",
|
||||
"peerDependencies": {
|
||||
"ws": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
@ -1301,6 +1317,7 @@
|
||||
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.1.tgz",
|
||||
"integrity": "sha512-OSs33Z9yWr148JZcbZd5WiAXhh/n9z8TxQcdMhIOlpN9AhWpLfvVFO73+m77bBABQMaY9XSvIa+qk0jlI7Gcaw==",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"node-gyp-build": "bin.js",
|
||||
"node-gyp-build-optional": "optional.js",
|
||||
@ -1383,6 +1400,7 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@ -1417,15 +1435,19 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/openapi-typescript-fetch": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/openapi-typescript-fetch/-/openapi-typescript-fetch-1.1.3.tgz",
|
||||
"integrity": "sha512-smLZPck4OkKMNExcw8jMgrMOGgVGx2N/s6DbKL2ftNl77g5HfoGpZGFy79RBzU/EkaO0OZpwBnslfdBfh7ZcWg==",
|
||||
"engines": {
|
||||
"node": ">= 12.0.0",
|
||||
"npm": ">= 7.0.0"
|
||||
"node_modules/openapi-fetch": {
|
||||
"version": "0.9.8",
|
||||
"resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.9.8.tgz",
|
||||
"integrity": "sha512-zM6elH0EZStD/gSiNlcPrzXcVQ/pZo3BDvC6CDwRDUt1dDzxlshpmQnpD6cZaJ39THaSmwVCxxRrPKNM1hHrDg==",
|
||||
"dependencies": {
|
||||
"openapi-typescript-helpers": "^0.0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/openapi-typescript-helpers": {
|
||||
"version": "0.0.8",
|
||||
"resolved": "https://registry.npmjs.org/openapi-typescript-helpers/-/openapi-typescript-helpers-0.0.8.tgz",
|
||||
"integrity": "sha512-1eNjQtbfNi5Z/kFhagDIaIRj6qqDzhjNJKz8cmMW0CVdGwT6e1GLbAfgI0d28VTJa1A8jz82jm/4dG8qNoNS8g=="
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
@ -1434,11 +1456,6 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-browserify": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
|
||||
"integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "0.1.7",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz",
|
||||
@ -2053,6 +2070,7 @@
|
||||
"integrity": "sha512-xu9GQDeFp+eZ6LnCywXN/zBancWvOpUMzgjLPSjy4BRHSmTelvn2E0DG0o1sTiw5hkCKBHo8rwSKncfRfv2EEQ==",
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"node-gyp-build": "^4.3.0"
|
||||
},
|
||||
@ -2098,26 +2116,6 @@
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
|
||||
"integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
|
@ -14,7 +14,7 @@
|
||||
"concurrently": "^8.2.2",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"e2b": "^0.16.1",
|
||||
"e2b": "^0.16.2-beta.47",
|
||||
"express": "^4.19.2",
|
||||
"rate-limiter-flexible": "^5.0.3",
|
||||
"simple-git": "^3.25.0",
|
||||
@ -31,4 +31,4 @@
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
}
|
||||
}
|
90
backend/server/src/AIWorker.ts
Normal file
90
backend/server/src/AIWorker.ts
Normal file
@ -0,0 +1,90 @@
|
||||
// AIWorker class for handling AI-related operations
|
||||
export class AIWorker {
|
||||
private aiWorkerUrl: string
|
||||
private cfAiKey: string
|
||||
private databaseWorkerUrl: string
|
||||
private workersKey: string
|
||||
|
||||
// Constructor to initialize AIWorker with necessary URLs and keys
|
||||
constructor(
|
||||
aiWorkerUrl: string,
|
||||
cfAiKey: string,
|
||||
databaseWorkerUrl: string,
|
||||
workersKey: string
|
||||
) {
|
||||
this.aiWorkerUrl = aiWorkerUrl
|
||||
this.cfAiKey = cfAiKey
|
||||
this.databaseWorkerUrl = databaseWorkerUrl
|
||||
this.workersKey = workersKey
|
||||
}
|
||||
|
||||
// Method to generate code based on user input
|
||||
async generateCode(
|
||||
userId: string,
|
||||
fileName: string,
|
||||
code: string,
|
||||
line: number,
|
||||
instructions: string
|
||||
): Promise<{ response: string; success: boolean }> {
|
||||
try {
|
||||
const fetchPromise = fetch(
|
||||
`${process.env.DATABASE_WORKER_URL}/api/sandbox/generate`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `${process.env.WORKERS_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
userId: userId,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
// Generate code from cloudflare workers AI
|
||||
const generateCodePromise = fetch(
|
||||
`${process.env.AI_WORKER_URL}/api?fileName=${encodeURIComponent(
|
||||
fileName
|
||||
)}&code=${encodeURIComponent(code)}&line=${encodeURIComponent(
|
||||
line
|
||||
)}&instructions=${encodeURIComponent(instructions)}`,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `${process.env.CF_AI_KEY}`,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const [fetchResponse, generateCodeResponse] = await Promise.all([
|
||||
fetchPromise,
|
||||
generateCodePromise,
|
||||
])
|
||||
|
||||
if (!generateCodeResponse.ok) {
|
||||
throw new Error(`HTTP error! status: ${generateCodeResponse.status}`)
|
||||
}
|
||||
|
||||
const reader = generateCodeResponse.body?.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let result = ""
|
||||
|
||||
if (reader) {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
result += decoder.decode(value, { stream: true })
|
||||
}
|
||||
}
|
||||
|
||||
// The result should now contain only the modified code
|
||||
return { response: result.trim(), success: true }
|
||||
} catch (e: any) {
|
||||
console.error("Error generating code:", e)
|
||||
return {
|
||||
response: "Error generating code. Please try again.",
|
||||
success: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
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();
|
||||
}
|
||||
|
||||
}
|
@ -1,37 +1,40 @@
|
||||
import { SSHSocketClient, SSHConfig } from "./SSHSocketClient"
|
||||
import { SSHConfig, SSHSocketClient } from "./SSHSocketClient"
|
||||
|
||||
// Interface for the response structure from Dokku commands
|
||||
export interface DokkuResponse {
|
||||
ok: boolean;
|
||||
output: string;
|
||||
ok: boolean
|
||||
output: string
|
||||
}
|
||||
|
||||
// DokkuClient class extends SSHSocketClient to interact with Dokku via SSH
|
||||
export class DokkuClient extends SSHSocketClient {
|
||||
|
||||
constructor(config: SSHConfig) {
|
||||
super(
|
||||
config,
|
||||
"/var/run/dokku-daemon/dokku-daemon.sock"
|
||||
)
|
||||
// Initialize with Dokku daemon socket path
|
||||
super(config, "/var/run/dokku-daemon/dokku-daemon.sock")
|
||||
}
|
||||
|
||||
// Send a command to Dokku and parse the response
|
||||
async sendCommand(command: string): Promise<DokkuResponse> {
|
||||
try {
|
||||
const response = await this.sendData(command);
|
||||
const response = await this.sendData(command)
|
||||
|
||||
if (typeof response !== "string") {
|
||||
throw new Error("Received data is not a string");
|
||||
throw new Error("Received data is not a string")
|
||||
}
|
||||
|
||||
return JSON.parse(response);
|
||||
// Parse the JSON response from Dokku
|
||||
return JSON.parse(response)
|
||||
} catch (error: any) {
|
||||
throw new Error(`Failed to send command: ${error.message}`);
|
||||
throw new Error(`Failed to send command: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// List all deployed Dokku apps
|
||||
async listApps(): Promise<string[]> {
|
||||
const response = await this.sendCommand("apps:list");
|
||||
return response.output.split("\n").slice(1); // Split by newline and ignore the first line (header)
|
||||
const response = await this.sendCommand("apps:list")
|
||||
// Split the output by newline and remove the header
|
||||
return response.output.split("\n").slice(1)
|
||||
}
|
||||
}
|
||||
|
||||
export { SSHConfig };
|
||||
export { SSHConfig }
|
||||
|
513
backend/server/src/FileManager.ts
Normal file
513
backend/server/src/FileManager.ts
Normal file
@ -0,0 +1,513 @@
|
||||
import { FilesystemEvent, Sandbox, WatchHandle } from "e2b"
|
||||
import path from "path"
|
||||
import RemoteFileStorage from "./RemoteFileStorage"
|
||||
import { MAX_BODY_SIZE } from "./ratelimit"
|
||||
import { TFile, TFileData, TFolder } from "./types"
|
||||
|
||||
// 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: [] }
|
||||
|
||||
paths.forEach((path) => {
|
||||
const parts = path.split("/")
|
||||
let current: TFolder = root
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i]
|
||||
const isFile = i === parts.length - 1 && part.length
|
||||
const existing = current.children.find((child) => child.name === part)
|
||||
|
||||
if (existing) {
|
||||
if (!isFile) {
|
||||
current = existing as TFolder
|
||||
}
|
||||
} else {
|
||||
if (isFile) {
|
||||
const file: TFile = { id: `/${parts.join("/")}`, type: "file", name: part }
|
||||
current.children.push(file)
|
||||
} else {
|
||||
const folder: TFolder = {
|
||||
id: `/${parts.slice(0, i + 1).join("/")}`,
|
||||
type: "folder",
|
||||
name: part,
|
||||
children: [],
|
||||
}
|
||||
current.children.push(folder)
|
||||
current = folder
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return root.children
|
||||
}
|
||||
|
||||
// FileManager class to handle file operations in a sandbox
|
||||
export class FileManager {
|
||||
private sandboxId: string
|
||||
private sandbox: Sandbox
|
||||
public files: (TFolder | TFile)[]
|
||||
public fileData: TFileData[]
|
||||
private fileWatchers: WatchHandle[] = []
|
||||
private dirName = "/home/user/project"
|
||||
private refreshFileList: ((files: (TFolder | TFile)[]) => void) | null
|
||||
|
||||
// Constructor to initialize the FileManager
|
||||
constructor(
|
||||
sandboxId: string,
|
||||
sandbox: Sandbox,
|
||||
refreshFileList: ((files: (TFolder | TFile)[]) => void) | null
|
||||
) {
|
||||
this.sandboxId = sandboxId
|
||||
this.sandbox = sandbox
|
||||
this.files = []
|
||||
this.fileData = []
|
||||
this.refreshFileList = refreshFileList
|
||||
}
|
||||
|
||||
// Fetch file data from list of paths
|
||||
private async generateFileData(paths: string[]): Promise<TFileData[]> {
|
||||
const fileData: TFileData[] = []
|
||||
|
||||
for (const path of paths) {
|
||||
const parts = path.split("/")
|
||||
const isFile = parts.length > 0 && parts[parts.length - 1].length > 0
|
||||
|
||||
if (isFile) {
|
||||
const fileId = `/${parts.join("/")}`
|
||||
const data = await RemoteFileStorage.fetchFileContent(`projects/${this.sandboxId}${fileId}`)
|
||||
fileData.push({ id: fileId, data })
|
||||
}
|
||||
}
|
||||
|
||||
return fileData
|
||||
}
|
||||
|
||||
// Convert local file path to remote path
|
||||
private getRemoteFileId(localId: string): string {
|
||||
return `projects/${this.sandboxId}${localId}`
|
||||
}
|
||||
|
||||
// Convert remote file path to local file path
|
||||
private getLocalFileId(remoteId: string): string | undefined {
|
||||
const allParts = remoteId.split("/")
|
||||
if (allParts[1] !== this.sandboxId) return undefined;
|
||||
return allParts.slice(2).join("/")
|
||||
}
|
||||
|
||||
// Convert remote file paths to local file paths
|
||||
private getLocalFileIds(remoteIds: string[]): string[] {
|
||||
return remoteIds
|
||||
.map(this.getLocalFileId.bind(this))
|
||||
.filter((id) => id !== undefined);
|
||||
}
|
||||
|
||||
// Download files from remote storage
|
||||
private async updateFileData(): Promise<TFileData[]> {
|
||||
const remotePaths = await RemoteFileStorage.getSandboxPaths(this.sandboxId)
|
||||
const localPaths = this.getLocalFileIds(remotePaths)
|
||||
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.files = generateFileStructure(localPaths)
|
||||
return this.files
|
||||
}
|
||||
|
||||
// Initialize the FileManager
|
||||
async initialize() {
|
||||
|
||||
// Download files from remote file storage
|
||||
await this.updateFileStructure()
|
||||
await this.updateFileData()
|
||||
|
||||
// Copy all files from the project to the container
|
||||
const promises = this.fileData.map(async (file) => {
|
||||
try {
|
||||
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)
|
||||
}
|
||||
await this.sandbox.files.write(filePath, file.data)
|
||||
} catch (e: any) {
|
||||
console.log("Failed to create file: " + e)
|
||||
}
|
||||
})
|
||||
await Promise.all(promises)
|
||||
|
||||
// Reload file list from the container to include template files
|
||||
const result = await this.sandbox.commands.run(`find "${this.dirName}" -type f`); // List all files recursively
|
||||
const localPaths = result.stdout.split('\n').filter(path => path); // Split the output into an array and filter out empty strings
|
||||
const relativePaths = localPaths.map(filePath => path.posix.relative(this.dirName, filePath)); // Convert absolute paths to relative paths
|
||||
this.files = generateFileStructure(relativePaths);
|
||||
|
||||
// Make the logged in user the owner of all project files
|
||||
this.fixPermissions()
|
||||
|
||||
await this.watchDirectory(this.dirName)
|
||||
await this.watchSubdirectories(this.dirName)
|
||||
}
|
||||
|
||||
// Check if the given path is a directory
|
||||
private async isDirectory(directoryPath: string): Promise<boolean> {
|
||||
try {
|
||||
const result = await this.sandbox.commands.run(
|
||||
`[ -d "${directoryPath}" ] && echo "true" || echo "false"`
|
||||
)
|
||||
return result.stdout.trim() === "true"
|
||||
} catch (e: any) {
|
||||
console.log("Failed to check if directory: " + e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Change the owner of the project directory to user
|
||||
private async fixPermissions() {
|
||||
try {
|
||||
await this.sandbox.commands.run(
|
||||
`sudo chown -R user "${this.dirName}"`
|
||||
)
|
||||
} catch (e: any) {
|
||||
console.log("Failed to fix permissions: " + e)
|
||||
}
|
||||
}
|
||||
|
||||
// Watch a directory for changes
|
||||
async watchDirectory(directory: string): Promise<WatchHandle | undefined> {
|
||||
try {
|
||||
const handle = await this.sandbox.files.watch(
|
||||
directory,
|
||||
async (event: FilesystemEvent) => {
|
||||
try {
|
||||
function removeDirName(path: string, dirName: string) {
|
||||
return path.startsWith(dirName)
|
||||
? path.slice(dirName.length)
|
||||
: path
|
||||
}
|
||||
|
||||
// This is the absolute file path in the container
|
||||
const containerFilePath = path.posix.join(directory, event.name)
|
||||
// This is the file path relative to the project directory
|
||||
const sandboxFilePath = removeDirName(containerFilePath, this.dirName)
|
||||
// This is the directory being watched relative to the project directory
|
||||
const sandboxDirectory = removeDirName(directory, this.dirName)
|
||||
|
||||
// Helper function to find a folder by id
|
||||
function findFolderById(
|
||||
files: (TFolder | TFile)[],
|
||||
folderId: string
|
||||
) {
|
||||
return files.find(
|
||||
(file: TFolder | TFile) =>
|
||||
file.type === "folder" && file.id === folderId
|
||||
)
|
||||
}
|
||||
|
||||
// Handle file/directory creation event
|
||||
if (event.type === "create") {
|
||||
const folder = findFolderById(
|
||||
this.files,
|
||||
sandboxDirectory
|
||||
) as TFolder
|
||||
const isDir = await this.isDirectory(containerFilePath)
|
||||
|
||||
const newItem = isDir
|
||||
? ({
|
||||
id: sandboxFilePath,
|
||||
name: event.name,
|
||||
type: "folder",
|
||||
children: [],
|
||||
} as TFolder)
|
||||
: ({
|
||||
id: sandboxFilePath,
|
||||
name: event.name,
|
||||
type: "file",
|
||||
} as TFile)
|
||||
|
||||
if (folder) {
|
||||
// If the folder exists, add the new item (file/folder) as a child
|
||||
folder.children.push(newItem)
|
||||
} else {
|
||||
// If folder doesn't exist, add the new item to the root
|
||||
this.files.push(newItem)
|
||||
}
|
||||
|
||||
if (!isDir) {
|
||||
const fileData = await this.sandbox.files.read(
|
||||
containerFilePath
|
||||
)
|
||||
const fileContents =
|
||||
typeof fileData === "string" ? fileData : ""
|
||||
this.fileData.push({
|
||||
id: sandboxFilePath,
|
||||
data: fileContents,
|
||||
})
|
||||
}
|
||||
|
||||
console.log(`Create ${sandboxFilePath}`)
|
||||
}
|
||||
|
||||
// Handle file/directory removal or rename event
|
||||
else if (event.type === "remove" || event.type == "rename") {
|
||||
const folder = findFolderById(
|
||||
this.files,
|
||||
sandboxDirectory
|
||||
) as TFolder
|
||||
const isDir = await this.isDirectory(containerFilePath)
|
||||
|
||||
const isFileMatch = (file: TFolder | TFile | TFileData) =>
|
||||
file.id === sandboxFilePath ||
|
||||
file.id.startsWith(containerFilePath + "/")
|
||||
|
||||
if (folder) {
|
||||
// Remove item from its parent folder
|
||||
folder.children = folder.children.filter(
|
||||
(file: TFolder | TFile) => !isFileMatch(file)
|
||||
)
|
||||
} else {
|
||||
// Remove from the root if it's not inside a folder
|
||||
this.files = this.files.filter(
|
||||
(file: TFolder | TFile) => !isFileMatch(file)
|
||||
)
|
||||
}
|
||||
|
||||
// Also remove any corresponding file data
|
||||
this.fileData = this.fileData.filter(
|
||||
(file: TFileData) => !isFileMatch(file)
|
||||
)
|
||||
|
||||
console.log(`Removed: ${sandboxFilePath}`)
|
||||
}
|
||||
|
||||
// Handle file write event
|
||||
else if (event.type === "write") {
|
||||
const folder = findFolderById(
|
||||
this.files,
|
||||
sandboxDirectory
|
||||
) as TFolder
|
||||
const fileToWrite = this.fileData.find(
|
||||
(file) => file.id === sandboxFilePath
|
||||
)
|
||||
|
||||
if (fileToWrite) {
|
||||
fileToWrite.data = await this.sandbox.files.read(
|
||||
containerFilePath
|
||||
)
|
||||
console.log(`Write to ${sandboxFilePath}`)
|
||||
} else {
|
||||
// If the file is part of a folder structure, locate it and update its data
|
||||
const fileInFolder = folder?.children.find(
|
||||
(file) => file.id === sandboxFilePath
|
||||
)
|
||||
if (fileInFolder) {
|
||||
const fileData = await this.sandbox.files.read(
|
||||
containerFilePath
|
||||
)
|
||||
const fileContents =
|
||||
typeof fileData === "string" ? fileData : ""
|
||||
this.fileData.push({
|
||||
id: sandboxFilePath,
|
||||
data: fileContents,
|
||||
})
|
||||
console.log(`Write to ${sandboxFilePath}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tell the client to reload the file list
|
||||
if (event.type !== "chmod") {
|
||||
this.refreshFileList?.(this.files)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error handling ${event.type} event for ${event.name}:`,
|
||||
error
|
||||
)
|
||||
}
|
||||
},
|
||||
{ timeout: 0 }
|
||||
)
|
||||
this.fileWatchers.push(handle)
|
||||
return handle
|
||||
} catch (error) {
|
||||
console.error(`Error watching filesystem:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
// Watch subdirectories recursively
|
||||
async watchSubdirectories(directory: string) {
|
||||
const dirContent = await this.sandbox.files.list(directory)
|
||||
await Promise.all(
|
||||
dirContent.map(async (item) => {
|
||||
if (item.type === "dir") {
|
||||
console.log("Watching " + item.path)
|
||||
await this.watchDirectory(item.path)
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Get file content
|
||||
async getFile(fileId: string): Promise<string | undefined> {
|
||||
const filePath = path.posix.join(this.dirName, fileId)
|
||||
const fileContent = await this.sandbox.files.read(filePath)
|
||||
return fileContent
|
||||
}
|
||||
|
||||
// Get folder content
|
||||
async getFolder(folderId: string): Promise<string[]> {
|
||||
const remotePaths = await RemoteFileStorage.getFolder(this.getRemoteFileId(folderId))
|
||||
return this.getLocalFileIds(remotePaths)
|
||||
}
|
||||
|
||||
// Save file content
|
||||
async saveFile(fileId: string, body: string): Promise<void> {
|
||||
if (!fileId) return // handles saving when no file is open
|
||||
|
||||
if (Buffer.byteLength(body, "utf-8") > MAX_BODY_SIZE) {
|
||||
throw new Error("File size too large. Please reduce the file size.")
|
||||
}
|
||||
await RemoteFileStorage.saveFile(this.getRemoteFileId(fileId), body)
|
||||
const file = this.fileData.find((f) => f.id === fileId)
|
||||
if (!file) return
|
||||
file.data = body
|
||||
|
||||
await this.sandbox.files.write(path.posix.join(this.dirName, file.id), body)
|
||||
this.fixPermissions()
|
||||
}
|
||||
|
||||
// Move a file to a different folder
|
||||
async moveFile(
|
||||
fileId: string,
|
||||
folderId: string
|
||||
): Promise<(TFolder | TFile)[]> {
|
||||
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()
|
||||
|
||||
await this.moveFileInContainer(fileId, newFileId)
|
||||
|
||||
await this.fixPermissions()
|
||||
|
||||
fileData.id = newFileId
|
||||
file.id = newFileId
|
||||
|
||||
await RemoteFileStorage.renameFile(this.getRemoteFileId(fileId), this.getRemoteFileId(newFileId), fileData.data)
|
||||
return this.updateFileStructure()
|
||||
}
|
||||
|
||||
// Move a file within the container
|
||||
private async moveFileInContainer(oldPath: string, newPath: string) {
|
||||
try {
|
||||
const fileContents = await this.sandbox.files.read(
|
||||
path.posix.join(this.dirName, oldPath)
|
||||
)
|
||||
await this.sandbox.files.write(
|
||||
path.posix.join(this.dirName, newPath),
|
||||
fileContents
|
||||
)
|
||||
await this.sandbox.files.remove(path.posix.join(this.dirName, oldPath))
|
||||
} catch (e) {
|
||||
console.error(`Error moving file from ${oldPath} to ${newPath}:`, e)
|
||||
}
|
||||
}
|
||||
|
||||
// Create a new file
|
||||
async createFile(name: string): Promise<boolean> {
|
||||
const size: number = await RemoteFileStorage.getProjectSize(this.sandboxId)
|
||||
if (size > 200 * 1024 * 1024) {
|
||||
throw new Error("Project size exceeded. Please delete some files.")
|
||||
}
|
||||
|
||||
const id = `/${name}`
|
||||
|
||||
await this.sandbox.files.write(path.posix.join(this.dirName, id), "")
|
||||
await this.fixPermissions()
|
||||
|
||||
this.files.push({
|
||||
id,
|
||||
name,
|
||||
type: "file",
|
||||
})
|
||||
|
||||
this.fileData.push({
|
||||
id,
|
||||
data: "",
|
||||
})
|
||||
|
||||
await RemoteFileStorage.createFile(this.getRemoteFileId(id))
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Create a new folder
|
||||
async createFolder(name: string): Promise<void> {
|
||||
const id = `/${name}`
|
||||
await this.sandbox.files.makeDir(path.posix.join(this.dirName, id))
|
||||
}
|
||||
|
||||
// Rename a file
|
||||
async renameFile(fileId: string, newName: string): Promise<void> {
|
||||
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("/")
|
||||
const newFileId = parts.slice(0, parts.length - 1).join("/") + "/" + newName
|
||||
|
||||
await this.moveFileInContainer(fileId, newFileId)
|
||||
await this.fixPermissions()
|
||||
await RemoteFileStorage.renameFile(this.getRemoteFileId(fileId), this.getRemoteFileId(newFileId), fileData.data)
|
||||
|
||||
fileData.id = newFileId
|
||||
file.id = newFileId
|
||||
}
|
||||
|
||||
// Delete a file
|
||||
async deleteFile(fileId: string): Promise<(TFolder | TFile)[]> {
|
||||
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.fileData = this.fileData.filter(
|
||||
(f) => f.id !== fileId
|
||||
)
|
||||
|
||||
await RemoteFileStorage.deleteFile(this.getRemoteFileId(fileId))
|
||||
return this.updateFileStructure()
|
||||
}
|
||||
|
||||
// Delete a folder
|
||||
async deleteFolder(folderId: string): Promise<(TFolder | TFile)[]> {
|
||||
const files = await RemoteFileStorage.getFolder(this.getRemoteFileId(folderId))
|
||||
|
||||
await Promise.all(
|
||||
files.map(async (file) => {
|
||||
await this.sandbox.files.remove(path.posix.join(this.dirName, file))
|
||||
this.fileData = this.fileData.filter(
|
||||
(f) => f.id !== file
|
||||
)
|
||||
await RemoteFileStorage.deleteFile(this.getRemoteFileId(file))
|
||||
})
|
||||
)
|
||||
|
||||
return this.updateFileStructure()
|
||||
}
|
||||
|
||||
// Close all file watchers
|
||||
async closeWatchers() {
|
||||
await Promise.all(
|
||||
this.fileWatchers.map(async (handle: WatchHandle) => {
|
||||
await handle.close()
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
117
backend/server/src/RemoteFileStorage.ts
Normal file
117
backend/server/src/RemoteFileStorage.ts
Normal file
@ -0,0 +1,117 @@
|
||||
import * as dotenv from "dotenv"
|
||||
import { R2Files } from "./types"
|
||||
|
||||
dotenv.config()
|
||||
|
||||
export const RemoteFileStorage = {
|
||||
getSandboxPaths: async (id: string) => {
|
||||
const res = await fetch(
|
||||
`${process.env.STORAGE_WORKER_URL}/api?sandboxId=${id}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `${process.env.WORKERS_KEY}`,
|
||||
},
|
||||
}
|
||||
)
|
||||
const data: R2Files = await res.json()
|
||||
|
||||
return data.objects.map((obj) => obj.key)
|
||||
},
|
||||
|
||||
getFolder: async (folderId: string) => {
|
||||
const res = await fetch(
|
||||
`${process.env.STORAGE_WORKER_URL}/api?folderId=${folderId}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `${process.env.WORKERS_KEY}`,
|
||||
},
|
||||
}
|
||||
)
|
||||
const data: R2Files = await res.json()
|
||||
|
||||
return data.objects.map((obj) => obj.key)
|
||||
},
|
||||
|
||||
fetchFileContent: async (fileId: string): Promise<string> => {
|
||||
try {
|
||||
const fileRes = await fetch(
|
||||
`${process.env.STORAGE_WORKER_URL}/api?fileId=${fileId}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `${process.env.WORKERS_KEY}`,
|
||||
},
|
||||
}
|
||||
)
|
||||
return await fileRes.text()
|
||||
} catch (error) {
|
||||
console.error("ERROR fetching file:", error)
|
||||
return ""
|
||||
}
|
||||
},
|
||||
|
||||
createFile: async (fileId: string) => {
|
||||
const res = await fetch(`${process.env.STORAGE_WORKER_URL}/api`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `${process.env.WORKERS_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({ fileId }),
|
||||
})
|
||||
return res.ok
|
||||
},
|
||||
|
||||
renameFile: async (
|
||||
fileId: string,
|
||||
newFileId: string,
|
||||
data: string
|
||||
) => {
|
||||
const res = await fetch(`${process.env.STORAGE_WORKER_URL}/api/rename`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `${process.env.WORKERS_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({ fileId, newFileId, data }),
|
||||
})
|
||||
return res.ok
|
||||
},
|
||||
|
||||
saveFile: async (fileId: string, data: string) => {
|
||||
const res = await fetch(`${process.env.STORAGE_WORKER_URL}/api/save`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `${process.env.WORKERS_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({ fileId, data }),
|
||||
})
|
||||
return res.ok
|
||||
},
|
||||
|
||||
deleteFile: async (fileId: string) => {
|
||||
const res = await fetch(`${process.env.STORAGE_WORKER_URL}/api`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `${process.env.WORKERS_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({ fileId }),
|
||||
})
|
||||
return res.ok
|
||||
},
|
||||
|
||||
getProjectSize: async (id: string) => {
|
||||
const res = await fetch(
|
||||
`${process.env.STORAGE_WORKER_URL}/api/size?sandboxId=${id}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `${process.env.WORKERS_KEY}`,
|
||||
},
|
||||
}
|
||||
)
|
||||
return (await res.json()).size
|
||||
}
|
||||
}
|
||||
|
||||
export default RemoteFileStorage
|
@ -1,90 +1,98 @@
|
||||
import { Client } from "ssh2";
|
||||
import { Client } from "ssh2"
|
||||
|
||||
// Interface defining the configuration for SSH connection
|
||||
export interface SSHConfig {
|
||||
host: string;
|
||||
port?: number;
|
||||
username: string;
|
||||
privateKey: Buffer;
|
||||
host: string
|
||||
port?: number
|
||||
username: string
|
||||
privateKey: Buffer
|
||||
}
|
||||
|
||||
// Class to handle SSH connections and communicate with a Unix socket
|
||||
export class SSHSocketClient {
|
||||
private conn: Client;
|
||||
private config: SSHConfig;
|
||||
private socketPath: string;
|
||||
private isConnected: boolean = false;
|
||||
|
||||
constructor(config: SSHConfig, socketPath: string) {
|
||||
this.conn = new Client();
|
||||
this.config = { ...config, port: 22};
|
||||
this.socketPath = socketPath;
|
||||
|
||||
this.setupTerminationHandlers();
|
||||
}
|
||||
|
||||
private setupTerminationHandlers() {
|
||||
process.on("SIGINT", this.closeConnection.bind(this));
|
||||
process.on("SIGTERM", this.closeConnection.bind(this));
|
||||
}
|
||||
|
||||
private closeConnection() {
|
||||
console.log("Closing SSH connection...");
|
||||
this.conn.end();
|
||||
this.isConnected = false;
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
connect(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.conn
|
||||
.on("ready", () => {
|
||||
console.log("SSH connection established");
|
||||
this.isConnected = true;
|
||||
resolve();
|
||||
})
|
||||
.on("error", (err) => {
|
||||
console.error("SSH connection error:", err);
|
||||
this.isConnected = false;
|
||||
reject(err);
|
||||
})
|
||||
.on("close", () => {
|
||||
console.log("SSH connection closed");
|
||||
this.isConnected = false;
|
||||
})
|
||||
.connect(this.config);
|
||||
});
|
||||
}
|
||||
|
||||
sendData(data: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.isConnected) {
|
||||
reject(new Error("SSH connection is not established"));
|
||||
return;
|
||||
}
|
||||
|
||||
this.conn.exec(
|
||||
`echo "${data}" | nc -U ${this.socketPath}`,
|
||||
(err, stream) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
stream
|
||||
.on("close", (code: number, signal: string) => {
|
||||
reject(
|
||||
new Error(
|
||||
`Stream closed with code ${code} and signal ${signal}`
|
||||
)
|
||||
);
|
||||
})
|
||||
.on("data", (data: Buffer) => {
|
||||
resolve(data.toString());
|
||||
})
|
||||
.stderr.on("data", (data: Buffer) => {
|
||||
reject(new Error(data.toString()));
|
||||
});
|
||||
private conn: Client
|
||||
private config: SSHConfig
|
||||
private socketPath: string
|
||||
private isConnected: boolean = false
|
||||
|
||||
// Constructor initializes the SSH client and sets up configuration
|
||||
constructor(config: SSHConfig, socketPath: string) {
|
||||
this.conn = new Client()
|
||||
this.config = { ...config, port: 22 } // Default port to 22 if not provided
|
||||
this.socketPath = socketPath
|
||||
|
||||
this.setupTerminationHandlers()
|
||||
}
|
||||
|
||||
// Set up handlers for graceful termination
|
||||
private setupTerminationHandlers() {
|
||||
process.on("SIGINT", this.closeConnection.bind(this))
|
||||
process.on("SIGTERM", this.closeConnection.bind(this))
|
||||
}
|
||||
|
||||
// Method to close the SSH connection
|
||||
private closeConnection() {
|
||||
console.log("Closing SSH connection...")
|
||||
this.conn.end()
|
||||
this.isConnected = false
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
// Method to establish the SSH connection
|
||||
connect(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.conn
|
||||
.on("ready", () => {
|
||||
console.log("SSH connection established")
|
||||
this.isConnected = true
|
||||
resolve()
|
||||
})
|
||||
.on("error", (err) => {
|
||||
console.error("SSH connection error:", err)
|
||||
this.isConnected = false
|
||||
reject(err)
|
||||
})
|
||||
.on("close", () => {
|
||||
console.log("SSH connection closed")
|
||||
this.isConnected = false
|
||||
})
|
||||
.connect(this.config)
|
||||
})
|
||||
}
|
||||
|
||||
// Method to send data through the SSH connection to the Unix socket
|
||||
sendData(data: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.isConnected) {
|
||||
reject(new Error("SSH connection is not established"))
|
||||
return
|
||||
}
|
||||
|
||||
// Use netcat to send data to the Unix socket
|
||||
this.conn.exec(
|
||||
`echo "${data}" | nc -U ${this.socketPath}`,
|
||||
(err, stream) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
stream
|
||||
.on("close", (code: number, signal: string) => {
|
||||
reject(
|
||||
new Error(
|
||||
`Stream closed with code ${code} and signal ${signal}`
|
||||
)
|
||||
)
|
||||
})
|
||||
.on("data", (data: Buffer) => {
|
||||
resolve(data.toString())
|
||||
})
|
||||
.stderr.on("data", (data: Buffer) => {
|
||||
reject(new Error(data.toString()))
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
262
backend/server/src/Sandbox.ts
Normal file
262
backend/server/src/Sandbox.ts
Normal file
@ -0,0 +1,262 @@
|
||||
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, TFileData, 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;
|
||||
type: string;
|
||||
fileManager: FileManager | null;
|
||||
terminalManager: TerminalManager | null;
|
||||
container: E2BSandbox | null;
|
||||
// Server context:
|
||||
dokkuClient: DokkuClient | null;
|
||||
gitClient: SecureGitClient | null;
|
||||
aiWorker: AIWorker;
|
||||
|
||||
constructor(sandboxId: string, type: string, { aiWorker, dokkuClient, gitClient }: ServerContext) {
|
||||
// Sandbox properties:
|
||||
this.sandboxId = sandboxId;
|
||||
this.type = type;
|
||||
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 template and timeout
|
||||
const templateTypes = ["vanillajs", "reactjs", "nextjs", "streamlit"];
|
||||
const template = templateTypes.includes(this.type)
|
||||
? `gitwit-${this.type}`
|
||||
: `base`;
|
||||
this.container = await E2BSandbox.create(template, {
|
||||
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)
|
||||
}
|
||||
|
||||
// Handle downloading files by download button
|
||||
const handleDownloadFiles: SocketHandler = async () => {
|
||||
if (!this.fileManager) throw Error("No file manager")
|
||||
|
||||
// Get all files with their data through fileManager
|
||||
const files = this.fileManager.fileData.map((file: TFileData) => ({
|
||||
path: file.id.startsWith('/') ? file.id.slice(1) : file.id,
|
||||
content: file.data
|
||||
}))
|
||||
|
||||
return { files }
|
||||
}
|
||||
|
||||
return {
|
||||
"heartbeat": handleHeartbeat,
|
||||
"getFile": handleGetFile,
|
||||
"downloadFiles": handleDownloadFiles,
|
||||
"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,
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
}
|
@ -1,82 +1,84 @@
|
||||
import simpleGit, { SimpleGit } from "simple-git";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import fs from "fs"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import simpleGit, { SimpleGit } from "simple-git"
|
||||
|
||||
export type FileData = {
|
||||
id: string;
|
||||
data: string;
|
||||
};
|
||||
id: string
|
||||
data: string
|
||||
}
|
||||
|
||||
export class SecureGitClient {
|
||||
private gitUrl: string;
|
||||
private sshKeyPath: string;
|
||||
private gitUrl: string
|
||||
private sshKeyPath: string
|
||||
|
||||
constructor(gitUrl: string, sshKeyPath: string) {
|
||||
this.gitUrl = gitUrl;
|
||||
this.sshKeyPath = sshKeyPath;
|
||||
this.gitUrl = gitUrl
|
||||
this.sshKeyPath = sshKeyPath
|
||||
}
|
||||
|
||||
async pushFiles(fileData: FileData[], repository: string): Promise<void> {
|
||||
let tempDir: string | undefined;
|
||||
let tempDir: string | undefined
|
||||
|
||||
try {
|
||||
// Create a temporary directory
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'git-push-'));
|
||||
console.log(`Temporary directory created: ${tempDir}`);
|
||||
tempDir = fs.mkdtempSync(path.posix.join(os.tmpdir(), "git-push-"))
|
||||
console.log(`Temporary directory created: ${tempDir}`)
|
||||
|
||||
// Write files to the temporary directory
|
||||
console.log(`Writing ${fileData.length} files.`);
|
||||
console.log(`Writing ${fileData.length} files.`)
|
||||
for (const { id, data } of fileData) {
|
||||
const filePath = path.join(tempDir, id);
|
||||
const dirPath = path.dirname(filePath);
|
||||
|
||||
const filePath = path.posix.join(tempDir, id)
|
||||
const dirPath = path.dirname(filePath)
|
||||
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
fs.mkdirSync(dirPath, { recursive: true })
|
||||
}
|
||||
fs.writeFileSync(filePath, data);
|
||||
fs.writeFileSync(filePath, data)
|
||||
}
|
||||
|
||||
// Initialize the simple-git instance with the temporary directory and custom SSH command
|
||||
const git: SimpleGit = simpleGit(tempDir, {
|
||||
config: [
|
||||
'core.sshCommand=ssh -i ' + this.sshKeyPath + ' -o IdentitiesOnly=yes'
|
||||
]
|
||||
"core.sshCommand=ssh -i " +
|
||||
this.sshKeyPath +
|
||||
" -o IdentitiesOnly=yes",
|
||||
],
|
||||
}).outputHandler((_command, stdout, stderr) => {
|
||||
stdout.pipe(process.stdout);
|
||||
stderr.pipe(process.stderr);
|
||||
});;
|
||||
stdout.pipe(process.stdout)
|
||||
stderr.pipe(process.stderr)
|
||||
})
|
||||
|
||||
// Initialize a new Git repository
|
||||
await git.init();
|
||||
await git.init()
|
||||
|
||||
// Add remote repository
|
||||
await git.addRemote("origin", `${this.gitUrl}:${repository}`);
|
||||
await git.addRemote("origin", `${this.gitUrl}:${repository}`)
|
||||
|
||||
// Add files to the repository
|
||||
for (const {id, data} of fileData) {
|
||||
await git.add(id);
|
||||
for (const { id, data } of fileData) {
|
||||
await git.add(id)
|
||||
}
|
||||
|
||||
// Commit the changes
|
||||
await git.commit("Add files.");
|
||||
await git.commit("Add files.")
|
||||
|
||||
// Push the changes to the remote repository
|
||||
await git.push("origin", "master", {'--force': null});
|
||||
await git.push("origin", "master", { "--force": null })
|
||||
|
||||
console.log("Files successfully pushed to the repository");
|
||||
console.log("Files successfully pushed to the repository")
|
||||
|
||||
if (tempDir) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
console.log(`Temporary directory removed: ${tempDir}`);
|
||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||
console.log(`Temporary directory removed: ${tempDir}`)
|
||||
}
|
||||
} catch (error) {
|
||||
if (tempDir) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
console.log(`Temporary directory removed: ${tempDir}`);
|
||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||
console.log(`Temporary directory removed: ${tempDir}`)
|
||||
}
|
||||
console.error("Error pushing files to the repository:", error);
|
||||
throw error;
|
||||
console.error("Error pushing files to the repository:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
70
backend/server/src/Terminal.ts
Normal file
70
backend/server/src/Terminal.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import { ProcessHandle, Sandbox } from "e2b"
|
||||
|
||||
// Terminal class to manage a pseudo-terminal (PTY) in a sandbox environment
|
||||
export class Terminal {
|
||||
private pty: ProcessHandle | undefined // Holds the PTY process handle
|
||||
private sandbox: Sandbox // Reference to the sandbox environment
|
||||
|
||||
// Constructor initializes the Terminal with a sandbox
|
||||
constructor(sandbox: Sandbox) {
|
||||
this.sandbox = sandbox
|
||||
}
|
||||
|
||||
// Initialize the terminal with specified rows, columns, and data handler
|
||||
async init({
|
||||
rows = 20,
|
||||
cols = 80,
|
||||
onData,
|
||||
}: {
|
||||
rows?: number
|
||||
cols?: number
|
||||
onData: (responseData: string) => void
|
||||
}): Promise<void> {
|
||||
// Create a new PTY process
|
||||
this.pty = await this.sandbox.pty.create({
|
||||
rows,
|
||||
cols,
|
||||
timeout: 0,
|
||||
onData: (data: Uint8Array) => {
|
||||
onData(new TextDecoder().decode(data)) // Convert received data to string and pass to handler
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Send data to the terminal
|
||||
async sendData(data: string) {
|
||||
if (this.pty) {
|
||||
await this.sandbox.pty.sendInput(
|
||||
this.pty.pid,
|
||||
new TextEncoder().encode(data)
|
||||
)
|
||||
} else {
|
||||
console.log("Cannot send data because pty is not initialized.")
|
||||
}
|
||||
}
|
||||
|
||||
// Resize the terminal
|
||||
async resize(size: { cols: number; rows: number }): Promise<void> {
|
||||
if (this.pty) {
|
||||
await this.sandbox.pty.resize(this.pty.pid, size)
|
||||
} else {
|
||||
console.log("Cannot resize terminal because pty is not initialized.")
|
||||
}
|
||||
}
|
||||
|
||||
// Close the terminal, killing the PTY process and stopping the input stream
|
||||
async close(): Promise<void> {
|
||||
if (this.pty) {
|
||||
await this.pty.kill()
|
||||
} else {
|
||||
console.log("Cannot kill pty because it is not initialized.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Usage example:
|
||||
// const terminal = new Terminal(sandbox);
|
||||
// await terminal.init();
|
||||
// terminal.sendData('ls -la');
|
||||
// await terminal.resize({ cols: 100, rows: 30 });
|
||||
// await terminal.close();
|
74
backend/server/src/TerminalManager.ts
Normal file
74
backend/server/src/TerminalManager.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import { Sandbox } from "e2b"
|
||||
import { Terminal } from "./Terminal"
|
||||
|
||||
export class TerminalManager {
|
||||
private sandbox: Sandbox
|
||||
private terminals: Record<string, Terminal> = {}
|
||||
|
||||
constructor(sandbox: Sandbox) {
|
||||
this.sandbox = sandbox
|
||||
}
|
||||
|
||||
async createTerminal(
|
||||
id: string,
|
||||
onData: (responseString: string) => void
|
||||
): Promise<void> {
|
||||
if (this.terminals[id]) {
|
||||
return
|
||||
}
|
||||
|
||||
this.terminals[id] = new Terminal(this.sandbox)
|
||||
await this.terminals[id].init({
|
||||
onData,
|
||||
cols: 80,
|
||||
rows: 20,
|
||||
})
|
||||
|
||||
const defaultDirectory = "/home/user/project"
|
||||
const defaultCommands = [
|
||||
`cd "${defaultDirectory}"`,
|
||||
"export PS1='user> '",
|
||||
"clear",
|
||||
]
|
||||
for (const command of defaultCommands) {
|
||||
await this.terminals[id].sendData(command + "\r")
|
||||
}
|
||||
|
||||
console.log("Created terminal", id)
|
||||
}
|
||||
|
||||
async resizeTerminal(dimensions: {
|
||||
cols: number
|
||||
rows: number
|
||||
}): Promise<void> {
|
||||
Object.values(this.terminals).forEach((t) => {
|
||||
t.resize(dimensions)
|
||||
})
|
||||
}
|
||||
|
||||
async sendTerminalData(id: string, data: string): Promise<void> {
|
||||
if (!this.terminals[id]) {
|
||||
return
|
||||
}
|
||||
|
||||
await this.terminals[id].sendData(data)
|
||||
}
|
||||
|
||||
async closeTerminal(id: string): Promise<void> {
|
||||
if (!this.terminals[id]) {
|
||||
return
|
||||
}
|
||||
|
||||
await this.terminals[id].close()
|
||||
delete this.terminals[id]
|
||||
}
|
||||
|
||||
async closeAllTerminals(): Promise<void> {
|
||||
await Promise.all(
|
||||
Object.entries(this.terminals).map(async ([key, terminal]) => {
|
||||
await terminal.close()
|
||||
delete this.terminals[key]
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
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,177 +0,0 @@
|
||||
import * as dotenv from "dotenv";
|
||||
import {
|
||||
R2FileBody,
|
||||
R2Files,
|
||||
Sandbox,
|
||||
TFile,
|
||||
TFileData,
|
||||
TFolder,
|
||||
} from "./types";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
export const getSandboxFiles = async (id: string) => {
|
||||
const res = await fetch(
|
||||
`${process.env.STORAGE_WORKER_URL}/api?sandboxId=${id}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `${process.env.WORKERS_KEY}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
const data: R2Files = await res.json();
|
||||
|
||||
const paths = data.objects.map((obj) => obj.key);
|
||||
const processedFiles = await processFiles(paths, id);
|
||||
return processedFiles;
|
||||
};
|
||||
|
||||
export const getFolder = async (folderId: string) => {
|
||||
const res = await fetch(
|
||||
`${process.env.STORAGE_WORKER_URL}/api?folderId=${folderId}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `${process.env.WORKERS_KEY}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
const data: R2Files = await res.json();
|
||||
|
||||
return data.objects.map((obj) => obj.key);
|
||||
};
|
||||
|
||||
const processFiles = async (paths: string[], id: string) => {
|
||||
const root: TFolder = { id: "/", type: "folder", name: "/", children: [] };
|
||||
const fileData: TFileData[] = [];
|
||||
|
||||
paths.forEach((path) => {
|
||||
const allParts = path.split("/");
|
||||
if (allParts[1] !== id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = allParts.slice(2);
|
||||
let current: TFolder = root;
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
const isFile = i === parts.length - 1 && part.length;
|
||||
const existing = current.children.find((child) => child.name === part);
|
||||
|
||||
if (existing) {
|
||||
if (!isFile) {
|
||||
current = existing as TFolder;
|
||||
}
|
||||
} else {
|
||||
if (isFile) {
|
||||
const file: TFile = { id: path, type: "file", name: part };
|
||||
current.children.push(file);
|
||||
fileData.push({ id: path, data: "" });
|
||||
} else {
|
||||
const folder: TFolder = {
|
||||
// id: path, // todo: wrong id. for example, folder "src" ID is: projects/a7vgttfqbgy403ratp7du3ln/src/App.css
|
||||
id: `projects/${id}/${parts.slice(0, i + 1).join("/")}`,
|
||||
type: "folder",
|
||||
name: part,
|
||||
children: [],
|
||||
};
|
||||
current.children.push(folder);
|
||||
current = folder;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
fileData.map(async (file) => {
|
||||
const data = await fetchFileContent(file.id);
|
||||
file.data = data;
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
files: root.children,
|
||||
fileData,
|
||||
};
|
||||
};
|
||||
|
||||
const fetchFileContent = async (fileId: string): Promise<string> => {
|
||||
try {
|
||||
const fileRes = await fetch(
|
||||
`${process.env.STORAGE_WORKER_URL}/api?fileId=${fileId}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `${process.env.WORKERS_KEY}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
return await fileRes.text();
|
||||
} catch (error) {
|
||||
console.error("ERROR fetching file:", error);
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
export const createFile = async (fileId: string) => {
|
||||
const res = await fetch(`${process.env.STORAGE_WORKER_URL}/api`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `${process.env.WORKERS_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({ fileId }),
|
||||
});
|
||||
return res.ok;
|
||||
};
|
||||
|
||||
export const renameFile = async (
|
||||
fileId: string,
|
||||
newFileId: string,
|
||||
data: string
|
||||
) => {
|
||||
const res = await fetch(`${process.env.STORAGE_WORKER_URL}/api/rename`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `${process.env.WORKERS_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({ fileId, newFileId, data }),
|
||||
});
|
||||
return res.ok;
|
||||
};
|
||||
|
||||
export const saveFile = async (fileId: string, data: string) => {
|
||||
const res = await fetch(`${process.env.STORAGE_WORKER_URL}/api/save`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `${process.env.WORKERS_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({ fileId, data }),
|
||||
});
|
||||
return res.ok;
|
||||
};
|
||||
|
||||
export const deleteFile = async (fileId: string) => {
|
||||
const res = await fetch(`${process.env.STORAGE_WORKER_URL}/api`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `${process.env.WORKERS_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({ fileId }),
|
||||
});
|
||||
return res.ok;
|
||||
};
|
||||
|
||||
export const getProjectSize = async (id: string) => {
|
||||
const res = await fetch(
|
||||
`${process.env.STORAGE_WORKER_URL}/api/size?sandboxId=${id}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `${process.env.WORKERS_KEY}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
return (await res.json()).size;
|
||||
};
|
@ -1,691 +1,184 @@
|
||||
import path from "path";
|
||||
import cors from "cors";
|
||||
import express, { Express } from "express";
|
||||
import dotenv from "dotenv";
|
||||
import { createServer } from "http";
|
||||
import { Server } from "socket.io";
|
||||
import { DokkuClient } from "./DokkuClient";
|
||||
import { SecureGitClient, FileData } from "./SecureGitClient";
|
||||
import fs from "fs";
|
||||
import cors from "cors"
|
||||
import dotenv from "dotenv"
|
||||
import express, { Express } from "express"
|
||||
import fs from "fs"
|
||||
import { createServer } from "http"
|
||||
import { Server, Socket } from "socket.io"
|
||||
import { AIWorker } from "./AIWorker"
|
||||
|
||||
import { z } from "zod";
|
||||
import { User } from "./types";
|
||||
import {
|
||||
createFile,
|
||||
deleteFile,
|
||||
getFolder,
|
||||
getProjectSize,
|
||||
getSandboxFiles,
|
||||
renameFile,
|
||||
saveFile,
|
||||
} from "./fileoperations";
|
||||
import { LockManager } from "./utils";
|
||||
import { Sandbox, Terminal, FilesystemManager } from "e2b";
|
||||
import {
|
||||
MAX_BODY_SIZE,
|
||||
createFileRL,
|
||||
createFolderRL,
|
||||
deleteFileRL,
|
||||
renameFileRL,
|
||||
saveFileRL,
|
||||
} from "./ratelimit";
|
||||
import { ConnectionManager } from "./ConnectionManager"
|
||||
import { DokkuClient } from "./DokkuClient"
|
||||
import { Sandbox } from "./Sandbox"
|
||||
import { SecureGitClient } from "./SecureGitClient"
|
||||
import { socketAuth } from "./socketAuth"; // Import the new socketAuth middleware
|
||||
import { TFile, TFolder } from "./types"
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const app: Express = express();
|
||||
const port = process.env.PORT || 4000;
|
||||
app.use(cors());
|
||||
const httpServer = createServer(app);
|
||||
const io = new Server(httpServer, {
|
||||
cors: {
|
||||
origin: "*",
|
||||
},
|
||||
});
|
||||
|
||||
let inactivityTimeout: NodeJS.Timeout | null = null;
|
||||
let isOwnerConnected = false;
|
||||
|
||||
const containers: Record<string, Sandbox> = {};
|
||||
const connections: Record<string, number> = {};
|
||||
const terminals: Record<string, Terminal> = {};
|
||||
|
||||
const dirName = "/home/user";
|
||||
|
||||
const moveFile = async (
|
||||
filesystem: FilesystemManager,
|
||||
filePath: string,
|
||||
newFilePath: string
|
||||
) => {
|
||||
const fileContents = await filesystem.readBytes(filePath);
|
||||
await filesystem.writeBytes(newFilePath, fileContents);
|
||||
await filesystem.remove(filePath);
|
||||
// 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}`);
|
||||
};
|
||||
|
||||
io.use(async (socket, next) => {
|
||||
const handshakeSchema = z.object({
|
||||
userId: z.string(),
|
||||
sandboxId: z.string(),
|
||||
EIO: z.string(),
|
||||
transport: z.string(),
|
||||
});
|
||||
// Handle uncaught exceptions
|
||||
process.on("uncaughtException", (error) => {
|
||||
console.error("Uncaught Exception:", error)
|
||||
// Do not exit the process
|
||||
})
|
||||
|
||||
const q = socket.handshake.query;
|
||||
const parseQuery = handshakeSchema.safeParse(q);
|
||||
// Handle unhandled promise rejections
|
||||
process.on("unhandledRejection", (reason, promise) => {
|
||||
console.error("Unhandled Rejection at:", promise, "reason:", reason)
|
||||
// Do not exit the process
|
||||
})
|
||||
|
||||
if (!parseQuery.success) {
|
||||
next(new Error("Invalid request."));
|
||||
return;
|
||||
}
|
||||
// Initialize containers and managers
|
||||
const connections = new ConnectionManager()
|
||||
const sandboxes: Record<string, Sandbox> = {}
|
||||
|
||||
const { sandboxId, userId } = parseQuery.data;
|
||||
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;
|
||||
// Load environment variables
|
||||
dotenv.config()
|
||||
|
||||
if (!dbUserJSON) {
|
||||
next(new Error("DB error."));
|
||||
return;
|
||||
}
|
||||
// Initialize Express app and create HTTP server
|
||||
const app: Express = express()
|
||||
const port = process.env.PORT || 4000
|
||||
app.use(cors())
|
||||
const httpServer = createServer(app)
|
||||
const io = new Server(httpServer, {
|
||||
cors: {
|
||||
origin: "*", // Allow connections from any origin
|
||||
},
|
||||
})
|
||||
|
||||
const sandbox = dbUserJSON.sandbox.find((s) => s.id === sandboxId);
|
||||
const sharedSandboxes = dbUserJSON.usersToSandboxes.find(
|
||||
(uts) => uts.sandboxId === sandboxId
|
||||
);
|
||||
// Middleware for socket authentication
|
||||
io.use(socketAuth) // Use the new socketAuth middleware
|
||||
|
||||
if (!sandbox && !sharedSandboxes) {
|
||||
next(new Error("Invalid credentials."));
|
||||
return;
|
||||
}
|
||||
// Check for required environment variables
|
||||
if (!process.env.DOKKU_HOST)
|
||||
console.warn("Environment variable DOKKU_HOST is not defined")
|
||||
if (!process.env.DOKKU_USERNAME)
|
||||
console.warn("Environment variable DOKKU_USERNAME is not defined")
|
||||
if (!process.env.DOKKU_KEY)
|
||||
console.warn("Environment variable DOKKU_KEY is not defined")
|
||||
|
||||
socket.data = {
|
||||
userId,
|
||||
sandboxId: sandboxId,
|
||||
isOwner: sandbox !== undefined,
|
||||
};
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
const lockManager = new LockManager();
|
||||
|
||||
if (!process.env.DOKKU_HOST) console.error('Environment variable DOKKU_HOST is not defined');
|
||||
if (!process.env.DOKKU_USERNAME) console.error('Environment variable DOKKU_USERNAME is not defined');
|
||||
if (!process.env.DOKKU_KEY) console.error('Environment variable DOKKU_KEY is not defined');
|
||||
|
||||
const client =
|
||||
// Initialize Dokku 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),
|
||||
})
|
||||
: null;
|
||||
client?.connect();
|
||||
host: process.env.DOKKU_HOST,
|
||||
username: process.env.DOKKU_USERNAME,
|
||||
privateKey: fs.readFileSync(process.env.DOKKU_KEY),
|
||||
})
|
||||
: null
|
||||
dokkuClient?.connect()
|
||||
|
||||
const git = process.env.DOKKU_HOST && process.env.DOKKU_KEY ? new SecureGitClient(
|
||||
`dokku@${process.env.DOKKU_HOST}`,
|
||||
process.env.DOKKU_KEY
|
||||
) : null;
|
||||
// Initialize Git client used to deploy Dokku apps
|
||||
const gitClient =
|
||||
process.env.DOKKU_HOST && process.env.DOKKU_KEY
|
||||
? new SecureGitClient(
|
||||
`dokku@${process.env.DOKKU_HOST}`,
|
||||
process.env.DOKKU_KEY
|
||||
)
|
||||
: null
|
||||
|
||||
// Add this near the top of the file, after other initializations
|
||||
const aiWorker = new AIWorker(
|
||||
process.env.AI_WORKER_URL!,
|
||||
process.env.CF_AI_KEY!,
|
||||
process.env.DATABASE_WORKER_URL!,
|
||||
process.env.WORKERS_KEY!
|
||||
)
|
||||
|
||||
// Handle a client connecting to the server
|
||||
io.on("connection", async (socket) => {
|
||||
try {
|
||||
if (inactivityTimeout) clearTimeout(inactivityTimeout);
|
||||
|
||||
// This data comes is added by our authentication middleware
|
||||
const data = socket.data as {
|
||||
userId: string;
|
||||
sandboxId: string;
|
||||
isOwner: boolean;
|
||||
};
|
||||
|
||||
if (data.isOwner) {
|
||||
isOwnerConnected = true;
|
||||
connections[data.sandboxId] = (connections[data.sandboxId] ?? 0) + 1;
|
||||
} else {
|
||||
if (!isOwnerConnected) {
|
||||
socket.emit("disableAccess", "The sandbox owner is not connected.");
|
||||
return;
|
||||
}
|
||||
userId: string
|
||||
sandboxId: string
|
||||
isOwner: boolean
|
||||
type: string
|
||||
}
|
||||
|
||||
await lockManager.acquireLock(data.sandboxId, async () => {
|
||||
try {
|
||||
if (!containers[data.sandboxId]) {
|
||||
containers[data.sandboxId] = await Sandbox.create();
|
||||
console.log("Created container ", data.sandboxId);
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error(`Error creating container ${data.sandboxId}:`, e);
|
||||
io.emit("error", `Error: container creation. ${e.message ?? e}`);
|
||||
}
|
||||
});
|
||||
// Register the connection
|
||||
connections.addConnectionForSandbox(socket, data.sandboxId, data.isOwner)
|
||||
|
||||
// Change the owner of the project directory to user
|
||||
const fixPermissions = async () => {
|
||||
await containers[data.sandboxId].process.startAndWait(
|
||||
`sudo chown -R user "${path.join(dirName, "projects", data.sandboxId)}"`
|
||||
);
|
||||
};
|
||||
|
||||
const sandboxFiles = await getSandboxFiles(data.sandboxId);
|
||||
sandboxFiles.fileData.forEach(async (file) => {
|
||||
const filePath = path.join(dirName, file.id);
|
||||
await containers[data.sandboxId].filesystem.makeDir(
|
||||
path.dirname(filePath)
|
||||
);
|
||||
await containers[data.sandboxId].filesystem.write(filePath, file.data);
|
||||
});
|
||||
fixPermissions();
|
||||
|
||||
socket.emit("loaded", sandboxFiles.files);
|
||||
|
||||
socket.on("getFile", (fileId: string, callback) => {
|
||||
console.log(fileId);
|
||||
try {
|
||||
const file = sandboxFiles.fileData.find((f) => f.id === fileId);
|
||||
if (!file) return;
|
||||
|
||||
callback(file.data);
|
||||
} catch (e: any) {
|
||||
console.error("Error getting file:", e);
|
||||
io.emit("error", `Error: get file. ${e.message ?? e}`);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("getFolder", async (folderId: string, callback) => {
|
||||
try {
|
||||
const files = await getFolder(folderId);
|
||||
callback(files);
|
||||
} catch (e: any) {
|
||||
console.error("Error getting folder:", e);
|
||||
io.emit("error", `Error: get folder. ${e.message ?? e}`);
|
||||
}
|
||||
});
|
||||
|
||||
// todo: send diffs + debounce for efficiency
|
||||
socket.on("saveFile", async (fileId: string, body: string) => {
|
||||
if (!fileId) return; // handles saving when no file is open
|
||||
|
||||
try {
|
||||
if (Buffer.byteLength(body, "utf-8") > MAX_BODY_SIZE) {
|
||||
socket.emit(
|
||||
"error",
|
||||
"Error: file size too large. Please reduce the file size."
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await saveFileRL.consume(data.userId, 1);
|
||||
await saveFile(fileId, body);
|
||||
} catch (e) {
|
||||
io.emit("error", "Rate limited: file saving. Please slow down.");
|
||||
return;
|
||||
}
|
||||
|
||||
const file = sandboxFiles.fileData.find((f) => f.id === fileId);
|
||||
if (!file) return;
|
||||
file.data = body;
|
||||
|
||||
await containers[data.sandboxId].filesystem.write(
|
||||
path.join(dirName, file.id),
|
||||
body
|
||||
);
|
||||
fixPermissions();
|
||||
} catch (e: any) {
|
||||
console.error("Error saving file:", e);
|
||||
io.emit("error", `Error: file saving. ${e.message ?? e}`);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on(
|
||||
"moveFile",
|
||||
async (fileId: string, folderId: string, callback) => {
|
||||
try {
|
||||
const file = sandboxFiles.fileData.find((f) => f.id === fileId);
|
||||
if (!file) return;
|
||||
|
||||
const parts = fileId.split("/");
|
||||
const newFileId = folderId + "/" + parts.pop();
|
||||
|
||||
await moveFile(
|
||||
containers[data.sandboxId].filesystem,
|
||||
path.join(dirName, fileId),
|
||||
path.join(dirName, newFileId)
|
||||
);
|
||||
fixPermissions();
|
||||
|
||||
file.id = newFileId;
|
||||
|
||||
await renameFile(fileId, newFileId, file.data);
|
||||
const newFiles = await getSandboxFiles(data.sandboxId);
|
||||
callback(newFiles.files);
|
||||
} catch (e: any) {
|
||||
console.error("Error moving file:", e);
|
||||
io.emit("error", `Error: file moving. ${e.message ?? e}`);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
interface CallbackResponse {
|
||||
success: boolean;
|
||||
apps?: string[];
|
||||
message?: string;
|
||||
// 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
|
||||
}
|
||||
|
||||
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",
|
||||
});
|
||||
try {
|
||||
// Create or retrieve the sandbox manager for the given sandbox ID
|
||||
const sandbox = sandboxes[data.sandboxId] ?? new Sandbox(
|
||||
data.sandboxId,
|
||||
data.type,
|
||||
{
|
||||
aiWorker, dokkuClient, gitClient,
|
||||
}
|
||||
}
|
||||
);
|
||||
)
|
||||
sandboxes[data.sandboxId] = sandbox
|
||||
|
||||
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 = 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
socket.on("createFile", async (name: string, callback) => {
|
||||
try {
|
||||
const size: number = await getProjectSize(data.sandboxId);
|
||||
// limit is 200mb
|
||||
if (size > 200 * 1024 * 1024) {
|
||||
io.emit(
|
||||
"error",
|
||||
"Rate limited: project size exceeded. Please delete some files."
|
||||
);
|
||||
callback({ success: false });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await createFileRL.consume(data.userId, 1);
|
||||
} catch (e) {
|
||||
io.emit("error", "Rate limited: file creation. Please slow down.");
|
||||
return;
|
||||
}
|
||||
|
||||
const id = `projects/${data.sandboxId}/${name}`;
|
||||
|
||||
await containers[data.sandboxId].filesystem.write(
|
||||
path.join(dirName, id),
|
||||
""
|
||||
);
|
||||
fixPermissions();
|
||||
|
||||
sandboxFiles.files.push({
|
||||
id,
|
||||
name,
|
||||
type: "file",
|
||||
// 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);
|
||||
});
|
||||
};
|
||||
|
||||
sandboxFiles.fileData.push({
|
||||
id,
|
||||
data: "",
|
||||
});
|
||||
// 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)
|
||||
|
||||
await createFile(id);
|
||||
|
||||
callback({ success: true });
|
||||
} catch (e: any) {
|
||||
console.error("Error creating file:", e);
|
||||
io.emit("error", `Error: file creation. ${e.message ?? e}`);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("createFolder", async (name: string, callback) => {
|
||||
try {
|
||||
try {
|
||||
await createFolderRL.consume(data.userId, 1);
|
||||
} catch (e) {
|
||||
io.emit("error", "Rate limited: folder creation. Please slow down.");
|
||||
return;
|
||||
}
|
||||
|
||||
const id = `projects/${data.sandboxId}/${name}`;
|
||||
|
||||
await containers[data.sandboxId].filesystem.makeDir(
|
||||
path.join(dirName, id)
|
||||
);
|
||||
|
||||
callback();
|
||||
} catch (e: any) {
|
||||
console.error("Error creating folder:", e);
|
||||
io.emit("error", `Error: folder creation. ${e.message ?? e}`);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("renameFile", async (fileId: string, newName: string) => {
|
||||
try {
|
||||
try {
|
||||
await renameFileRL.consume(data.userId, 1);
|
||||
} catch (e) {
|
||||
io.emit("error", "Rate limited: file renaming. Please slow down.");
|
||||
return;
|
||||
}
|
||||
|
||||
const file = sandboxFiles.fileData.find((f) => f.id === fileId);
|
||||
if (!file) return;
|
||||
file.id = newName;
|
||||
|
||||
const parts = fileId.split("/");
|
||||
const newFileId =
|
||||
parts.slice(0, parts.length - 1).join("/") + "/" + newName;
|
||||
|
||||
await moveFile(
|
||||
containers[data.sandboxId].filesystem,
|
||||
path.join(dirName, fileId),
|
||||
path.join(dirName, newFileId)
|
||||
);
|
||||
fixPermissions();
|
||||
await renameFile(fileId, newFileId, file.data);
|
||||
} catch (e: any) {
|
||||
console.error("Error renaming folder:", e);
|
||||
io.emit("error", `Error: folder renaming. ${e.message ?? e}`);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("deleteFile", async (fileId: string, callback) => {
|
||||
try {
|
||||
try {
|
||||
await deleteFileRL.consume(data.userId, 1);
|
||||
} catch (e) {
|
||||
io.emit("error", "Rate limited: file deletion. Please slow down.");
|
||||
}
|
||||
|
||||
const file = sandboxFiles.fileData.find((f) => f.id === fileId);
|
||||
if (!file) return;
|
||||
|
||||
await containers[data.sandboxId].filesystem.remove(
|
||||
path.join(dirName, fileId)
|
||||
);
|
||||
sandboxFiles.fileData = sandboxFiles.fileData.filter(
|
||||
(f) => f.id !== fileId
|
||||
);
|
||||
|
||||
await deleteFile(fileId);
|
||||
|
||||
const newFiles = await getSandboxFiles(data.sandboxId);
|
||||
callback(newFiles.files);
|
||||
} catch (e: any) {
|
||||
console.error("Error deleting file:", e);
|
||||
io.emit("error", `Error: file deletion. ${e.message ?? e}`);
|
||||
}
|
||||
});
|
||||
|
||||
// todo
|
||||
// socket.on("renameFolder", async (folderId: string, newName: string) => {
|
||||
// });
|
||||
|
||||
socket.on("deleteFolder", async (folderId: string, callback) => {
|
||||
try {
|
||||
const files = await getFolder(folderId);
|
||||
|
||||
await Promise.all(
|
||||
files.map(async (file) => {
|
||||
await containers[data.sandboxId].filesystem.remove(
|
||||
path.join(dirName, file)
|
||||
);
|
||||
|
||||
sandboxFiles.fileData = sandboxFiles.fileData.filter(
|
||||
(f) => f.id !== file
|
||||
);
|
||||
|
||||
await deleteFile(file);
|
||||
})
|
||||
);
|
||||
|
||||
const newFiles = await getSandboxFiles(data.sandboxId);
|
||||
|
||||
callback(newFiles.files);
|
||||
} catch (e: any) {
|
||||
console.error("Error deleting folder:", e);
|
||||
io.emit("error", `Error: folder deletion. ${e.message ?? e}`);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("createTerminal", async (id: string, callback) => {
|
||||
try {
|
||||
if (terminals[id] || Object.keys(terminals).length >= 4) {
|
||||
return;
|
||||
}
|
||||
|
||||
await lockManager.acquireLock(data.sandboxId, async () => {
|
||||
// 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 {
|
||||
terminals[id] = await containers[data.sandboxId].terminal.start({
|
||||
onData: (responseData: string) => {
|
||||
io.emit("terminalResponse", { id, data: responseData });
|
||||
|
||||
function extractPortNumber(inputString: string) {
|
||||
// Remove ANSI escape codes
|
||||
const cleanedString = inputString.replace(/\x1B\[[0-9;]*m/g, '');
|
||||
// Regular expression to match port number
|
||||
const regex = /http:\/\/localhost:(\d+)/;
|
||||
// If a match is found, return the port number
|
||||
const match = cleanedString.match(regex);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
const port = parseInt(extractPortNumber(responseData) ?? "");
|
||||
if (port) {
|
||||
io.emit(
|
||||
"previewURL",
|
||||
"https://" + containers[data.sandboxId].getHostname(port)
|
||||
);
|
||||
}
|
||||
|
||||
},
|
||||
size: { cols: 80, rows: 20 },
|
||||
onExit: () => console.log("Terminal exited", id),
|
||||
});
|
||||
await terminals[id].sendData(
|
||||
`cd "${path.join(dirName, "projects", data.sandboxId)}"\r`
|
||||
);
|
||||
await terminals[id].sendData("export PS1='user> '\rclear\r");
|
||||
console.log("Created terminal", id);
|
||||
const result = await handler(options)
|
||||
callback?.(result);
|
||||
} catch (e: any) {
|
||||
console.error(`Error creating terminal ${id}:`, e);
|
||||
io.emit("error", `Error: terminal creation. ${e.message ?? e}`);
|
||||
handleErrors(`Error processing event "${event}":`, e, socket);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
callback();
|
||||
} catch (e: any) {
|
||||
console.error(`Error creating terminal ${id}:`, e);
|
||||
io.emit("error", `Error: terminal creation. ${e.message ?? e}`);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on(
|
||||
"resizeTerminal",
|
||||
(dimensions: { cols: number; rows: number }) => {
|
||||
// Handle disconnection event
|
||||
socket.on("disconnect", async () => {
|
||||
try {
|
||||
Object.values(terminals).forEach((t) => {
|
||||
t.resize(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);
|
||||
io.emit("error", `Error: terminal resizing. ${e.message ?? e}`);
|
||||
handleErrors("Error disconnecting:", e, socket);
|
||||
}
|
||||
}
|
||||
);
|
||||
})
|
||||
|
||||
socket.on("terminalData", (id: string, data: string) => {
|
||||
try {
|
||||
if (!terminals[id]) {
|
||||
return;
|
||||
}
|
||||
} catch (e: any) {
|
||||
handleErrors(`Error initializing sandbox ${data.sandboxId}:`, e, socket);
|
||||
}
|
||||
|
||||
terminals[id].sendData(data);
|
||||
} catch (e: any) {
|
||||
console.error("Error writing to terminal:", e);
|
||||
io.emit("error", `Error: writing to terminal. ${e.message ?? e}`);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("closeTerminal", async (id: string, callback) => {
|
||||
try {
|
||||
if (!terminals[id]) {
|
||||
return;
|
||||
}
|
||||
|
||||
await terminals[id].kill();
|
||||
delete terminals[id];
|
||||
|
||||
callback();
|
||||
} catch (e: any) {
|
||||
console.error("Error closing terminal:", e);
|
||||
io.emit("error", `Error: closing terminal. ${e.message ?? e}`);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on(
|
||||
"generateCode",
|
||||
async (
|
||||
fileName: string,
|
||||
code: string,
|
||||
line: number,
|
||||
instructions: string,
|
||||
callback
|
||||
) => {
|
||||
try {
|
||||
const fetchPromise = fetch(
|
||||
`${process.env.DATABASE_WORKER_URL}/api/sandbox/generate`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `${process.env.WORKERS_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
userId: data.userId,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
// Generate code from cloudflare workers AI
|
||||
const generateCodePromise = fetch(
|
||||
`${process.env.AI_WORKER_URL}/api?fileName=${fileName}&code=${code}&line=${line}&instructions=${instructions}`,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `${process.env.CF_AI_KEY}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const [fetchResponse, generateCodeResponse] = await Promise.all([
|
||||
fetchPromise,
|
||||
generateCodePromise,
|
||||
]);
|
||||
|
||||
const json = await generateCodeResponse.json();
|
||||
|
||||
callback({ response: json.response, success: true });
|
||||
} catch (e: any) {
|
||||
console.error("Error generating code:", e);
|
||||
io.emit("error", `Error: code generation. ${e.message ?? e}`);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
socket.on("disconnect", async () => {
|
||||
try {
|
||||
if (data.isOwner) {
|
||||
connections[data.sandboxId]--;
|
||||
}
|
||||
|
||||
if (data.isOwner && connections[data.sandboxId] <= 0) {
|
||||
await Promise.all(
|
||||
Object.entries(terminals).map(async ([key, terminal]) => {
|
||||
await terminal.kill();
|
||||
delete terminals[key];
|
||||
})
|
||||
);
|
||||
|
||||
await lockManager.acquireLock(data.sandboxId, async () => {
|
||||
try {
|
||||
if (containers[data.sandboxId]) {
|
||||
await containers[data.sandboxId].close();
|
||||
delete containers[data.sandboxId];
|
||||
console.log("Closed container", data.sandboxId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error closing container ", data.sandboxId, error);
|
||||
}
|
||||
});
|
||||
|
||||
socket.broadcast.emit(
|
||||
"disableAccess",
|
||||
"The sandbox owner has disconnected."
|
||||
);
|
||||
}
|
||||
|
||||
// const sockets = await io.fetchSockets();
|
||||
// if (inactivityTimeout) {
|
||||
// clearTimeout(inactivityTimeout);
|
||||
// }
|
||||
// if (sockets.length === 0) {
|
||||
// console.log("STARTING TIMER");
|
||||
// inactivityTimeout = setTimeout(() => {
|
||||
// io.fetchSockets().then(async (sockets) => {
|
||||
// if (sockets.length === 0) {
|
||||
// console.log("Server stopped", res);
|
||||
// }
|
||||
// });
|
||||
// }, 20000);
|
||||
// } else {
|
||||
// console.log("number of sockets", sockets.length);
|
||||
// }
|
||||
} catch (e: any) {
|
||||
console.log("Error disconnecting:", e);
|
||||
io.emit("error", `Error: disconnecting. ${e.message ?? e}`);
|
||||
}
|
||||
});
|
||||
} catch (e: any) {
|
||||
console.error("Error connecting:", e);
|
||||
io.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}`);
|
||||
});
|
||||
console.log(`Server running on port ${port}`)
|
||||
})
|
@ -30,4 +30,4 @@ export const deleteFileRL = new RateLimiterMemory({
|
||||
export const deleteFolderRL = new RateLimiterMemory({
|
||||
points: 1,
|
||||
duration: 2,
|
||||
})
|
||||
})
|
||||
|
75
backend/server/src/socketAuth.ts
Normal file
75
backend/server/src/socketAuth.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import { Socket } from "socket.io"
|
||||
import { z } from "zod"
|
||||
import { Sandbox, 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
|
||||
|
||||
// Fetch sandbox data from the database
|
||||
const dbSandbox = await fetch(
|
||||
`${process.env.DATABASE_WORKER_URL}/api/sandbox?id=${sandboxId}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `${process.env.WORKERS_KEY}`,
|
||||
},
|
||||
}
|
||||
)
|
||||
const dbSandboxJSON = (await dbSandbox.json()) as Sandbox
|
||||
|
||||
// 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,
|
||||
type: dbSandboxJSON.type
|
||||
}
|
||||
|
||||
// Allow the connection
|
||||
next()
|
||||
}
|
@ -1,70 +1,75 @@
|
||||
// DB Types
|
||||
|
||||
export type User = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
generations: number;
|
||||
sandbox: Sandbox[];
|
||||
usersToSandboxes: UsersToSandboxes[];
|
||||
};
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
generations: number
|
||||
sandbox: Sandbox[]
|
||||
usersToSandboxes: UsersToSandboxes[]
|
||||
}
|
||||
|
||||
export type Sandbox = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "react" | "node";
|
||||
visibility: "public" | "private";
|
||||
createdAt: Date;
|
||||
userId: string;
|
||||
usersToSandboxes: UsersToSandboxes[];
|
||||
};
|
||||
id: string
|
||||
name: string
|
||||
type: "reactjs" | "vanillajs" | "nextjs" | "streamlit"
|
||||
visibility: "public" | "private"
|
||||
createdAt: Date
|
||||
userId: string
|
||||
usersToSandboxes: UsersToSandboxes[]
|
||||
}
|
||||
|
||||
export type UsersToSandboxes = {
|
||||
userId: string;
|
||||
sandboxId: string;
|
||||
sharedOn: Date;
|
||||
};
|
||||
userId: string
|
||||
sandboxId: string
|
||||
sharedOn: Date
|
||||
}
|
||||
|
||||
export type TFolder = {
|
||||
id: string;
|
||||
type: "folder";
|
||||
name: string;
|
||||
children: (TFile | TFolder)[];
|
||||
};
|
||||
id: string
|
||||
type: "folder"
|
||||
name: string
|
||||
children: (TFile | TFolder)[]
|
||||
}
|
||||
|
||||
export type TFile = {
|
||||
id: string;
|
||||
type: "file";
|
||||
name: string;
|
||||
};
|
||||
id: string
|
||||
type: "file"
|
||||
name: string
|
||||
}
|
||||
|
||||
export type TFileData = {
|
||||
id: string;
|
||||
data: string;
|
||||
};
|
||||
id: string
|
||||
data: string
|
||||
}
|
||||
|
||||
export type R2Files = {
|
||||
objects: R2FileData[];
|
||||
truncated: boolean;
|
||||
delimitedPrefixes: any[];
|
||||
};
|
||||
objects: R2FileData[]
|
||||
truncated: boolean
|
||||
delimitedPrefixes: any[]
|
||||
}
|
||||
|
||||
export type R2FileData = {
|
||||
storageClass: string;
|
||||
uploaded: string;
|
||||
checksums: any;
|
||||
httpEtag: string;
|
||||
etag: string;
|
||||
size: number;
|
||||
version: string;
|
||||
key: string;
|
||||
};
|
||||
storageClass: string
|
||||
uploaded: string
|
||||
checksums: any
|
||||
httpEtag: string
|
||||
etag: string
|
||||
size: number
|
||||
version: string
|
||||
key: string
|
||||
}
|
||||
|
||||
export type R2FileBody = R2FileData & {
|
||||
body: ReadableStream;
|
||||
bodyUsed: boolean;
|
||||
arrayBuffer: Promise<ArrayBuffer>;
|
||||
text: Promise<string>;
|
||||
json: Promise<any>;
|
||||
blob: Promise<Blob>;
|
||||
};
|
||||
body: ReadableStream
|
||||
bodyUsed: boolean
|
||||
arrayBuffer: Promise<ArrayBuffer>
|
||||
text: Promise<string>
|
||||
json: Promise<any>
|
||||
blob: Promise<Blob>
|
||||
}
|
||||
export interface DokkuResponse {
|
||||
success: boolean
|
||||
apps?: string[]
|
||||
message?: string
|
||||
}
|
||||
|
@ -1,23 +1,23 @@
|
||||
export class LockManager {
|
||||
private locks: { [key: string]: Promise<any> };
|
||||
private locks: { [key: string]: Promise<any> }
|
||||
|
||||
constructor() {
|
||||
this.locks = {};
|
||||
this.locks = {}
|
||||
}
|
||||
|
||||
async acquireLock<T>(key: string, task: () => Promise<T>): Promise<T> {
|
||||
if (!this.locks[key]) {
|
||||
this.locks[key] = new Promise<T>(async (resolve, reject) => {
|
||||
try {
|
||||
const result = await task();
|
||||
resolve(result);
|
||||
const result = await task()
|
||||
resolve(result)
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
reject(error)
|
||||
} finally {
|
||||
delete this.locks[key];
|
||||
delete this.locks[key]
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
return await this.locks[key];
|
||||
return await this.locks[key]
|
||||
}
|
||||
}
|
@ -1,5 +0,0 @@
|
||||
{
|
||||
"tabWidth": 2,
|
||||
"semi": false,
|
||||
"singleQuote": false
|
||||
}
|
6207
backend/storage/package-lock.json
generated
6207
backend/storage/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,22 +1,23 @@
|
||||
{
|
||||
"name": "storage",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"deploy": "wrangler deploy",
|
||||
"dev": "wrangler dev --remote",
|
||||
"start": "wrangler dev",
|
||||
"test": "vitest",
|
||||
"cf-typegen": "wrangler types"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/vitest-pool-workers": "^0.1.0",
|
||||
"@cloudflare/workers-types": "^4.20240419.0",
|
||||
"typescript": "^5.0.4",
|
||||
"vitest": "1.3.0",
|
||||
"wrangler": "^3.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^3.23.4"
|
||||
}
|
||||
"name": "storage",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"deploy": "wrangler deploy",
|
||||
"dev": "wrangler dev --remote",
|
||||
"start": "wrangler dev",
|
||||
"test": "vitest",
|
||||
"cf-typegen": "wrangler types"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/vitest-pool-workers": "^0.1.0",
|
||||
"@cloudflare/workers-types": "^4.20241106.0",
|
||||
"typescript": "^5.0.4",
|
||||
"vitest": "1.3.0",
|
||||
"wrangler": "^3.86.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"p-limit": "^6.1.0",
|
||||
"zod": "^3.23.4"
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,9 @@
|
||||
import { ExecutionContext, R2Bucket, Headers as CFHeaders } from "@cloudflare/workers-types"
|
||||
import { z } from "zod"
|
||||
|
||||
export interface Env {
|
||||
R2: R2Bucket
|
||||
Templates: R2Bucket
|
||||
KEY: string
|
||||
}
|
||||
|
||||
@ -74,14 +76,13 @@ export default {
|
||||
if (obj === null) {
|
||||
return new Response(`${fileId} not found`, { status: 404 })
|
||||
}
|
||||
const headers = new Headers()
|
||||
const headers = new Headers() as unknown as CFHeaders
|
||||
headers.set("etag", obj.httpEtag)
|
||||
obj.writeHttpMetadata(headers)
|
||||
|
||||
const text = await obj.text()
|
||||
|
||||
return new Response(text, {
|
||||
headers,
|
||||
headers: Object.fromEntries(headers.entries()),
|
||||
})
|
||||
} else return invalidRequest
|
||||
} else if (method === "POST") {
|
||||
@ -134,28 +135,7 @@ export default {
|
||||
|
||||
return success
|
||||
} else if (path === "/api/init" && method === "POST") {
|
||||
const initSchema = z.object({
|
||||
sandboxId: z.string(),
|
||||
type: z.string(),
|
||||
})
|
||||
|
||||
const body = await request.json()
|
||||
const { sandboxId, type } = initSchema.parse(body)
|
||||
|
||||
console.log(`Copying template: ${type}`);
|
||||
|
||||
const templateDirectory = `templates/${type}`;
|
||||
|
||||
// List all objects under the directory
|
||||
const { objects } = await env.R2.list({ prefix: templateDirectory });
|
||||
|
||||
// Copy each object to the new directory
|
||||
for (const { key } of objects) {
|
||||
const destinationKey = key.replace(templateDirectory, `projects/${sandboxId}`);
|
||||
const fileBody = await env.R2.get(key).then(res => res?.body ?? "");
|
||||
await env.R2.put(destinationKey, fileBody);
|
||||
}
|
||||
|
||||
// This API path no longer does anything, because template files are stored in E2B sandbox templates.
|
||||
return success
|
||||
} else {
|
||||
return notFound
|
||||
|
@ -1,25 +1,30 @@
|
||||
// test/index.spec.ts
|
||||
import { env, createExecutionContext, waitOnExecutionContext, SELF } from 'cloudflare:test';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import worker from '../src/index';
|
||||
import {
|
||||
createExecutionContext,
|
||||
env,
|
||||
SELF,
|
||||
waitOnExecutionContext,
|
||||
} from "cloudflare:test"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import worker from "../src/index"
|
||||
|
||||
// For now, you'll need to do something like this to get a correctly-typed
|
||||
// `Request` to pass to `worker.fetch()`.
|
||||
const IncomingRequest = Request<unknown, IncomingRequestCfProperties>;
|
||||
const IncomingRequest = Request<unknown, IncomingRequestCfProperties>
|
||||
|
||||
describe('Hello World worker', () => {
|
||||
it('responds with Hello World! (unit style)', async () => {
|
||||
const request = new IncomingRequest('http://example.com');
|
||||
describe("Hello World worker", () => {
|
||||
it("responds with Hello World! (unit style)", async () => {
|
||||
const request = new IncomingRequest("http://example.com")
|
||||
// Create an empty context to pass to `worker.fetch()`.
|
||||
const ctx = createExecutionContext();
|
||||
const response = await worker.fetch(request, env, ctx);
|
||||
const ctx = createExecutionContext()
|
||||
const response = await worker.fetch(request, env, ctx)
|
||||
// Wait for all `Promise`s passed to `ctx.waitUntil()` to settle before running test assertions
|
||||
await waitOnExecutionContext(ctx);
|
||||
expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`);
|
||||
});
|
||||
await waitOnExecutionContext(ctx)
|
||||
expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`)
|
||||
})
|
||||
|
||||
it('responds with Hello World! (integration style)', async () => {
|
||||
const response = await SELF.fetch('https://example.com');
|
||||
expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`);
|
||||
});
|
||||
});
|
||||
it("responds with Hello World! (integration style)", async () => {
|
||||
const response = await SELF.fetch("https://example.com")
|
||||
expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`)
|
||||
})
|
||||
})
|
||||
|
@ -1,11 +1,11 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": [
|
||||
"@cloudflare/workers-types/experimental",
|
||||
"@cloudflare/vitest-pool-workers"
|
||||
]
|
||||
},
|
||||
"include": ["./**/*.ts", "../src/env.d.ts"],
|
||||
"exclude": []
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": [
|
||||
"@cloudflare/workers-types/experimental",
|
||||
"@cloudflare/vitest-pool-workers"
|
||||
]
|
||||
},
|
||||
"include": ["./**/*.ts", "../src/env.d.ts"],
|
||||
"exclude": []
|
||||
}
|
||||
|
@ -12,7 +12,9 @@
|
||||
|
||||
/* Language and Environment */
|
||||
"target": "es2021" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
|
||||
"lib": ["es2021"] /* Specify a set of bundled library declaration files that describe the target runtime environment. */,
|
||||
"lib": [
|
||||
"es2021"
|
||||
] /* Specify a set of bundled library declaration files that describe the target runtime environment. */,
|
||||
"jsx": "react" /* Specify what JSX code is generated. */,
|
||||
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
|
||||
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
|
||||
@ -32,7 +34,7 @@
|
||||
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
|
||||
// "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */
|
||||
"types": [
|
||||
"@cloudflare/workers-types/2023-07-01"
|
||||
"@cloudflare/workers-types"
|
||||
] /* Specify type package names to be included without being referenced in a source file. */,
|
||||
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||
"resolveJsonModule": true /* Enable importing .json files */,
|
||||
|
@ -1,11 +1,11 @@
|
||||
import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config";
|
||||
import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config"
|
||||
|
||||
export default defineWorkersConfig({
|
||||
test: {
|
||||
poolOptions: {
|
||||
workers: {
|
||||
wrangler: { configPath: "./wrangler.toml" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
test: {
|
||||
poolOptions: {
|
||||
workers: {
|
||||
wrangler: { configPath: "./wrangler.toml" },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
3
backend/storage/worker-configuration.d.ts
vendored
3
backend/storage/worker-configuration.d.ts
vendored
@ -1,4 +1,3 @@
|
||||
// Generated by Wrangler
|
||||
// After adding bindings to `wrangler.toml`, regenerate this interface via `npm run cf-typegen`
|
||||
interface Env {
|
||||
}
|
||||
interface Env {}
|
||||
|
@ -8,8 +8,10 @@ NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||
|
||||
# Set WORKER_URLs after deploying the workers.
|
||||
# Set NEXT_PUBLIC_WORKERS_KEY to be the same as KEY in /backend/storage/wrangler.toml.
|
||||
# These URLs should begin with https:// in production
|
||||
NEXT_PUBLIC_DATABASE_WORKER_URL=
|
||||
NEXT_PUBLIC_STORAGE_WORKER_URL=
|
||||
NEXT_PUBLIC_AI_WORKER_URL=
|
||||
NEXT_PUBLIC_WORKERS_KEY=
|
||||
|
||||
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
|
||||
|
@ -1,5 +0,0 @@
|
||||
{
|
||||
"tabWidth": 2,
|
||||
"semi": false,
|
||||
"singleQuote": false
|
||||
}
|
@ -1,11 +1,11 @@
|
||||
// import { Room } from "@/components/editor/live/room"
|
||||
import Loading from "@/components/editor/loading"
|
||||
import Navbar from "@/components/editor/navbar"
|
||||
import { Room } from "@/components/editor/live/room"
|
||||
import { TerminalProvider } from "@/context/TerminalContext"
|
||||
import { Sandbox, User, UsersToSandboxes } from "@/lib/types"
|
||||
import { currentUser } from "@clerk/nextjs"
|
||||
import { notFound, redirect } from "next/navigation"
|
||||
import Loading from "@/components/editor/loading"
|
||||
import dynamic from "next/dynamic"
|
||||
import fs from "fs"
|
||||
import { notFound, redirect } from "next/navigation"
|
||||
|
||||
export const revalidate = 0
|
||||
|
||||
@ -51,7 +51,7 @@ const getSharedUsers = async (usersToSandboxes: UsersToSandboxes[]) => {
|
||||
}
|
||||
)
|
||||
const userData: User = await userRes.json()
|
||||
return { id: userData.id, name: userData.name }
|
||||
return { id: userData.id, name: userData.name, avatarUrl: userData.avatarUrl }
|
||||
})
|
||||
)
|
||||
|
||||
@ -87,16 +87,21 @@ export default async function CodePage({ params }: { params: { id: string } }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden overscroll-none w-screen flex flex-col h-screen bg-background">
|
||||
<Room id={sandboxId}>
|
||||
<Navbar userData={userData} sandboxData={sandboxData} shared={shared} />
|
||||
<div className="w-screen flex grow">
|
||||
<CodeEditor
|
||||
userData={userData}
|
||||
sandboxData={sandboxData}
|
||||
/>
|
||||
</div>
|
||||
</Room>
|
||||
</div>
|
||||
<>
|
||||
<div className="overflow-hidden overscroll-none w-screen flex flex-col h-screen bg-background">
|
||||
{/* <Room id={sandboxId}> */}
|
||||
<TerminalProvider>
|
||||
<Navbar
|
||||
userData={userData}
|
||||
sandboxData={sandboxData}
|
||||
shared={shared as { id: string; name: string; avatarUrl: string }[]}
|
||||
/>
|
||||
<div className="w-screen flex grow">
|
||||
<CodeEditor userData={userData} sandboxData={sandboxData} />
|
||||
</div>
|
||||
</TerminalProvider>
|
||||
{/* </Room> */}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { UserButton, currentUser } from "@clerk/nextjs"
|
||||
import { redirect } from "next/navigation"
|
||||
import Dashboard from "@/components/dashboard"
|
||||
import Navbar from "@/components/dashboard/navbar"
|
||||
import { Sandbox, User } from "@/lib/types"
|
||||
import { User } from "@/lib/types"
|
||||
import { currentUser } from "@clerk/nextjs"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const user = await currentUser()
|
||||
@ -35,6 +35,7 @@ export default async function DashboardPage() {
|
||||
type: "react" | "node"
|
||||
author: string
|
||||
sharedOn: Date
|
||||
authorAvatarUrl: string
|
||||
}[]
|
||||
|
||||
return (
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { User } from "@/lib/types"
|
||||
import { currentUser } from "@clerk/nextjs"
|
||||
import { redirect } from "next/navigation"
|
||||
import { generateUniqueUsername } from "@/lib/username-generator";
|
||||
|
||||
export default async function AppAuthLayout({
|
||||
children,
|
||||
@ -24,6 +25,25 @@ export default async function AppAuthLayout({
|
||||
const dbUserJSON = (await dbUser.json()) as User
|
||||
|
||||
if (!dbUserJSON.id) {
|
||||
// Try to get GitHub username if available
|
||||
const githubUsername = user.externalAccounts.find(
|
||||
account => account.provider === "github"
|
||||
)?.username;
|
||||
|
||||
const username = githubUsername || await generateUniqueUsername(async (username) => {
|
||||
// Check if username exists in database
|
||||
const userCheck = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_DATABASE_WORKER_URL}/api/user/check-username?username=${username}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `${process.env.NEXT_PUBLIC_WORKERS_KEY}`,
|
||||
},
|
||||
}
|
||||
)
|
||||
const exists = await userCheck.json()
|
||||
return exists.exists
|
||||
});
|
||||
|
||||
const res = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_DATABASE_WORKER_URL}/api/user`,
|
||||
{
|
||||
@ -36,9 +56,20 @@ export default async function AppAuthLayout({
|
||||
id: user.id,
|
||||
name: user.firstName + " " + user.lastName,
|
||||
email: user.emailAddresses[0].emailAddress,
|
||||
username: username,
|
||||
avatarUrl: user.imageUrl || null,
|
||||
createdAt: new Date().toISOString(),
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.text();
|
||||
console.error("Failed to create user:", error);
|
||||
} else {
|
||||
const data = await res.json();
|
||||
console.log("User created successfully:", data);
|
||||
}
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
|
@ -1,57 +1,61 @@
|
||||
import { colors } from "@/lib/colors"
|
||||
import { User } from "@/lib/types"
|
||||
// import { colors } from "@/lib/colors"
|
||||
// import { User } from "@/lib/types"
|
||||
import { currentUser } from "@clerk/nextjs"
|
||||
import { Liveblocks } from "@liveblocks/node"
|
||||
// import { Liveblocks } from "@liveblocks/node"
|
||||
import { NextRequest } from "next/server"
|
||||
|
||||
const API_KEY = process.env.LIVEBLOCKS_SECRET_KEY!
|
||||
// const API_KEY = process.env.LIVEBLOCKS_SECRET_KEY!
|
||||
|
||||
const liveblocks = new Liveblocks({
|
||||
secret: API_KEY!,
|
||||
})
|
||||
// const liveblocks = new Liveblocks({
|
||||
// secret: API_KEY!,
|
||||
// })
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const clerkUser = await currentUser()
|
||||
// Temporarily return unauthorized while Liveblocks is disabled
|
||||
return new Response("Liveblocks collaboration temporarily disabled", { status: 503 })
|
||||
|
||||
if (!clerkUser) {
|
||||
return new Response("Unauthorized", { status: 401 })
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_DATABASE_WORKER_URL}/api/user?id=${clerkUser.id}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `${process.env.NEXT_PUBLIC_WORKERS_KEY}`,
|
||||
},
|
||||
}
|
||||
)
|
||||
const user = (await res.json()) as User
|
||||
|
||||
const colorNames = Object.keys(colors)
|
||||
const randomColor = colorNames[
|
||||
Math.floor(Math.random() * colorNames.length)
|
||||
] as keyof typeof colors
|
||||
const code = colors[randomColor]
|
||||
|
||||
// Create a session for the current user
|
||||
// userInfo is made available in Liveblocks presence hooks, e.g. useOthers
|
||||
const session = liveblocks.prepareSession(user.id, {
|
||||
userInfo: {
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
color: randomColor,
|
||||
},
|
||||
})
|
||||
|
||||
// Give the user access to the room
|
||||
user.sandbox.forEach((sandbox) => {
|
||||
session.allow(`${sandbox.id}`, session.FULL_ACCESS)
|
||||
})
|
||||
user.usersToSandboxes.forEach((userToSandbox) => {
|
||||
session.allow(`${userToSandbox.sandboxId}`, session.FULL_ACCESS)
|
||||
})
|
||||
|
||||
// Authorize the user and return the result
|
||||
const { body, status } = await session.authorize()
|
||||
return new Response(body, { status })
|
||||
// Original implementation commented out:
|
||||
// const clerkUser = await currentUser()
|
||||
//
|
||||
// if (!clerkUser) {
|
||||
// return new Response("Unauthorized", { status: 401 })
|
||||
// }
|
||||
//
|
||||
// const res = await fetch(
|
||||
// `${process.env.NEXT_PUBLIC_DATABASE_WORKER_URL}/api/user?id=${clerkUser.id}`,
|
||||
// {
|
||||
// headers: {
|
||||
// Authorization: `${process.env.NEXT_PUBLIC_WORKERS_KEY}`,
|
||||
// },
|
||||
// }
|
||||
// )
|
||||
// const user = (await res.json()) as User
|
||||
//
|
||||
// const colorNames = Object.keys(colors)
|
||||
// const randomColor = colorNames[
|
||||
// Math.floor(Math.random() * colorNames.length)
|
||||
// ] as keyof typeof colors
|
||||
// const code = colors[randomColor]
|
||||
//
|
||||
// // Create a session for the current user
|
||||
// // userInfo is made available in Liveblocks presence hooks, e.g. useOthers
|
||||
// const session = liveblocks.prepareSession(user.id, {
|
||||
// userInfo: {
|
||||
// name: user.name,
|
||||
// email: user.email,
|
||||
// color: randomColor,
|
||||
// },
|
||||
// })
|
||||
//
|
||||
// // Give the user access to the room
|
||||
// user.sandbox.forEach((sandbox) => {
|
||||
// session.allow(`${sandbox.id}`, session.FULL_ACCESS)
|
||||
// })
|
||||
// user.usersToSandboxes.forEach((userToSandbox) => {
|
||||
// session.allow(`${userToSandbox.sandboxId}`, session.FULL_ACCESS)
|
||||
// })
|
||||
//
|
||||
// // Authorize the user and return the result
|
||||
// const { body, status } = await session.authorize()
|
||||
// return new Response(body, { status })
|
||||
}
|
||||
|
@ -99,6 +99,29 @@
|
||||
); /* violet 900 -> bg */
|
||||
}
|
||||
|
||||
.light .gradient-button-bg {
|
||||
background: radial-gradient(
|
||||
circle at top,
|
||||
#262626 0%,
|
||||
#f5f5f5 50%
|
||||
); /* Dark gray -> Light gray */
|
||||
}
|
||||
|
||||
.light .gradient-button {
|
||||
background: radial-gradient(
|
||||
circle at bottom,
|
||||
hsl(0, 10%, 25%) -10%,
|
||||
#9d9d9d 50%
|
||||
); /* Light gray -> Almost white */
|
||||
}
|
||||
|
||||
.light .gradient-button-bg > div:hover {
|
||||
background: radial-gradient(
|
||||
circle at bottom,
|
||||
hsl(0, 10%, 25%) -10%,
|
||||
#9d9d9d 80%
|
||||
); /* Light gray -> Almost white */
|
||||
}
|
||||
.inline-decoration::before {
|
||||
content: "Generate";
|
||||
color: #525252;
|
||||
|
@ -1,14 +1,13 @@
|
||||
import type { Metadata } from "next"
|
||||
import { GeistSans } from "geist/font/sans"
|
||||
import { GeistMono } from "geist/font/mono"
|
||||
import "./globals.css"
|
||||
import { ThemeProvider } from "@/components/layout/themeProvider"
|
||||
import { ClerkProvider } from "@clerk/nextjs"
|
||||
import { Toaster } from "@/components/ui/sonner"
|
||||
import { Analytics } from "@vercel/analytics/react"
|
||||
import { TerminalProvider } from '@/context/TerminalContext';
|
||||
import { PreviewProvider } from "@/context/PreviewContext";
|
||||
import { ThemeProvider } from "@/components/ui/theme-provider"
|
||||
import { PreviewProvider } from "@/context/PreviewContext"
|
||||
import { SocketProvider } from '@/context/SocketContext'
|
||||
import { ClerkProvider } from "@clerk/nextjs"
|
||||
import { Analytics } from "@vercel/analytics/react"
|
||||
import { GeistMono } from "geist/font/mono"
|
||||
import { GeistSans } from "geist/font/sans"
|
||||
import type { Metadata } from "next"
|
||||
import "./globals.css"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Sandbox",
|
||||
@ -16,7 +15,7 @@ export const metadata: Metadata = {
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode
|
||||
}>) {
|
||||
@ -26,16 +25,11 @@ export default function RootLayout({
|
||||
<body>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="dark"
|
||||
forcedTheme="dark"
|
||||
defaultTheme="system"
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<SocketProvider>
|
||||
<PreviewProvider>
|
||||
<TerminalProvider>
|
||||
{children}
|
||||
</TerminalProvider>
|
||||
</PreviewProvider>
|
||||
<PreviewProvider>{children}</PreviewProvider>
|
||||
</SocketProvider>
|
||||
<Analytics />
|
||||
<Toaster position="bottom-left" richColors />
|
||||
@ -44,4 +38,4 @@ export default function RootLayout({
|
||||
</html>
|
||||
</ClerkProvider>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -1,13 +1,13 @@
|
||||
import { currentUser } from "@clerk/nextjs";
|
||||
import { redirect } from "next/navigation";
|
||||
import Landing from "@/components/landing";
|
||||
import Landing from "@/components/landing"
|
||||
import { currentUser } from "@clerk/nextjs"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
export default async function Home() {
|
||||
const user = await currentUser();
|
||||
const user = await currentUser()
|
||||
|
||||
if (user) {
|
||||
redirect("/dashboard");
|
||||
redirect("/dashboard")
|
||||
}
|
||||
|
||||
return <Landing />;
|
||||
return <Landing />
|
||||
}
|
||||
|
@ -3,16 +3,9 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import Image from "next/image"
|
||||
import { useState } from "react"
|
||||
|
||||
import { Button } from "../ui/button"
|
||||
import { ChevronRight } from "lucide-react"
|
||||
|
||||
export default function AboutModal({
|
||||
open,
|
||||
@ -25,11 +18,38 @@ export default function AboutModal({
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>About this project</DialogTitle>
|
||||
<DialogTitle>Help & Support</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Sandbox is an open-source cloud-based code editing environment with
|
||||
custom AI code autocompletion and real-time collaboration.
|
||||
<div className="space-y-4">
|
||||
{/* <div className="text-sm text-muted-foreground">
|
||||
Sandbox is an open-source cloud-based code editing environment with
|
||||
custom AI code autocompletion and real-time collaboration.
|
||||
</div> */}
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Get help and support through our Discord community or by creating issues on GitHub:
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm">
|
||||
<a
|
||||
href="https://discord.gitwit.dev/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Join our Discord community →
|
||||
</a>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<a
|
||||
href="https://github.com/jamesmurdza/sandbox/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Report issues on GitHub →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
@ -1,24 +1,16 @@
|
||||
"use client"
|
||||
|
||||
import CustomButton from "@/components/ui/customButton"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Code2,
|
||||
FolderDot,
|
||||
HelpCircle,
|
||||
Plus,
|
||||
Settings,
|
||||
Users,
|
||||
} from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import CustomButton from "@/components/ui/customButton"
|
||||
import { Sandbox } from "@/lib/types"
|
||||
import { Code2, FolderDot, HelpCircle, Plus, Users } from "lucide-react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import AboutModal from "./about"
|
||||
import NewProjectModal from "./newProject"
|
||||
import DashboardProjects from "./projects"
|
||||
import DashboardSharedWithMe from "./shared"
|
||||
import NewProjectModal from "./newProject"
|
||||
import Link from "next/link"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import AboutModal from "./about"
|
||||
import { toast } from "sonner"
|
||||
|
||||
type TScreen = "projects" | "shared" | "settings" | "search"
|
||||
|
||||
@ -33,6 +25,7 @@ export default function Dashboard({
|
||||
type: "react" | "node"
|
||||
author: string
|
||||
sharedOn: Date
|
||||
authorAvatarUrl?: string
|
||||
}[]
|
||||
}) {
|
||||
const [screen, setScreen] = useState<TScreen>("projects")
|
||||
@ -50,10 +43,9 @@ export default function Dashboard({
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
if (!sandboxes) {
|
||||
router.refresh()
|
||||
}
|
||||
}, [sandboxes])
|
||||
// update the dashboard to show a new project
|
||||
router.refresh()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -86,14 +78,14 @@ export default function Dashboard({
|
||||
<FolderDot className="w-4 h-4 mr-2" />
|
||||
My Projects
|
||||
</Button>
|
||||
<Button
|
||||
{/* <Button
|
||||
variant="ghost"
|
||||
onClick={() => setScreen("shared")}
|
||||
className={activeScreen("shared")}
|
||||
>
|
||||
<Users className="w-4 h-4 mr-2" />
|
||||
Shared With Me
|
||||
</Button>
|
||||
</Button> */}
|
||||
{/* <Button
|
||||
variant="ghost"
|
||||
onClick={() => setScreen("settings")}
|
||||
@ -104,7 +96,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"
|
||||
@ -119,7 +111,7 @@ export default function Dashboard({
|
||||
className="justify-start font-normal text-muted-foreground"
|
||||
>
|
||||
<HelpCircle className="w-4 h-4 mr-2" />
|
||||
About
|
||||
Help
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@ -130,7 +122,12 @@ export default function Dashboard({
|
||||
) : null}
|
||||
</>
|
||||
) : screen === "shared" ? (
|
||||
<DashboardSharedWithMe shared={shared} />
|
||||
<DashboardSharedWithMe
|
||||
shared={shared.map((item) => ({
|
||||
...item,
|
||||
authorAvatarUrl: item.authorAvatarUrl || "",
|
||||
}))}
|
||||
/>
|
||||
) : screen === "settings" ? null : null}
|
||||
</div>
|
||||
</>
|
||||
|
@ -1,9 +1,10 @@
|
||||
import Logo from "@/assets/logo.svg"
|
||||
import { ThemeSwitcher } from "@/components/ui/theme-switcher"
|
||||
import { User } from "@/lib/types"
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
import Logo from "@/assets/logo.svg"
|
||||
import DashboardNavbarSearch from "./search"
|
||||
import UserButton from "../../ui/userButton"
|
||||
import { User } from "@/lib/types"
|
||||
import DashboardNavbarSearch from "./search"
|
||||
|
||||
export default function DashboardNavbar({ userData }: { userData: User }) {
|
||||
return (
|
||||
@ -19,6 +20,7 @@ export default function DashboardNavbar({ userData }: { userData: User }) {
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<DashboardNavbarSearch />
|
||||
<ThemeSwitcher />
|
||||
<UserButton userData={userData} />
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,13 +1,12 @@
|
||||
"use client";
|
||||
"use client"
|
||||
|
||||
import { Input } from "../../ui/input";
|
||||
import { Search } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Search } from "lucide-react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Input } from "../../ui/input"
|
||||
|
||||
export default function DashboardNavbarSearch() {
|
||||
// const [search, setSearch] = useState("");
|
||||
const router = useRouter();
|
||||
const router = useRouter()
|
||||
|
||||
// useEffect(() => {
|
||||
// const delayDebounceFn = setTimeout(() => {
|
||||
@ -29,14 +28,14 @@ export default function DashboardNavbarSearch() {
|
||||
// onChange={(e) => setSearch(e.target.value)}
|
||||
onChange={(e) => {
|
||||
if (e.target.value === "") {
|
||||
router.push(`/dashboard`);
|
||||
return;
|
||||
router.push(`/dashboard`)
|
||||
return
|
||||
}
|
||||
router.push(`/dashboard?q=${e.target.value}`);
|
||||
router.push(`/dashboard?q=${e.target.value}`)
|
||||
}}
|
||||
placeholder="Search projects..."
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
@ -3,16 +3,14 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import Image from "next/image"
|
||||
import { useState } from "react"
|
||||
import { set, z } from "zod"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import Image from "next/image"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { z } from "zod"
|
||||
|
||||
import {
|
||||
Form,
|
||||
@ -31,49 +29,17 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { useUser } from "@clerk/nextjs"
|
||||
import { createSandbox } from "@/lib/actions"
|
||||
import { projectTemplates } from "@/lib/data"
|
||||
import { useUser } from "@clerk/nextjs"
|
||||
import { ChevronLeft, ChevronRight, Loader2, Search } from "lucide-react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { Button } from "../ui/button"
|
||||
|
||||
const data: {
|
||||
id: string
|
||||
name: string
|
||||
icon: string
|
||||
description: string
|
||||
disabled: boolean
|
||||
}[] = [
|
||||
{
|
||||
id: "reactjs",
|
||||
name: "React",
|
||||
icon: "/project-icons/react.svg",
|
||||
description: "A JavaScript library for building user interfaces",
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
id: "vanillajs",
|
||||
name: "HTML/JS",
|
||||
icon: "/project-icons/more.svg",
|
||||
description: "More coming soon, feel free to contribute on GitHub",
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
id: "nextjs",
|
||||
name: "NextJS",
|
||||
icon: "/project-icons/node.svg",
|
||||
description: "A JavaScript runtime built on the V8 JavaScript engine",
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
id: "streamlit",
|
||||
name: "Streamlit",
|
||||
icon: "/project-icons/python.svg",
|
||||
description: "A JavaScript runtime built on the V8 JavaScript engine",
|
||||
disabled: false,
|
||||
}
|
||||
]
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { EmblaCarouselType } from "embla-carousel"
|
||||
import useEmblaCarousel from "embla-carousel-react"
|
||||
import { WheelGesturesPlugin } from "embla-carousel-wheel-gestures"
|
||||
const formSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
@ -93,11 +59,20 @@ export default function NewProjectModal({
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
}) {
|
||||
const [selected, setSelected] = useState("react")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const router = useRouter()
|
||||
|
||||
const user = useUser()
|
||||
const [selected, setSelected] = useState("reactjs")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: false }, [
|
||||
WheelGesturesPlugin(),
|
||||
])
|
||||
const {
|
||||
prevBtnDisabled,
|
||||
nextBtnDisabled,
|
||||
onPrevButtonClick,
|
||||
onNextButtonClick,
|
||||
} = usePrevNextButtons(emblaApi)
|
||||
const [search, setSearch] = useState("")
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
@ -107,6 +82,26 @@ export default function NewProjectModal({
|
||||
},
|
||||
})
|
||||
|
||||
const handleTemplateClick = useCallback(
|
||||
({ id, index }: { id: string; index: number }) => {
|
||||
setSelected(id)
|
||||
emblaApi?.scrollTo(index)
|
||||
},
|
||||
[emblaApi]
|
||||
)
|
||||
const filteredTemplates = useMemo(
|
||||
() =>
|
||||
projectTemplates.filter(
|
||||
(item) =>
|
||||
item.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
item.description.toLowerCase().includes(search.toLowerCase())
|
||||
),
|
||||
[search, projectTemplates]
|
||||
)
|
||||
const emptyTemplates = useMemo(
|
||||
() => filteredTemplates.length === 0,
|
||||
[filteredTemplates]
|
||||
)
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
if (!user.isSignedIn) return
|
||||
|
||||
@ -116,7 +111,6 @@ export default function NewProjectModal({
|
||||
const id = await createSandbox(sandboxData)
|
||||
router.push(`/code/${id}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
@ -124,29 +118,93 @@ export default function NewProjectModal({
|
||||
if (!loading) setOpen(open)
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogContent className="max-h-[95vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create A Sandbox</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid grid-cols-2 w-full gap-2 mt-2">
|
||||
{data.map((item) => (
|
||||
<button
|
||||
disabled={item.disabled || loading}
|
||||
key={item.id}
|
||||
onClick={() => setSelected(item.id)}
|
||||
className={`${
|
||||
selected === item.id ? "border-foreground" : "border-border"
|
||||
} rounded-md border bg-card text-card-foreground shadow text-left p-4 flex flex-col transition-all focus-visible:outline-none focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||
<div className="flex flex-col gap-2 max-w-full overflow-hidden">
|
||||
<div className="flex items-center justify-end">
|
||||
<SearchInput
|
||||
{...{
|
||||
value: search,
|
||||
onValueChange: setSearch,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="overflow-hidden relative" ref={emblaRef}>
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-flow-col gap-x-2 min-h-[97px]",
|
||||
emptyTemplates ? "auto-cols-[100%]" : "auto-cols-[200px]"
|
||||
)}
|
||||
>
|
||||
<div className="space-x-2 flex items-center justify-start w-full">
|
||||
<Image alt="" src={item.icon} width={20} height={20} />
|
||||
<div className="font-medium">{item.name}</div>
|
||||
</div>
|
||||
<div className="mt-2 text-muted-foreground text-sm">
|
||||
{item.description}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{filteredTemplates.map((item, i) => (
|
||||
<button
|
||||
disabled={item.disabled || loading}
|
||||
key={item.id}
|
||||
onClick={handleTemplateClick.bind(null, {
|
||||
id: item.id,
|
||||
index: i,
|
||||
})}
|
||||
className={cn(
|
||||
selected === item.id
|
||||
? "shadow-foreground"
|
||||
: "shadow-border",
|
||||
"shadow-[0_0_0_1px_inset] rounded-md border bg-card text-card-foreground text-left p-4 flex flex-col transition-all focus-visible:outline-none focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
<div className="space-x-2 flex items-center justify-start w-full">
|
||||
<Image alt="" src={item.icon} width={20} height={20} />
|
||||
<div className="font-medium">{item.name}</div>
|
||||
</div>
|
||||
<div className="mt-2 text-muted-foreground text-xs line-clamp-2">
|
||||
{item.description}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{emptyTemplates && (
|
||||
<div className="flex flex-col gap-2 items-center text-center justify-center text-muted-foreground text-sm">
|
||||
<p>No templates found</p>
|
||||
<Button size="xs" asChild>
|
||||
<a
|
||||
href="https://github.com/jamesmurdza/sandbox"
|
||||
target="_blank"
|
||||
>
|
||||
Contribute
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute transition-all opacity-100 duration-400 bg-gradient-to-r from-background via-background to-transparent w-14 pl-1 left-0 top-0 -translate-x-1 bottom-0 h-full flex items-center",
|
||||
prevBtnDisabled && "opacity-0 pointer-events-none"
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
size="smIcon"
|
||||
className="rounded-full"
|
||||
onClick={onPrevButtonClick}
|
||||
>
|
||||
<ChevronLeft className="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute transition-all opacity-100 duration-400 bg-gradient-to-l from-background via-background to-transparent w-14 pl-1 right-0 top-0 translate-x-1 bottom-0 h-full flex items-center",
|
||||
nextBtnDisabled && "opacity-0 pointer-events-none"
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
size="smIcon"
|
||||
className="rounded-full"
|
||||
onClick={onNextButtonClick}
|
||||
>
|
||||
<ChevronRight className="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
@ -214,3 +272,68 @@ export default function NewProjectModal({
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function SearchInput({
|
||||
value,
|
||||
onValueChange,
|
||||
}: {
|
||||
value?: string
|
||||
onValueChange?: (value: string) => void
|
||||
}) {
|
||||
const onSubmit = useCallback((e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
console.log("searching")
|
||||
}, [])
|
||||
return (
|
||||
<form {...{ onSubmit }} className="w-40 h-8 ">
|
||||
<label
|
||||
htmlFor="template-search"
|
||||
className="flex gap-2 rounded-sm transition-colors bg-gray-100 dark:bg-[#2e2e2e] border border-[--s-color] [--s-color:hsl(var(--muted-foreground))] focus-within:[--s-color:hsl(var(--muted-foreground),50%)] h-full items-center px-2"
|
||||
>
|
||||
<Search className="size-4 text-[--s-color] transition-colors" />
|
||||
<input
|
||||
id="template-search"
|
||||
type="text"
|
||||
name="search"
|
||||
placeholder="Search templates"
|
||||
value={value}
|
||||
onChange={(e) => onValueChange?.(e.target.value)}
|
||||
className="bg-transparent placeholder:text-muted-foreground w-full focus:outline-none text-xs"
|
||||
/>
|
||||
</label>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
const usePrevNextButtons = (emblaApi: EmblaCarouselType | undefined) => {
|
||||
const [prevBtnDisabled, setPrevBtnDisabled] = useState(true)
|
||||
const [nextBtnDisabled, setNextBtnDisabled] = useState(true)
|
||||
|
||||
const onPrevButtonClick = useCallback(() => {
|
||||
if (!emblaApi) return
|
||||
emblaApi.scrollPrev()
|
||||
}, [emblaApi])
|
||||
|
||||
const onNextButtonClick = useCallback(() => {
|
||||
if (!emblaApi) return
|
||||
emblaApi.scrollNext()
|
||||
}, [emblaApi])
|
||||
|
||||
const onSelect = useCallback((emblaApi: EmblaCarouselType) => {
|
||||
setPrevBtnDisabled(!emblaApi.canScrollPrev())
|
||||
setNextBtnDisabled(!emblaApi.canScrollNext())
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!emblaApi) return
|
||||
|
||||
onSelect(emblaApi)
|
||||
emblaApi.on("reInit", onSelect).on("select", onSelect)
|
||||
}, [emblaApi, onSelect])
|
||||
|
||||
return {
|
||||
prevBtnDisabled,
|
||||
nextBtnDisabled,
|
||||
onPrevButtonClick,
|
||||
onNextButtonClick,
|
||||
}
|
||||
}
|
||||
|
@ -1,30 +1,30 @@
|
||||
"use client";
|
||||
"use client"
|
||||
|
||||
import { Sandbox } from "@/lib/types";
|
||||
import { Ellipsis, Globe, Lock, Trash2 } from "lucide-react";
|
||||
import { Sandbox } from "@/lib/types"
|
||||
import { Ellipsis, Globe, Lock, Trash2 } from "lucide-react"
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
|
||||
export default function ProjectCardDropdown({
|
||||
sandbox,
|
||||
onVisibilityChange,
|
||||
onDelete,
|
||||
}: {
|
||||
sandbox: Sandbox;
|
||||
onVisibilityChange: (sandbox: Sandbox) => void;
|
||||
onDelete: (sandbox: Sandbox) => void;
|
||||
sandbox: Sandbox
|
||||
onVisibilityChange: (sandbox: Sandbox) => void
|
||||
onDelete: (sandbox: Sandbox) => void
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
className="h-6 w-6 flex items-center justify-center transition-colors bg-transparent hover:bg-muted-foreground/25 rounded-sm outline-foreground"
|
||||
>
|
||||
@ -33,8 +33,8 @@ export default function ProjectCardDropdown({
|
||||
<DropdownMenuContent className="w-40">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onVisibilityChange(sandbox);
|
||||
e.stopPropagation()
|
||||
onVisibilityChange(sandbox)
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
@ -52,8 +52,8 @@ export default function ProjectCardDropdown({
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(sandbox);
|
||||
e.stopPropagation()
|
||||
onDelete(sandbox)
|
||||
}}
|
||||
className="!text-destructive cursor-pointer"
|
||||
>
|
||||
@ -62,5 +62,5 @@ export default function ProjectCardDropdown({
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
@ -1,13 +1,14 @@
|
||||
"use client"
|
||||
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { projectTemplates } from "@/lib/data"
|
||||
import { Sandbox } from "@/lib/types"
|
||||
import { AnimatePresence, motion } from "framer-motion"
|
||||
import { Clock, Globe, Lock } from "lucide-react"
|
||||
import Image from "next/image"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useEffect, useState } from "react"
|
||||
import ProjectCardDropdown from "./dropdown"
|
||||
import { Clock, Globe, Lock } from "lucide-react"
|
||||
import { Sandbox } from "@/lib/types"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
export default function ProjectCard({
|
||||
children,
|
||||
@ -43,7 +44,9 @@ export default function ProjectCard({
|
||||
setDate(`${Math.floor(diffInMinutes / 1440)}d ago`)
|
||||
}
|
||||
}, [sandbox])
|
||||
|
||||
const projectIcon =
|
||||
projectTemplates.find((p) => p.id === sandbox.type)?.icon ??
|
||||
"/project-icons/node.svg"
|
||||
return (
|
||||
<Card
|
||||
tabIndex={0}
|
||||
@ -65,16 +68,7 @@ export default function ProjectCard({
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="space-x-2 flex items-center justify-start w-full z-10">
|
||||
<Image
|
||||
alt=""
|
||||
src={
|
||||
sandbox.type === "react"
|
||||
? "/project-icons/react.svg"
|
||||
: "/project-icons/node.svg"
|
||||
}
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
<Image alt="" src={projectIcon} width={20} height={20} />
|
||||
<div className="font-medium static whitespace-nowrap w-full text-ellipsis overflow-hidden">
|
||||
{sandbox.name}
|
||||
</div>
|
||||
|
@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Canvas, useFrame, useThree } from "@react-three/fiber";
|
||||
import React, { useMemo, useRef } from "react";
|
||||
import * as THREE from "three";
|
||||
"use client"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Canvas, useFrame, useThree } from "@react-three/fiber"
|
||||
import React, { useMemo, useRef } from "react"
|
||||
import * as THREE from "three"
|
||||
|
||||
export const CanvasRevealEffect = ({
|
||||
animationSpeed = 0.4,
|
||||
@ -12,12 +12,12 @@ export const CanvasRevealEffect = ({
|
||||
dotSize,
|
||||
showGradient = true,
|
||||
}: {
|
||||
animationSpeed?: number;
|
||||
opacities?: number[];
|
||||
colors?: number[][];
|
||||
containerClassName?: string;
|
||||
dotSize?: number;
|
||||
showGradient?: boolean;
|
||||
animationSpeed?: number
|
||||
opacities?: number[]
|
||||
colors?: number[][]
|
||||
containerClassName?: string
|
||||
dotSize?: number
|
||||
showGradient?: boolean
|
||||
}) => {
|
||||
return (
|
||||
<div className={cn("h-full relative bg-white w-full", containerClassName)}>
|
||||
@ -41,16 +41,16 @@ export const CanvasRevealEffect = ({
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-background to-[100%]" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
interface DotMatrixProps {
|
||||
colors?: number[][];
|
||||
opacities?: number[];
|
||||
totalSize?: number;
|
||||
dotSize?: number;
|
||||
shader?: string;
|
||||
center?: ("x" | "y")[];
|
||||
colors?: number[][]
|
||||
opacities?: number[]
|
||||
totalSize?: number
|
||||
dotSize?: number
|
||||
shader?: string
|
||||
center?: ("x" | "y")[]
|
||||
}
|
||||
|
||||
const DotMatrix: React.FC<DotMatrixProps> = ({
|
||||
@ -69,7 +69,7 @@ const DotMatrix: React.FC<DotMatrixProps> = ({
|
||||
colors[0],
|
||||
colors[0],
|
||||
colors[0],
|
||||
];
|
||||
]
|
||||
if (colors.length === 2) {
|
||||
colorsArray = [
|
||||
colors[0],
|
||||
@ -78,7 +78,7 @@ const DotMatrix: React.FC<DotMatrixProps> = ({
|
||||
colors[1],
|
||||
colors[1],
|
||||
colors[1],
|
||||
];
|
||||
]
|
||||
} else if (colors.length === 3) {
|
||||
colorsArray = [
|
||||
colors[0],
|
||||
@ -87,7 +87,7 @@ const DotMatrix: React.FC<DotMatrixProps> = ({
|
||||
colors[1],
|
||||
colors[2],
|
||||
colors[2],
|
||||
];
|
||||
]
|
||||
}
|
||||
|
||||
return {
|
||||
@ -111,8 +111,8 @@ const DotMatrix: React.FC<DotMatrixProps> = ({
|
||||
value: dotSize,
|
||||
type: "uniform1f",
|
||||
},
|
||||
};
|
||||
}, [colors, opacities, totalSize, dotSize]);
|
||||
}
|
||||
}, [colors, opacities, totalSize, dotSize])
|
||||
|
||||
return (
|
||||
<Shader
|
||||
@ -168,87 +168,87 @@ const DotMatrix: React.FC<DotMatrixProps> = ({
|
||||
uniforms={uniforms}
|
||||
maxFps={60}
|
||||
/>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
type Uniforms = {
|
||||
[key: string]: {
|
||||
value: number[] | number[][] | number;
|
||||
type: string;
|
||||
};
|
||||
};
|
||||
value: number[] | number[][] | number
|
||||
type: string
|
||||
}
|
||||
}
|
||||
const ShaderMaterial = ({
|
||||
source,
|
||||
uniforms,
|
||||
maxFps = 60,
|
||||
}: {
|
||||
source: string;
|
||||
hovered?: boolean;
|
||||
maxFps?: number;
|
||||
uniforms: Uniforms;
|
||||
source: string
|
||||
hovered?: boolean
|
||||
maxFps?: number
|
||||
uniforms: Uniforms
|
||||
}) => {
|
||||
const { size } = useThree();
|
||||
const ref = useRef<THREE.Mesh>();
|
||||
let lastFrameTime = 0;
|
||||
const { size } = useThree()
|
||||
const ref = useRef<THREE.Mesh>()
|
||||
let lastFrameTime = 0
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
if (!ref.current) return;
|
||||
const timestamp = clock.getElapsedTime();
|
||||
if (!ref.current) return
|
||||
const timestamp = clock.getElapsedTime()
|
||||
if (timestamp - lastFrameTime < 1 / maxFps) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
lastFrameTime = timestamp;
|
||||
lastFrameTime = timestamp
|
||||
|
||||
const material: any = ref.current.material;
|
||||
const timeLocation = material.uniforms.u_time;
|
||||
timeLocation.value = timestamp;
|
||||
});
|
||||
const material: any = ref.current.material
|
||||
const timeLocation = material.uniforms.u_time
|
||||
timeLocation.value = timestamp
|
||||
})
|
||||
|
||||
const getUniforms = () => {
|
||||
const preparedUniforms: any = {};
|
||||
const preparedUniforms: any = {}
|
||||
|
||||
for (const uniformName in uniforms) {
|
||||
const uniform: any = uniforms[uniformName];
|
||||
const uniform: any = uniforms[uniformName]
|
||||
|
||||
switch (uniform.type) {
|
||||
case "uniform1f":
|
||||
preparedUniforms[uniformName] = { value: uniform.value, type: "1f" };
|
||||
break;
|
||||
preparedUniforms[uniformName] = { value: uniform.value, type: "1f" }
|
||||
break
|
||||
case "uniform3f":
|
||||
preparedUniforms[uniformName] = {
|
||||
value: new THREE.Vector3().fromArray(uniform.value),
|
||||
type: "3f",
|
||||
};
|
||||
break;
|
||||
}
|
||||
break
|
||||
case "uniform1fv":
|
||||
preparedUniforms[uniformName] = { value: uniform.value, type: "1fv" };
|
||||
break;
|
||||
preparedUniforms[uniformName] = { value: uniform.value, type: "1fv" }
|
||||
break
|
||||
case "uniform3fv":
|
||||
preparedUniforms[uniformName] = {
|
||||
value: uniform.value.map((v: number[]) =>
|
||||
new THREE.Vector3().fromArray(v)
|
||||
),
|
||||
type: "3fv",
|
||||
};
|
||||
break;
|
||||
}
|
||||
break
|
||||
case "uniform2f":
|
||||
preparedUniforms[uniformName] = {
|
||||
value: new THREE.Vector2().fromArray(uniform.value),
|
||||
type: "2f",
|
||||
};
|
||||
break;
|
||||
}
|
||||
break
|
||||
default:
|
||||
console.error(`Invalid uniform type for '${uniformName}'.`);
|
||||
break;
|
||||
console.error(`Invalid uniform type for '${uniformName}'.`)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
preparedUniforms["u_time"] = { value: 0, type: "1f" };
|
||||
preparedUniforms["u_time"] = { value: 0, type: "1f" }
|
||||
preparedUniforms["u_resolution"] = {
|
||||
value: new THREE.Vector2(size.width * 2, size.height * 2),
|
||||
}; // Initialize u_resolution
|
||||
return preparedUniforms;
|
||||
};
|
||||
} // Initialize u_resolution
|
||||
return preparedUniforms
|
||||
}
|
||||
|
||||
// Shader material
|
||||
const material = useMemo(() => {
|
||||
@ -272,33 +272,33 @@ const ShaderMaterial = ({
|
||||
blending: THREE.CustomBlending,
|
||||
blendSrc: THREE.SrcAlphaFactor,
|
||||
blendDst: THREE.OneFactor,
|
||||
});
|
||||
})
|
||||
|
||||
return materialObject;
|
||||
}, [size.width, size.height, source]);
|
||||
return materialObject
|
||||
}, [size.width, size.height, source])
|
||||
|
||||
return (
|
||||
<mesh ref={ref as any}>
|
||||
<planeGeometry args={[2, 2]} />
|
||||
<primitive object={material} attach="material" />
|
||||
</mesh>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
const Shader: React.FC<ShaderProps> = ({ source, uniforms, maxFps = 60 }) => {
|
||||
return (
|
||||
<Canvas className="absolute inset-0 h-full w-full">
|
||||
<ShaderMaterial source={source} uniforms={uniforms} maxFps={maxFps} />
|
||||
</Canvas>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
interface ShaderProps {
|
||||
source: string;
|
||||
source: string
|
||||
uniforms: {
|
||||
[key: string]: {
|
||||
value: number[] | number[][] | number;
|
||||
type: string;
|
||||
};
|
||||
};
|
||||
maxFps?: number;
|
||||
value: number[] | number[][] | number
|
||||
type: string
|
||||
}
|
||||
}
|
||||
maxFps?: number
|
||||
}
|
||||
|
@ -1,16 +1,12 @@
|
||||
"use client";
|
||||
"use client"
|
||||
|
||||
import { Sandbox } from "@/lib/types";
|
||||
import ProjectCard from "./projectCard";
|
||||
import Image from "next/image";
|
||||
import ProjectCardDropdown from "./projectCard/dropdown";
|
||||
import { Clock, Globe, Lock } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Card } from "../ui/card";
|
||||
import { deleteSandbox, updateSandbox } from "@/lib/actions";
|
||||
import { toast } from "sonner";
|
||||
import { useEffect, useState } from "react";
|
||||
import { CanvasRevealEffect } from "./projectCard/revealEffect";
|
||||
import { deleteSandbox, updateSandbox } from "@/lib/actions"
|
||||
import { Sandbox } from "@/lib/types"
|
||||
import Link from "next/link"
|
||||
import { useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import ProjectCard from "./projectCard"
|
||||
import { CanvasRevealEffect } from "./projectCard/revealEffect"
|
||||
|
||||
const colors: { [key: string]: number[][] } = {
|
||||
react: [
|
||||
@ -21,38 +17,37 @@ const colors: { [key: string]: number[][] } = {
|
||||
[86, 184, 72],
|
||||
[59, 112, 52],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export default function DashboardProjects({
|
||||
sandboxes,
|
||||
q,
|
||||
}: {
|
||||
sandboxes: Sandbox[];
|
||||
q: string | null;
|
||||
sandboxes: Sandbox[]
|
||||
q: string | null
|
||||
}) {
|
||||
const [deletingId, setDeletingId] = useState<string>("");
|
||||
const [deletingId, setDeletingId] = useState<string>("")
|
||||
|
||||
const onDelete = async (sandbox: Sandbox) => {
|
||||
setDeletingId(sandbox.id);
|
||||
toast(`Project ${sandbox.name} deleted.`);
|
||||
await deleteSandbox(sandbox.id);
|
||||
};
|
||||
setDeletingId(sandbox.id)
|
||||
toast(`Project ${sandbox.name} deleted.`)
|
||||
await deleteSandbox(sandbox.id)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (deletingId) {
|
||||
setDeletingId("");
|
||||
setDeletingId("")
|
||||
}
|
||||
}, [sandboxes]);
|
||||
}, [sandboxes])
|
||||
|
||||
const onVisibilityChange = async (sandbox: Sandbox) => {
|
||||
const newVisibility =
|
||||
sandbox.visibility === "public" ? "private" : "public";
|
||||
toast(`Project ${sandbox.name} is now ${newVisibility}.`);
|
||||
const newVisibility = sandbox.visibility === "public" ? "private" : "public"
|
||||
toast(`Project ${sandbox.name} is now ${newVisibility}.`)
|
||||
await updateSandbox({
|
||||
id: sandbox.id,
|
||||
visibility: newVisibility,
|
||||
});
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grow p-4 flex flex-col">
|
||||
@ -65,7 +60,7 @@ export default function DashboardProjects({
|
||||
{sandboxes.map((sandbox) => {
|
||||
if (q && q.length > 0) {
|
||||
if (!sandbox.name.toLowerCase().includes(q.toLowerCase())) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
}
|
||||
return (
|
||||
@ -93,7 +88,7 @@ export default function DashboardProjects({
|
||||
<div className="absolute inset-0 [mask-image:radial-gradient(400px_at_center,white,transparent)] bg-background/75" />
|
||||
</ProjectCard>
|
||||
</Link>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
@ -103,5 +98,5 @@ export default function DashboardProjects({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
@ -1,29 +1,29 @@
|
||||
import { Sandbox } from "@/lib/types";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCaption,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import Image from "next/image";
|
||||
import Button from "../ui/customButton";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import Avatar from "../ui/avatar";
|
||||
import Link from "next/link";
|
||||
} from "@/components/ui/table"
|
||||
import { ChevronRight } from "lucide-react"
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
import Avatar from "../ui/avatar"
|
||||
import Button from "../ui/customButton"
|
||||
import { projectTemplates } from "@/lib/data"
|
||||
|
||||
export default function DashboardSharedWithMe({
|
||||
shared,
|
||||
}: {
|
||||
shared: {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "react" | "node";
|
||||
author: string;
|
||||
sharedOn: Date;
|
||||
}[];
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
author: string
|
||||
authorAvatarUrl: string
|
||||
sharedOn: Date
|
||||
}[]
|
||||
}) {
|
||||
return (
|
||||
<div className="grow p-4 flex flex-col">
|
||||
@ -47,9 +47,7 @@ export default function DashboardSharedWithMe({
|
||||
<Image
|
||||
alt=""
|
||||
src={
|
||||
sandbox.type === "react"
|
||||
? "/project-icons/react.svg"
|
||||
: "/project-icons/node.svg"
|
||||
projectTemplates.find((p) => p.id === sandbox.type)?.icon ?? "/project-icons/node.svg"
|
||||
}
|
||||
width={20}
|
||||
height={20}
|
||||
@ -60,7 +58,11 @@ export default function DashboardSharedWithMe({
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center">
|
||||
<Avatar name={sandbox.author} className="mr-2" />
|
||||
<Avatar
|
||||
name={sandbox.author}
|
||||
avatarUrl={sandbox.authorAvatarUrl}
|
||||
className="mr-2"
|
||||
/>
|
||||
{sandbox.author}
|
||||
</div>
|
||||
</TableCell>
|
||||
@ -86,5 +88,5 @@ export default function DashboardSharedWithMe({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
239
frontend/components/editor/AIChat/ChatInput.tsx
Normal file
239
frontend/components/editor/AIChat/ChatInput.tsx
Normal file
@ -0,0 +1,239 @@
|
||||
import { Send, StopCircle, Image as ImageIcon, Paperclip } from "lucide-react"
|
||||
import { Button } from "../../ui/button"
|
||||
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,
|
||||
setInput,
|
||||
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="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}
|
||||
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}
|
||||
>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
226
frontend/components/editor/AIChat/ChatMessage.tsx
Normal file
226
frontend/components/editor/AIChat/ChatMessage.tsx
Normal file
@ -0,0 +1,226 @@
|
||||
import { Check, Copy, CornerUpLeft } from "lucide-react"
|
||||
import React, { useState } from "react"
|
||||
import ReactMarkdown from "react-markdown"
|
||||
import remarkGfm from "remark-gfm"
|
||||
import { Button } from "../../ui/button"
|
||||
import { copyToClipboard, stringifyContent } from "./lib/chatUtils"
|
||||
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)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="p-1 h-6"
|
||||
>
|
||||
{copiedText === stringifyContent(text) ? (
|
||||
<Check className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<Copy className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
|
||||
// Set context for code when asking about code
|
||||
const askAboutCode = (code: any) => {
|
||||
const contextString = stringifyContent(code)
|
||||
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)
|
||||
|
||||
return (
|
||||
<div className="relative group">
|
||||
<div className="absolute top-0 right-0 flex opacity-0 group-hover:opacity-30 transition-opacity">
|
||||
{renderCopyButton(content)}
|
||||
<Button
|
||||
onClick={() => askAboutCode(content)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="p-1 h-6"
|
||||
>
|
||||
<CornerUpLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{/* Render markdown element */}
|
||||
{React.createElement(
|
||||
node.tagName,
|
||||
{
|
||||
...props,
|
||||
className: `${
|
||||
props.className || ""
|
||||
} hover:bg-transparent rounded p-1 transition-colors`,
|
||||
},
|
||||
children
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Create markdown components
|
||||
const components = createMarkdownComponents(
|
||||
renderCopyButton,
|
||||
renderMarkdownElement,
|
||||
askAboutCode
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="text-left relative">
|
||||
<div
|
||||
className={`relative p-2 rounded-lg ${
|
||||
message.role === "user"
|
||||
? "bg-[#262626] text-white"
|
||||
: "bg-transparent text-white"
|
||||
} max-w-full`}
|
||||
>
|
||||
{/* Render context tabs */}
|
||||
{message.role === "user" && message.context && (
|
||||
<div className="mb-2 bg-input rounded-lg">
|
||||
<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">
|
||||
{renderCopyButton(
|
||||
message.context.replace(/^Regarding this code:\n/, "")
|
||||
)}
|
||||
</div>
|
||||
{/* Render code textarea */}
|
||||
{(() => {
|
||||
const code = message.context.replace(
|
||||
/^Regarding this code:\n/,
|
||||
""
|
||||
)
|
||||
const match = /language-(\w+)/.exec(code)
|
||||
const language = match ? match[1] : "typescript"
|
||||
return (
|
||||
<div className="pt-6">
|
||||
<textarea
|
||||
value={code}
|
||||
onChange={(e) => {
|
||||
const updatedContext = `Regarding this code:\n${e.target.value}`
|
||||
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}
|
||||
style={{
|
||||
resize: "vertical",
|
||||
minHeight: "100px",
|
||||
maxHeight: "400px",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</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={components}
|
||||
>
|
||||
{message.content}
|
||||
</ReactMarkdown>
|
||||
) : (
|
||||
<div className="whitespace-pre-wrap group">{message.content}</div>
|
||||
)}
|
||||
</div>
|
||||
</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)
|
||||
}
|
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>
|
||||
)
|
||||
}
|
222
frontend/components/editor/AIChat/index.tsx
Normal file
222
frontend/components/editor/AIChat/index.tsx
Normal file
@ -0,0 +1,222 @@
|
||||
import { X } from "lucide-react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import LoadingDots from "../../ui/LoadingDots"
|
||||
import ChatInput from "./ChatInput"
|
||||
import ChatMessage from "./ChatMessage"
|
||||
import ContextTabs from "./ContextTabs"
|
||||
import { handleSend, handleStopGeneration } from "./lib/chatUtils"
|
||||
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,
|
||||
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)
|
||||
|
||||
// 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(() => {
|
||||
chatContainerRef.current?.scrollTo({
|
||||
top: chatContainerRef.current.scrollHeight,
|
||||
behavior: "smooth",
|
||||
})
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
|
||||
// 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">
|
||||
<span className="text-muted-foreground/50 font-medium">CHAT</span>
|
||||
<div className="flex items-center h-full">
|
||||
<span className="text-muted-foreground/50 font-medium">
|
||||
{activeFileName}
|
||||
</span>
|
||||
<div className="mx-2 h-full w-px bg-muted-foreground/20"></div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-muted-foreground/50 hover:text-muted-foreground focus:outline-none"
|
||||
aria-label="Close AI Chat"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref={chatContainerRef}
|
||||
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">
|
||||
{/* 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={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>
|
||||
)
|
||||
}
|
235
frontend/components/editor/AIChat/lib/chatUtils.ts
Normal file
235
frontend/components/editor/AIChat/lib/chatUtils.ts
Normal file
@ -0,0 +1,235 @@
|
||||
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
|
||||
)
|
||||
.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]"
|
||||
}
|
||||
seen.add(content)
|
||||
try {
|
||||
const pairs = Object.entries(content).map(
|
||||
([key, value]) => `${key}: ${stringifyContent(value, seen)}`
|
||||
)
|
||||
return "{" + pairs.join(", ") + "}"
|
||||
} catch (error) {
|
||||
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,
|
||||
messages: any[],
|
||||
setMessages: React.Dispatch<React.SetStateAction<any[]>>,
|
||||
setInput: React.Dispatch<React.SetStateAction<string>>,
|
||||
setIsContextExpanded: React.Dispatch<React.SetStateAction<boolean>>,
|
||||
setIsGenerating: React.Dispatch<React.SetStateAction<boolean>>,
|
||||
setIsLoading: React.Dispatch<React.SetStateAction<boolean>>,
|
||||
abortControllerRef: React.MutableRefObject<AbortController | null>,
|
||||
activeFileContent: string
|
||||
) => {
|
||||
// Return if input is empty and context is null
|
||||
if (input.trim() === "" && !context) return
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Update messages for chat message component
|
||||
const updatedMessages = [...messages, userMessage]
|
||||
setMessages(updatedMessages)
|
||||
setInput("")
|
||||
setIsContextExpanded(false)
|
||||
setIsGenerating(true)
|
||||
setIsLoading(true)
|
||||
|
||||
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`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
messages: anthropicMessages,
|
||||
context: context || undefined,
|
||||
activeFileContent: activeFileContent,
|
||||
}),
|
||||
signal: abortControllerRef.current.signal,
|
||||
}
|
||||
)
|
||||
|
||||
// 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()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
|
||||
const currentTime = Date.now()
|
||||
if (currentTime - lastUpdateTime > updateInterval) {
|
||||
setMessages((prev) => {
|
||||
const updatedMessages = [...prev]
|
||||
const lastMessage = updatedMessages[updatedMessages.length - 1]
|
||||
lastMessage.content = buffer
|
||||
return updatedMessages
|
||||
})
|
||||
lastUpdateTime = currentTime
|
||||
}
|
||||
}
|
||||
|
||||
// Update messages for chat message component
|
||||
setMessages((prev) => {
|
||||
const updatedMessages = [...prev]
|
||||
const lastMessage = updatedMessages[updatedMessages.length - 1]
|
||||
lastMessage.content = buffer
|
||||
return updatedMessages
|
||||
})
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Handle abort error for chat message component
|
||||
if (error.name === "AbortError") {
|
||||
console.log("Generation aborted")
|
||||
} else {
|
||||
console.error("Error fetching AI response:", error)
|
||||
const errorMessage = {
|
||||
role: "assistant" as const,
|
||||
content: "Sorry, I encountered an error. Please try again.",
|
||||
}
|
||||
setMessages((prev) => [...prev, errorMessage])
|
||||
}
|
||||
} finally {
|
||||
setIsGenerating(false)
|
||||
setIsLoading(false)
|
||||
abortControllerRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
// Handle stop generation for chat message component
|
||||
export const handleStopGeneration = (
|
||||
abortControllerRef: React.MutableRefObject<AbortController | null>
|
||||
) => {
|
||||
if (abortControllerRef.current) {
|
||||
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
|
||||
}
|
@ -1,13 +1,13 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { Button } from "../ui/button"
|
||||
import { Check, Loader2, RotateCw, Sparkles, X } from "lucide-react"
|
||||
import { Socket } from "socket.io-client"
|
||||
import { Editor } from "@monaco-editor/react"
|
||||
import { User } from "@/lib/types"
|
||||
import { toast } from "sonner"
|
||||
import { Editor } from "@monaco-editor/react"
|
||||
import { Check, Loader2, RotateCw, Sparkles, X } from "lucide-react"
|
||||
import { usePathname, useRouter } from "next/navigation"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { Socket } from "socket.io-client"
|
||||
import { toast } from "sonner"
|
||||
import { Button } from "../ui/button"
|
||||
// import monaco from "monaco-editor"
|
||||
|
||||
export default function GenerateInput({
|
||||
@ -59,7 +59,7 @@ export default function GenerateInput({
|
||||
}: {
|
||||
regenerate?: boolean
|
||||
}) => {
|
||||
if (user.generations >= 10) {
|
||||
if (user.generations >= 1000) {
|
||||
toast.error("You reached the maximum # of generations.")
|
||||
return
|
||||
}
|
||||
@ -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) {
|
||||
@ -84,6 +86,13 @@ export default function GenerateInput({
|
||||
}
|
||||
)
|
||||
}
|
||||
const handleGenerateForm = useCallback(
|
||||
(e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
handleGenerate({ regenerate: false })
|
||||
},
|
||||
[input, currentPrompt]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (code) {
|
||||
@ -93,9 +102,23 @@ export default function GenerateInput({
|
||||
}
|
||||
}, [code])
|
||||
|
||||
useEffect(() => {
|
||||
//listen to when Esc key is pressed and close the modal
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="w-full pr-4 space-y-2">
|
||||
<div className="flex items-center font-sans space-x-2">
|
||||
<form
|
||||
onSubmit={handleGenerateForm}
|
||||
className="flex items-center font-sans space-x-2"
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
style={{
|
||||
@ -109,8 +132,8 @@ export default function GenerateInput({
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
type="submit"
|
||||
disabled={loading.generate || loading.regenerate || input === ""}
|
||||
onClick={() => handleGenerate({})}
|
||||
>
|
||||
{loading.generate ? (
|
||||
<>
|
||||
@ -126,13 +149,14 @@ export default function GenerateInput({
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="smIcon"
|
||||
className="bg-transparent shrink-0 border-muted-foreground"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
{expanded ? (
|
||||
<>
|
||||
<div className="rounded-md border border-muted-foreground w-full h-28 overflow-y-scroll p-2">
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
"use client"
|
||||
|
||||
import { useOthers } from "@/liveblocks.config";
|
||||
import { useOthers } from "@/liveblocks.config"
|
||||
|
||||
const classNames = {
|
||||
red: "w-8 h-8 leading-none font-mono rounded-full ring-1 ring-red-700 ring-offset-2 ring-offset-background overflow-hidden bg-gradient-to-tr from-red-950 to-red-600 flex items-center justify-center text-xs font-medium",
|
||||
@ -14,10 +14,10 @@ const classNames = {
|
||||
purple:
|
||||
"w-8 h-8 leading-none font-mono rounded-full ring-1 ring-purple-700 ring-offset-2 ring-offset-background overflow-hidden bg-gradient-to-tr from-purple-950 to-purple-600 flex items-center justify-center text-xs font-medium",
|
||||
pink: "w-8 h-8 leading-none font-mono rounded-full ring-1 ring-pink-700 ring-offset-2 ring-offset-background overflow-hidden bg-gradient-to-tr from-pink-950 to-pink-600 flex items-center justify-center text-xs font-medium",
|
||||
};
|
||||
}
|
||||
|
||||
export function Avatars() {
|
||||
const users = useOthers();
|
||||
const users = useOthers()
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -30,12 +30,12 @@ export function Avatars() {
|
||||
.slice(0, 2)
|
||||
.map((letter) => letter[0].toUpperCase())}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{users.length > 0 ? (
|
||||
<div className="h-full w-[1px] bg-border mx-2" />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
@ -1,11 +1,10 @@
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { colors } from "@/lib/colors"
|
||||
import {
|
||||
AwarenessList,
|
||||
TypedLiveblocksProvider,
|
||||
UserAwareness,
|
||||
useSelf,
|
||||
} from "@/liveblocks.config"
|
||||
import { colors } from "@/lib/colors"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
|
||||
export function Cursors({
|
||||
yProvider,
|
||||
|
@ -1,43 +1,35 @@
|
||||
"use client";
|
||||
"use client"
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
} from "@/components/ui/dialog"
|
||||
|
||||
import {
|
||||
ChevronRight,
|
||||
FileStack,
|
||||
Globe,
|
||||
Loader2,
|
||||
TextCursor,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useEffect } from "react"
|
||||
|
||||
export default function DisableAccessModal({
|
||||
open,
|
||||
setOpen,
|
||||
message,
|
||||
}: {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
message: string;
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
message: string
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const timeout = setTimeout(() => {
|
||||
router.push("/dashboard");
|
||||
}, 5000);
|
||||
return () => clearTimeout(timeout);
|
||||
router.push("/dashboard")
|
||||
}, 5000)
|
||||
return () => clearTimeout(timeout)
|
||||
}
|
||||
}, []);
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
@ -54,5 +46,5 @@ export default function DisableAccessModal({
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
@ -1,14 +1,13 @@
|
||||
"use client";
|
||||
"use client"
|
||||
|
||||
import { RoomProvider } from "@/liveblocks.config";
|
||||
import { ClientSideSuspense } from "@liveblocks/react";
|
||||
import { RoomProvider } from "@/liveblocks.config"
|
||||
|
||||
export function Room({
|
||||
id,
|
||||
children,
|
||||
}: {
|
||||
id: string;
|
||||
children: React.ReactNode;
|
||||
id: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<RoomProvider
|
||||
@ -21,5 +20,5 @@ export function Room({
|
||||
{children}
|
||||
{/* </ClientSideSuspense> */}
|
||||
</RoomProvider>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
@ -1,9 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import Image from "next/image"
|
||||
import Logo from "@/assets/logo.svg"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Loader2, X } from "lucide-react"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@ -11,6 +8,9 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Loader2, X } from "lucide-react"
|
||||
import Image from "next/image"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
export default function Loading({
|
||||
@ -84,8 +84,10 @@ export default function Loading({
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full mt-1 flex flex-col">
|
||||
<div className="w-full flex justify-center">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<div className="w-full flex flex-col justify-center">
|
||||
{new Array(6).fill(0).map((_, i) => (
|
||||
<Skeleton key={i} className="h-[1.625rem] mb-0.5 rounded-sm" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,34 +1,38 @@
|
||||
"use client";
|
||||
"use client"
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTerminal } from "@/context/TerminalContext";
|
||||
import { Play, Pause, Globe, Globe2 } from "lucide-react";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Sandbox, User } from "@/lib/types";
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
import { useTerminal } from "@/context/TerminalContext"
|
||||
import { Sandbox, User } from "@/lib/types"
|
||||
import { Globe } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
|
||||
export default function DeployButtonModal({
|
||||
userData,
|
||||
data,
|
||||
}: {
|
||||
userData: User;
|
||||
data: Sandbox;
|
||||
userData: User
|
||||
data: Sandbox
|
||||
}) {
|
||||
const { deploy } = useTerminal();
|
||||
const [isDeploying, setIsDeploying] = useState(false);
|
||||
const { deploy } = useTerminal()
|
||||
const [isDeploying, setIsDeploying] = useState(false)
|
||||
|
||||
const handleDeploy = () => {
|
||||
if (isDeploying) {
|
||||
console.log("Stopping deployment...");
|
||||
setIsDeploying(false);
|
||||
console.log("Stopping deployment...")
|
||||
setIsDeploying(false)
|
||||
} else {
|
||||
console.log("Starting deployment...");
|
||||
setIsDeploying(true);
|
||||
console.log("Starting deployment...")
|
||||
setIsDeploying(true)
|
||||
deploy(() => {
|
||||
setIsDeploying(false);
|
||||
});
|
||||
setIsDeploying(false)
|
||||
})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -39,7 +43,10 @@ export default function DeployButtonModal({
|
||||
Deploy
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-4 w-full max-w-xs sm:max-w-sm md:max-w-md lg:max-w-lg xl:max-w-xl rounded-lg shadow-lg" style={{ backgroundColor: 'rgb(10,10,10)', color: 'white' }}>
|
||||
<PopoverContent
|
||||
className="p-4 w-full max-w-xs sm:max-w-sm md:max-w-md lg:max-w-lg xl:max-w-xl rounded-lg shadow-lg"
|
||||
style={{ backgroundColor: "rgb(10,10,10)", color: "white" }}
|
||||
>
|
||||
<h3 className="font-semibold text-gray-300 mb-2">Domains</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
<DeploymentOption
|
||||
@ -49,16 +56,30 @@ export default function DeployButtonModal({
|
||||
user={userData.name}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" className="mt-4 w-full bg-[#0a0a0a] text-white hover:bg-[#262626]" onClick={handleDeploy}>
|
||||
{isDeploying ? "Deploying..." : "Update"}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-4 w-full bg-[#0a0a0a] text-white hover:bg-[#262626]"
|
||||
onClick={handleDeploy}
|
||||
>
|
||||
{isDeploying ? "Deploying..." : "Update"}
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function DeploymentOption({ icon, domain, timestamp, user }: { icon: React.ReactNode; domain: string; timestamp: string; user: string }) {
|
||||
function DeploymentOption({
|
||||
icon,
|
||||
domain,
|
||||
timestamp,
|
||||
user,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
domain: string
|
||||
timestamp: string
|
||||
user: string
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 w-full text-left p-2 rounded-md border border-gray-700 bg-gray-900">
|
||||
<div className="flex items-start gap-2 relative">
|
||||
@ -72,7 +93,9 @@ function DeploymentOption({ icon, domain, timestamp, user }: { icon: React.React
|
||||
{domain}
|
||||
</a>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400 mt-0 ml-7">{timestamp} • {user}</p>
|
||||
<p className="text-sm text-gray-400 mt-0 ml-7">
|
||||
{timestamp} • {user}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
34
frontend/components/editor/navbar/downloadButton.tsx
Normal file
34
frontend/components/editor/navbar/downloadButton.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import JSZip from 'jszip'
|
||||
import { useSocket } from "@/context/SocketContext"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Download } from "lucide-react"
|
||||
|
||||
export default function DownloadButton({ name }: { name: string }) {
|
||||
const { socket } = useSocket()
|
||||
|
||||
const handleDownload = async () => {
|
||||
socket?.emit("downloadFiles", {}, async (response: {files: {path: string, content: string}[]}) => {
|
||||
const zip = new JSZip()
|
||||
|
||||
response.files.forEach(file => {
|
||||
zip.file(file.path, file.content)
|
||||
})
|
||||
|
||||
const blob = await zip.generateAsync({type: "blob"})
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${name}.zip`
|
||||
a.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<Button variant="outline" onClick={handleDownload}>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Download
|
||||
</Button>
|
||||
)
|
||||
}
|
@ -1,60 +1,57 @@
|
||||
"use client";
|
||||
"use client"
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
} from "@/components/ui/dialog"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { z } from "zod"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
} from "@/components/ui/form"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Sandbox } from "@/lib/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { deleteSandbox, updateSandbox } from "@/lib/actions";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
} from "@/components/ui/select"
|
||||
import { deleteSandbox, updateSandbox } from "@/lib/actions"
|
||||
import { Sandbox } from "@/lib/types"
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(1).max(16),
|
||||
visibility: z.enum(["public", "private"]),
|
||||
});
|
||||
})
|
||||
|
||||
export default function EditSandboxModal({
|
||||
open,
|
||||
setOpen,
|
||||
data,
|
||||
}: {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
data: Sandbox;
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
data: Sandbox
|
||||
}) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingDelete, setLoadingDelete] = useState(false);
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [loadingDelete, setLoadingDelete] = useState(false)
|
||||
|
||||
const router = useRouter();
|
||||
const router = useRouter()
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
@ -62,22 +59,22 @@ export default function EditSandboxModal({
|
||||
name: data.name,
|
||||
visibility: data.visibility,
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
setLoading(true);
|
||||
await updateSandbox({ id: data.id, ...values });
|
||||
setLoading(true)
|
||||
await updateSandbox({ id: data.id, ...values })
|
||||
|
||||
toast.success("Sandbox updated successfully");
|
||||
toast.success("Sandbox updated successfully")
|
||||
|
||||
setLoading(false);
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
async function onDelete() {
|
||||
setLoadingDelete(true);
|
||||
await deleteSandbox(data.id);
|
||||
setLoadingDelete(true)
|
||||
await deleteSandbox(data.id)
|
||||
|
||||
router.push("/dashboard");
|
||||
router.push("/dashboard")
|
||||
}
|
||||
|
||||
return (
|
||||
@ -153,5 +150,5 @@ export default function EditSandboxModal({
|
||||
</Button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
@ -1,33 +1,35 @@
|
||||
"use client";
|
||||
"use client"
|
||||
|
||||
import Image from "next/image";
|
||||
import Logo from "@/assets/logo.svg";
|
||||
import { Pencil, Users } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Sandbox, User } from "@/lib/types";
|
||||
import UserButton from "@/components/ui/userButton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useState } from "react";
|
||||
import EditSandboxModal from "./edit";
|
||||
import ShareSandboxModal from "./share";
|
||||
import { Avatars } from "../live/avatars";
|
||||
import RunButtonModal from "./run";
|
||||
import DeployButtonModal from "./deploy";
|
||||
import Logo from "@/assets/logo.svg"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ThemeSwitcher } from "@/components/ui/theme-switcher"
|
||||
import UserButton from "@/components/ui/userButton"
|
||||
import { Sandbox, User } from "@/lib/types"
|
||||
import { Pencil, Users } from "lucide-react"
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
import { useState } from "react"
|
||||
// import { Avatars } from "../live/avatars"
|
||||
import DeployButtonModal from "./deploy"
|
||||
import EditSandboxModal from "./edit"
|
||||
import RunButtonModal from "./run"
|
||||
import ShareSandboxModal from "./share"
|
||||
import DownloadButton from "./downloadButton"
|
||||
|
||||
export default function Navbar({
|
||||
userData,
|
||||
sandboxData,
|
||||
shared,
|
||||
}: {
|
||||
userData: User;
|
||||
sandboxData: Sandbox;
|
||||
shared: { id: string; name: string }[];
|
||||
userData: User
|
||||
sandboxData: Sandbox
|
||||
shared: { id: string; name: string; avatarUrl: string }[]
|
||||
}) {
|
||||
const [isEditOpen, setIsEditOpen] = useState(false);
|
||||
const [isShareOpen, setIsShareOpen] = useState(false);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [isEditOpen, setIsEditOpen] = useState(false)
|
||||
const [isShareOpen, setIsShareOpen] = useState(false)
|
||||
const [isRunning, setIsRunning] = useState(false)
|
||||
|
||||
const isOwner = sandboxData.userId === userData.id;;
|
||||
const isOwner = sandboxData.userId === userData.id
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -68,23 +70,21 @@ export default function Navbar({
|
||||
sandboxData={sandboxData}
|
||||
/>
|
||||
<div className="flex items-center h-full space-x-4">
|
||||
<Avatars />
|
||||
{/* <Avatars /> */}
|
||||
|
||||
{isOwner ? (
|
||||
<>
|
||||
<DeployButtonModal
|
||||
data={sandboxData}
|
||||
userData={userData}
|
||||
/>
|
||||
<Button variant="outline" onClick={() => setIsShareOpen(true)}>
|
||||
<Users className="w-4 h-4 mr-2" />
|
||||
Share
|
||||
</Button>
|
||||
</>
|
||||
<DeployButtonModal data={sandboxData} userData={userData} />
|
||||
{/* <Button variant="outline" onClick={() => setIsShareOpen(true)}>
|
||||
<Users className="w-4 h-4 mr-2" />
|
||||
Share
|
||||
</Button> */}
|
||||
<DownloadButton name={sandboxData.name} /></>
|
||||
) : null}
|
||||
<ThemeSwitcher />
|
||||
<UserButton userData={userData} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
)
|
||||
}
|
||||
|
@ -1,68 +1,78 @@
|
||||
"use client";
|
||||
"use client"
|
||||
|
||||
import { Play, StopCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTerminal } from "@/context/TerminalContext";
|
||||
import { usePreview } from "@/context/PreviewContext";
|
||||
import { toast } from "sonner";
|
||||
import { Sandbox } from "@/lib/types";
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { usePreview } from "@/context/PreviewContext"
|
||||
import { useTerminal } from "@/context/TerminalContext"
|
||||
import { Sandbox } from "@/lib/types"
|
||||
import { Play, StopCircle } from "lucide-react"
|
||||
import { useEffect, useRef } from "react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export default function RunButtonModal({
|
||||
isRunning,
|
||||
setIsRunning,
|
||||
sandboxData,
|
||||
}: {
|
||||
isRunning: boolean;
|
||||
setIsRunning: (running: boolean) => void;
|
||||
sandboxData: Sandbox;
|
||||
isRunning: boolean
|
||||
setIsRunning: (running: boolean) => void
|
||||
sandboxData: Sandbox
|
||||
}) {
|
||||
const { createNewTerminal, terminals, closeTerminal } = useTerminal();
|
||||
const { setIsPreviewCollapsed, previewPanelRef } = usePreview();
|
||||
const { createNewTerminal, closeTerminal, terminals } = useTerminal()
|
||||
const { setIsPreviewCollapsed, previewPanelRef } = usePreview()
|
||||
// Ref to keep track of the last created terminal's ID
|
||||
const lastCreatedTerminalRef = useRef<string | null>(null)
|
||||
|
||||
const handleRun = () => {
|
||||
if (isRunning) {
|
||||
console.log('Stopping sandbox...');
|
||||
console.log('Closing Preview Window');
|
||||
|
||||
terminals.forEach(term => {
|
||||
if (term.terminal) {
|
||||
closeTerminal(term.id);
|
||||
console.log('Closing Terminal', term.id);
|
||||
}
|
||||
});
|
||||
|
||||
setIsPreviewCollapsed(true);
|
||||
previewPanelRef.current?.collapse();
|
||||
} else {
|
||||
console.log('Running sandbox...');
|
||||
console.log('Opening Terminal');
|
||||
console.log('Opening Preview Window');
|
||||
|
||||
if (terminals.length < 4) {
|
||||
if (sandboxData.type === "streamlit") {
|
||||
createNewTerminal(
|
||||
"pip install -r requirements.txt && streamlit run main.py --server.runOnSave true"
|
||||
);
|
||||
} else {
|
||||
createNewTerminal("yarn install && yarn dev");
|
||||
}
|
||||
} else {
|
||||
toast.error("You reached the maximum # of terminals.");
|
||||
console.error("Maximum number of terminals reached.");
|
||||
// Effect to update the lastCreatedTerminalRef when a new terminal is added
|
||||
useEffect(() => {
|
||||
if (terminals.length > 0 && !isRunning) {
|
||||
const latestTerminal = terminals[terminals.length - 1]
|
||||
if (
|
||||
latestTerminal &&
|
||||
latestTerminal.id !== lastCreatedTerminalRef.current
|
||||
) {
|
||||
lastCreatedTerminalRef.current = latestTerminal.id
|
||||
}
|
||||
|
||||
setIsPreviewCollapsed(false);
|
||||
previewPanelRef.current?.expand();
|
||||
}
|
||||
setIsRunning(!isRunning);
|
||||
};
|
||||
}, [terminals, isRunning])
|
||||
|
||||
const handleRun = async () => {
|
||||
if (isRunning && lastCreatedTerminalRef.current) {
|
||||
await closeTerminal(lastCreatedTerminalRef.current)
|
||||
lastCreatedTerminalRef.current = null
|
||||
setIsPreviewCollapsed(true)
|
||||
previewPanelRef.current?.collapse()
|
||||
} else if (!isRunning && terminals.length < 4) {
|
||||
const command =
|
||||
sandboxData.type === "streamlit"
|
||||
? "./venv/bin/streamlit run main.py --server.runOnSave true"
|
||||
: "npm run dev"
|
||||
|
||||
try {
|
||||
// Create a new terminal with the appropriate command
|
||||
await createNewTerminal(command)
|
||||
setIsPreviewCollapsed(false)
|
||||
previewPanelRef.current?.expand()
|
||||
} catch (error) {
|
||||
toast.error("Failed to create new terminal.")
|
||||
console.error("Error creating new terminal:", error)
|
||||
return
|
||||
}
|
||||
} else if (!isRunning) {
|
||||
toast.error("You've reached the maximum number of terminals.")
|
||||
return
|
||||
}
|
||||
|
||||
setIsRunning(!isRunning)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleRun}>
|
||||
{isRunning ? <StopCircle className="w-4 h-4 mr-2" /> : <Play className="w-4 h-4 mr-2" />}
|
||||
{isRunning ? 'Stop' : 'Run'}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
<Button variant="outline" onClick={handleRun}>
|
||||
{isRunning ? (
|
||||
<StopCircle className="w-4 h-4 mr-2" />
|
||||
) : (
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{isRunning ? "Stop" : "Run"}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
@ -6,10 +6,11 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { z } from "zod"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { z } from "zod"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@ -18,14 +19,13 @@ import {
|
||||
FormMessage,
|
||||
} from "@/components/ui/form"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Link, Loader2, UserPlus, X } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { Sandbox } from "@/lib/types"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { shareSandbox } from "@/lib/actions"
|
||||
import { Sandbox } from "@/lib/types"
|
||||
import { DialogDescription } from "@radix-ui/react-dialog"
|
||||
import { Link, Loader2, UserPlus } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import SharedUser from "./sharedUser"
|
||||
import { DialogDescription } from "@radix-ui/react-dialog"
|
||||
|
||||
const formSchema = z.object({
|
||||
email: z.string().email(),
|
||||
@ -43,6 +43,7 @@ export default function ShareSandboxModal({
|
||||
shared: {
|
||||
id: string
|
||||
name: string
|
||||
avatarUrl: string
|
||||
}[]
|
||||
}) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
@ -142,7 +143,11 @@ export default function ShareSandboxModal({
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
{shared.map((user) => (
|
||||
<SharedUser key={user.id} user={user} sandboxId={data.id} />
|
||||
<SharedUser
|
||||
key={user.id}
|
||||
user={user}
|
||||
sandboxId={data.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -10,7 +10,7 @@ export default function SharedUser({
|
||||
user,
|
||||
sandboxId,
|
||||
}: {
|
||||
user: { id: string; name: string }
|
||||
user: { id: string; name: string; avatarUrl: string }
|
||||
sandboxId: string
|
||||
}) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
@ -24,7 +24,7 @@ export default function SharedUser({
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<Avatar name={user.name} className="mr-2" />
|
||||
<Avatar name={user.name} avatarUrl={user.avatarUrl} className="mr-2" />
|
||||
{user.name}
|
||||
</div>
|
||||
<Button
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user