Compare commits
22 Commits
feature/ap
...
file-save
Author | SHA1 | Date | |
---|---|---|---|
0117e197bb | |||
37ebe41526 | |||
ed367ed27c | |||
9666401625 | |||
22d638a090 | |||
0dd99cbc77 | |||
093a4e9758 | |||
6a60f4d286 | |||
260110724e | |||
ee51ae7a33 | |||
0d0eed34b2 | |||
4e1c5cac27 | |||
0ae89341d2 | |||
1630a5a9cd | |||
ffdcdef56c | |||
6612692d98 | |||
534b148b86 | |||
e384607d24 | |||
e7d9989931 | |||
42305d67b9 | |||
06dadf3a0b | |||
ba7a1dcc2c |
22
README.md
22
README.md
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
Sandbox is an open-source cloud-based code editing environment with custom AI code generation, live preview, real-time collaboration and AI chat.
|
Sandbox is an open-source cloud-based code editing environment with custom AI code generation, live preview, real-time collaboration, and AI chat.
|
||||||
|
|
||||||
For the latest updates, join our Discord server: [discord.gitwit.dev](https://discord.gitwit.dev/).
|
For the latest updates, join our Discord server: [discord.gitwit.dev](https://discord.gitwit.dev/).
|
||||||
|
|
||||||
@ -10,11 +10,11 @@ For the latest updates, join our Discord server: [discord.gitwit.dev](https://di
|
|||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
- Double check that whatever you change "SUPERDUPERSECRET" to, it's the same in all config files.
|
- Double-check that whatever you change "SUPERDUPERSECRET" to, it's the same in all config files.
|
||||||
|
|
||||||
### 0. Requirements
|
### 0. Requirements
|
||||||
|
|
||||||
The application uses NodeJS for the backend, NextJS for the frontend and Cloudflare workers for additional backend tasks.
|
The application uses NodeJS for the backend, NextJS for the frontend, and Cloudflare workers for additional backend tasks.
|
||||||
|
|
||||||
Needed accounts to set up:
|
Needed accounts to set up:
|
||||||
|
|
||||||
@ -22,6 +22,7 @@ Needed accounts to set up:
|
|||||||
- [Liveblocks](https://liveblocks.io/): Used for collaborative editing.
|
- [Liveblocks](https://liveblocks.io/): Used for collaborative editing.
|
||||||
- [E2B](https://e2b.dev/): Used for the terminals and live preview.
|
- [E2B](https://e2b.dev/): Used for the terminals and live preview.
|
||||||
- [Cloudflare](https://www.cloudflare.com/): Used for relational data storage (D2) and file storage (R2).
|
- [Cloudflare](https://www.cloudflare.com/): Used for relational data storage (D2) and file storage (R2).
|
||||||
|
- [Anthropic](https://anthropic.com/) and [OpenAI](https://openai.com/): API keys for code generation.
|
||||||
|
|
||||||
A quick overview of the tech before we start: The deployment uses a **NextJS** app for the frontend and an **ExpressJS** server on the backend. Presumably that's because NextJS integrates well with Clerk middleware but not with Socket.io.
|
A quick overview of the tech before we start: The deployment uses a **NextJS** app for the frontend and an **ExpressJS** server on the backend. Presumably that's because NextJS integrates well with Clerk middleware but not with Socket.io.
|
||||||
|
|
||||||
@ -41,7 +42,6 @@ Run `npm install` in:
|
|||||||
/backend/database
|
/backend/database
|
||||||
/backend/storage
|
/backend/storage
|
||||||
/backend/server
|
/backend/server
|
||||||
/backend/ai
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Adding Clerk
|
### 2. Adding Clerk
|
||||||
@ -152,6 +152,7 @@ NEXT_PUBLIC_DATABASE_WORKER_URL='https://database.🍎.workers.dev'
|
|||||||
NEXT_PUBLIC_STORAGE_WORKER_URL='https://storage.🍎.workers.dev'
|
NEXT_PUBLIC_STORAGE_WORKER_URL='https://storage.🍎.workers.dev'
|
||||||
NEXT_PUBLIC_WORKERS_KEY='SUPERDUPERSECRET'
|
NEXT_PUBLIC_WORKERS_KEY='SUPERDUPERSECRET'
|
||||||
ANTHROPIC_API_KEY='🔑'
|
ANTHROPIC_API_KEY='🔑'
|
||||||
|
OPENAI_API_KEY='🔑'
|
||||||
```
|
```
|
||||||
|
|
||||||
### 10. Running the IDE
|
### 10. Running the IDE
|
||||||
@ -173,6 +174,15 @@ Setting up deployments first requires a separate domain (such as gitwit.app, whi
|
|||||||
|
|
||||||
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
|
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
|
||||||
|
|
||||||
|
And we install [dokku-daemon](https://github.com/dokku/dokku-daemon) with the following commands:
|
||||||
|
|
||||||
|
```
|
||||||
|
git clone https://github.com/dokku/dokku-daemon
|
||||||
|
cd dokku-daemon
|
||||||
|
sudo make install
|
||||||
|
systemctl start dokku-daemon
|
||||||
|
```
|
||||||
|
|
||||||
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:
|
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
|
```bash
|
||||||
@ -246,8 +256,7 @@ backend/
|
|||||||
├── database/
|
├── database/
|
||||||
│ ├── src
|
│ ├── src
|
||||||
│ └── drizzle
|
│ └── drizzle
|
||||||
├── storage
|
└── storage
|
||||||
└── ai
|
|
||||||
```
|
```
|
||||||
|
|
||||||
| Path | Description |
|
| Path | Description |
|
||||||
@ -256,7 +265,6 @@ backend/
|
|||||||
| `backend/server` | The Express websocket server. |
|
| `backend/server` | The Express websocket server. |
|
||||||
| `backend/database` | API for interfacing with the D1 database (SQLite). |
|
| `backend/database` | API for interfacing with the D1 database (SQLite). |
|
||||||
| `backend/storage` | API for interfacing with R2 storage. Service-bound to `/backend/database`. |
|
| `backend/storage` | API for interfacing with R2 storage. Service-bound to `/backend/database`. |
|
||||||
| `backend/ai` | API for making requests to Workers AI . |
|
|
||||||
|
|
||||||
### Development
|
### Development
|
||||||
|
|
||||||
|
@ -6,18 +6,18 @@ import { z } from "zod"
|
|||||||
import { and, eq, sql } from "drizzle-orm"
|
import { and, eq, sql } from "drizzle-orm"
|
||||||
import * as schema from "./schema"
|
import * as schema from "./schema"
|
||||||
import {
|
import {
|
||||||
Sandbox,
|
Sandbox,
|
||||||
sandbox,
|
sandbox,
|
||||||
sandboxLikes,
|
sandboxLikes,
|
||||||
user,
|
user,
|
||||||
usersToSandboxes,
|
usersToSandboxes,
|
||||||
} from "./schema"
|
} from "./schema"
|
||||||
|
|
||||||
export interface Env {
|
export interface Env {
|
||||||
DB: D1Database
|
DB: D1Database
|
||||||
STORAGE: any
|
STORAGE: any
|
||||||
KEY: string
|
KEY: string
|
||||||
STORAGE_WORKER_URL: string
|
STORAGE_WORKER_URL: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// https://github.com/drizzle-team/drizzle-orm/tree/main/examples/cloudflare-d1
|
// https://github.com/drizzle-team/drizzle-orm/tree/main/examples/cloudflare-d1
|
||||||
@ -25,469 +25,488 @@ export interface Env {
|
|||||||
// npm run generate
|
// npm run generate
|
||||||
// npx wrangler d1 execute d1-sandbox --local --file=./drizzle/<FILE>
|
// npx wrangler d1 execute d1-sandbox --local --file=./drizzle/<FILE>
|
||||||
interface SandboxWithLiked extends Sandbox {
|
interface SandboxWithLiked extends Sandbox {
|
||||||
liked: boolean
|
liked: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UserResponse extends Omit<schema.User, "sandbox"> {
|
interface UserResponse extends Omit<schema.User, "sandbox"> {
|
||||||
sandbox: SandboxWithLiked[]
|
sandbox: SandboxWithLiked[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
async fetch(
|
async fetch(
|
||||||
request: Request,
|
request: Request,
|
||||||
env: Env,
|
env: Env,
|
||||||
ctx: ExecutionContext
|
ctx: ExecutionContext
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const success = new Response("Success", { status: 200 })
|
const success = new Response("Success", { status: 200 })
|
||||||
const invalidRequest = new Response("Invalid Request", { status: 400 })
|
const invalidRequest = new Response("Invalid Request", { status: 400 })
|
||||||
const notFound = new Response("Not Found", { status: 404 })
|
const notFound = new Response("Not Found", { status: 404 })
|
||||||
const methodNotAllowed = new Response("Method Not Allowed", { status: 405 })
|
const methodNotAllowed = new Response("Method Not Allowed", { status: 405 })
|
||||||
|
|
||||||
const url = new URL(request.url)
|
const url = new URL(request.url)
|
||||||
const path = url.pathname
|
const path = url.pathname
|
||||||
const method = request.method
|
const method = request.method
|
||||||
|
|
||||||
if (request.headers.get("Authorization") !== env.KEY) {
|
if (request.headers.get("Authorization") !== env.KEY) {
|
||||||
return new Response("Unauthorized", { status: 401 })
|
return new Response("Unauthorized", { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const db = drizzle(env.DB, { schema })
|
const db = drizzle(env.DB, { schema })
|
||||||
|
|
||||||
if (path === "/api/sandbox") {
|
if (path === "/api/sandbox") {
|
||||||
if (method === "GET") {
|
if (method === "GET") {
|
||||||
const params = url.searchParams
|
const params = url.searchParams
|
||||||
if (params.has("id")) {
|
if (params.has("id")) {
|
||||||
const id = params.get("id") as string
|
const id = params.get("id") as string
|
||||||
const res = await db.query.sandbox.findFirst({
|
const res = await db.query.sandbox.findFirst({
|
||||||
where: (sandbox, { eq }) => eq(sandbox.id, id),
|
where: (sandbox, { eq }) => eq(sandbox.id, id),
|
||||||
with: {
|
with: {
|
||||||
usersToSandboxes: true,
|
usersToSandboxes: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return json(res ?? {})
|
return json(res ?? {})
|
||||||
} else {
|
} else {
|
||||||
const res = await db.select().from(sandbox).all()
|
const res = await db.select().from(sandbox).all()
|
||||||
return json(res ?? {})
|
return json(res ?? {})
|
||||||
}
|
}
|
||||||
} else if (method === "DELETE") {
|
} else if (method === "DELETE") {
|
||||||
const params = url.searchParams
|
const params = url.searchParams
|
||||||
if (params.has("id")) {
|
if (params.has("id")) {
|
||||||
const id = params.get("id") as string
|
const id = params.get("id") as string
|
||||||
await db
|
await db
|
||||||
.delete(usersToSandboxes)
|
.delete(usersToSandboxes)
|
||||||
.where(eq(usersToSandboxes.sandboxId, id))
|
.where(eq(usersToSandboxes.sandboxId, id))
|
||||||
await db.delete(sandbox).where(eq(sandbox.id, id))
|
await db.delete(sandbox).where(eq(sandbox.id, id))
|
||||||
|
|
||||||
const deleteStorageRequest = new Request(
|
const deleteStorageRequest = new Request(
|
||||||
`${env.STORAGE_WORKER_URL}/api/project`,
|
`${env.STORAGE_WORKER_URL}/api/project`,
|
||||||
{
|
{
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
body: JSON.stringify({ sandboxId: id }),
|
body: JSON.stringify({ sandboxId: id }),
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: `${env.KEY}`,
|
Authorization: `${env.KEY}`,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
await env.STORAGE.fetch(deleteStorageRequest)
|
await env.STORAGE.fetch(deleteStorageRequest)
|
||||||
|
|
||||||
return success
|
return success
|
||||||
} else {
|
} else {
|
||||||
return invalidRequest
|
return invalidRequest
|
||||||
}
|
}
|
||||||
} else if (method === "POST") {
|
} else if (method === "POST") {
|
||||||
const postSchema = z.object({
|
const postSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
visibility: z.enum(["public", "private"]).optional(),
|
visibility: z.enum(["public", "private"]).optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const { id, name, visibility } = postSchema.parse(body)
|
const { id, name, visibility } = postSchema.parse(body)
|
||||||
const sb = await db
|
const sb = await db
|
||||||
.update(sandbox)
|
.update(sandbox)
|
||||||
.set({ name, visibility })
|
.set({ name, visibility })
|
||||||
.where(eq(sandbox.id, id))
|
.where(eq(sandbox.id, id))
|
||||||
.returning()
|
.returning()
|
||||||
.get()
|
.get()
|
||||||
|
|
||||||
return success
|
return success
|
||||||
} else if (method === "PUT") {
|
} else if (method === "PUT") {
|
||||||
const initSchema = z.object({
|
const initSchema = z.object({
|
||||||
type: z.string(),
|
type: z.string(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
userId: z.string(),
|
userId: z.string(),
|
||||||
visibility: z.enum(["public", "private"]),
|
visibility: z.enum(["public", "private"]),
|
||||||
})
|
})
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const { type, name, userId, visibility } = initSchema.parse(body)
|
const { type, name, userId, visibility } = initSchema.parse(body)
|
||||||
|
|
||||||
const userSandboxes = await db
|
const userSandboxes = await db
|
||||||
.select()
|
.select()
|
||||||
.from(sandbox)
|
.from(sandbox)
|
||||||
.where(eq(sandbox.userId, userId))
|
.where(eq(sandbox.userId, userId))
|
||||||
.all()
|
.all()
|
||||||
|
|
||||||
if (userSandboxes.length >= 8) {
|
if (userSandboxes.length >= 8) {
|
||||||
return new Response("You reached the maximum # of sandboxes.", {
|
return new Response("You reached the maximum # of sandboxes.", {
|
||||||
status: 400,
|
status: 400,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const sb = await db
|
const sb = await db
|
||||||
.insert(sandbox)
|
.insert(sandbox)
|
||||||
.values({ type, name, userId, visibility, createdAt: new Date() })
|
.values({ type, name, userId, visibility, createdAt: new Date() })
|
||||||
.returning()
|
.returning()
|
||||||
.get()
|
.get()
|
||||||
|
|
||||||
const initStorageRequest = new Request(
|
const initStorageRequest = new Request(
|
||||||
`${env.STORAGE_WORKER_URL}/api/init`,
|
`${env.STORAGE_WORKER_URL}/api/init`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ sandboxId: sb.id, type }),
|
body: JSON.stringify({ sandboxId: sb.id, type }),
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: `${env.KEY}`,
|
Authorization: `${env.KEY}`,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
await env.STORAGE.fetch(initStorageRequest)
|
await env.STORAGE.fetch(initStorageRequest)
|
||||||
|
|
||||||
return new Response(sb.id, { status: 200 })
|
return new Response(sb.id, { status: 200 })
|
||||||
} else {
|
} else {
|
||||||
return methodNotAllowed
|
return methodNotAllowed
|
||||||
}
|
}
|
||||||
} else if (path === "/api/sandbox/share") {
|
} else if (path === "/api/sandbox/share") {
|
||||||
if (method === "GET") {
|
if (method === "GET") {
|
||||||
const params = url.searchParams
|
const params = url.searchParams
|
||||||
if (params.has("id")) {
|
if (params.has("id")) {
|
||||||
const id = params.get("id") as string
|
const id = params.get("id") as string
|
||||||
const res = await db.query.usersToSandboxes.findMany({
|
const res = await db.query.usersToSandboxes.findMany({
|
||||||
where: (uts, { eq }) => eq(uts.userId, id),
|
where: (uts, { eq }) => eq(uts.userId, id),
|
||||||
})
|
})
|
||||||
|
|
||||||
const owners = await Promise.all(
|
const owners = await Promise.all(
|
||||||
res.map(async (r) => {
|
res.map(async (r) => {
|
||||||
const sb = await db.query.sandbox.findFirst({
|
const sb = await db.query.sandbox.findFirst({
|
||||||
where: (sandbox, { eq }) => eq(sandbox.id, r.sandboxId),
|
where: (sandbox, { eq }) => eq(sandbox.id, r.sandboxId),
|
||||||
with: {
|
with: {
|
||||||
author: true,
|
author: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if (!sb) return
|
if (!sb) return
|
||||||
return {
|
return {
|
||||||
id: sb.id,
|
id: sb.id,
|
||||||
name: sb.name,
|
name: sb.name,
|
||||||
type: sb.type,
|
type: sb.type,
|
||||||
author: sb.author.name,
|
author: sb.author.name,
|
||||||
authorAvatarUrl: sb.author.avatarUrl,
|
authorAvatarUrl: sb.author.avatarUrl,
|
||||||
sharedOn: r.sharedOn,
|
sharedOn: r.sharedOn,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
return json(owners ?? {})
|
return json(owners ?? {})
|
||||||
} else return invalidRequest
|
} else return invalidRequest
|
||||||
} else if (method === "POST") {
|
} else if (method === "POST") {
|
||||||
const shareSchema = z.object({
|
const shareSchema = z.object({
|
||||||
sandboxId: z.string(),
|
sandboxId: z.string(),
|
||||||
email: z.string(),
|
email: z.string(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const { sandboxId, email } = shareSchema.parse(body)
|
const { sandboxId, email } = shareSchema.parse(body)
|
||||||
|
|
||||||
const user = await db.query.user.findFirst({
|
const user = await db.query.user.findFirst({
|
||||||
where: (user, { eq }) => eq(user.email, email),
|
where: (user, { eq }) => eq(user.email, email),
|
||||||
with: {
|
with: {
|
||||||
sandbox: true,
|
sandbox: true,
|
||||||
usersToSandboxes: true,
|
usersToSandboxes: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return new Response("No user associated with email.", { status: 400 })
|
return new Response("No user associated with email.", { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.sandbox.find((sb) => sb.id === sandboxId)) {
|
if (user.sandbox.find((sb) => sb.id === sandboxId)) {
|
||||||
return new Response("Cannot share with yourself!", { status: 400 })
|
return new Response("Cannot share with yourself!", { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.usersToSandboxes.find((uts) => uts.sandboxId === sandboxId)) {
|
if (user.usersToSandboxes.find((uts) => uts.sandboxId === sandboxId)) {
|
||||||
return new Response("User already has access.", { status: 400 })
|
return new Response("User already has access.", { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.insert(usersToSandboxes)
|
.insert(usersToSandboxes)
|
||||||
.values({ userId: user.id, sandboxId, sharedOn: new Date() })
|
.values({ userId: user.id, sandboxId, sharedOn: new Date() })
|
||||||
.get()
|
.get()
|
||||||
|
|
||||||
return success
|
return success
|
||||||
} else if (method === "DELETE") {
|
} else if (method === "DELETE") {
|
||||||
const deleteShareSchema = z.object({
|
const deleteShareSchema = z.object({
|
||||||
sandboxId: z.string(),
|
sandboxId: z.string(),
|
||||||
userId: z.string(),
|
userId: z.string(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const { sandboxId, userId } = deleteShareSchema.parse(body)
|
const { sandboxId, userId } = deleteShareSchema.parse(body)
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.delete(usersToSandboxes)
|
.delete(usersToSandboxes)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(usersToSandboxes.userId, userId),
|
eq(usersToSandboxes.userId, userId),
|
||||||
eq(usersToSandboxes.sandboxId, sandboxId)
|
eq(usersToSandboxes.sandboxId, sandboxId)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
return success
|
return success
|
||||||
} else return methodNotAllowed
|
} else return methodNotAllowed
|
||||||
} else if (path === "/api/user") {
|
} else if (path === "/api/user") {
|
||||||
if (method === "GET") {
|
if (method === "GET") {
|
||||||
const params = url.searchParams
|
const params = url.searchParams
|
||||||
|
|
||||||
if (params.has("id")) {
|
if (params.has("id")) {
|
||||||
const id = params.get("id") as string
|
const id = params.get("id") as string
|
||||||
|
|
||||||
const res = await db.query.user.findFirst({
|
const res = await db.query.user.findFirst({
|
||||||
where: (user, { eq }) => eq(user.id, id),
|
where: (user, { eq }) => eq(user.id, id),
|
||||||
with: {
|
with: {
|
||||||
sandbox: {
|
sandbox: {
|
||||||
orderBy: (sandbox, { desc }) => [desc(sandbox.createdAt)],
|
orderBy: (sandbox, { desc }) => [desc(sandbox.createdAt)],
|
||||||
with: {
|
with: {
|
||||||
likes: true,
|
likes: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
usersToSandboxes: true,
|
usersToSandboxes: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if (res) {
|
if (res) {
|
||||||
const transformedUser: UserResponse = {
|
const transformedUser: UserResponse = {
|
||||||
...res,
|
...res,
|
||||||
sandbox: res.sandbox.map(
|
sandbox: res.sandbox.map(
|
||||||
(sb): SandboxWithLiked => ({
|
(sb): SandboxWithLiked => ({
|
||||||
...sb,
|
...sb,
|
||||||
liked: sb.likes.some((like) => like.userId === id),
|
liked: sb.likes.some((like) => like.userId === id),
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
return json(transformedUser)
|
return json(transformedUser)
|
||||||
}
|
}
|
||||||
return json(res ?? {})
|
return json(res ?? {})
|
||||||
} else if (params.has("username")) {
|
} else if (params.has("username")) {
|
||||||
const username = params.get("username") as string
|
const username = params.get("username") as string
|
||||||
const userId = params.get("currentUserId")
|
const userId = params.get("currentUserId")
|
||||||
const res = await db.query.user.findFirst({
|
const res = await db.query.user.findFirst({
|
||||||
where: (user, { eq }) => eq(user.username, username),
|
where: (user, { eq }) => eq(user.username, username),
|
||||||
with: {
|
with: {
|
||||||
sandbox: {
|
sandbox: {
|
||||||
orderBy: (sandbox, { desc }) => [desc(sandbox.createdAt)],
|
orderBy: (sandbox, { desc }) => [desc(sandbox.createdAt)],
|
||||||
with: {
|
with: {
|
||||||
likes: true,
|
likes: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
usersToSandboxes: true,
|
usersToSandboxes: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if (res) {
|
if (res) {
|
||||||
const transformedUser: UserResponse = {
|
const transformedUser: UserResponse = {
|
||||||
...res,
|
...res,
|
||||||
sandbox: res.sandbox.map(
|
sandbox: res.sandbox.map(
|
||||||
(sb): SandboxWithLiked => ({
|
(sb): SandboxWithLiked => ({
|
||||||
...sb,
|
...sb,
|
||||||
liked: sb.likes.some((like) => like.userId === userId),
|
liked: sb.likes.some((like) => like.userId === userId),
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
return json(transformedUser)
|
return json(transformedUser)
|
||||||
}
|
}
|
||||||
return json(res ?? {})
|
return json(res ?? {})
|
||||||
} else {
|
} else {
|
||||||
const res = await db.select().from(user).all()
|
const res = await db.select().from(user).all()
|
||||||
return json(res ?? {})
|
return json(res ?? {})
|
||||||
}
|
}
|
||||||
} else if (method === "POST") {
|
} else if (method === "POST") {
|
||||||
const userSchema = z.object({
|
const userSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
username: z.string(),
|
username: z.string(),
|
||||||
avatarUrl: z.string().optional(),
|
avatarUrl: z.string().optional(),
|
||||||
createdAt: z.string().optional(),
|
createdAt: z.string().optional(),
|
||||||
generations: z.number().optional(),
|
generations: z.number().optional(),
|
||||||
tier: z.enum(["FREE", "PRO", "ENTERPRISE"]).optional(),
|
tier: z.enum(["FREE", "PRO", "ENTERPRISE"]).optional(),
|
||||||
tierExpiresAt: z.number().optional(),
|
tierExpiresAt: z.number().optional(),
|
||||||
lastResetDate: z.number().optional(),
|
lastResetDate: z.number().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
|
|
||||||
const { id, name, email, username, avatarUrl, createdAt, generations, tier, tierExpiresAt, lastResetDate } = userSchema.parse(body)
|
const {
|
||||||
const res = await db
|
id,
|
||||||
.insert(user)
|
name,
|
||||||
.values({
|
email,
|
||||||
id,
|
username,
|
||||||
name,
|
avatarUrl,
|
||||||
email,
|
createdAt,
|
||||||
username,
|
generations,
|
||||||
avatarUrl,
|
tier,
|
||||||
createdAt: createdAt ? new Date(createdAt) : new Date(),
|
tierExpiresAt,
|
||||||
generations,
|
lastResetDate,
|
||||||
tier,
|
} = userSchema.parse(body)
|
||||||
tierExpiresAt,
|
const res = await db
|
||||||
lastResetDate,
|
.insert(user)
|
||||||
})
|
.values({
|
||||||
.returning()
|
id,
|
||||||
.get()
|
name,
|
||||||
return json({ res })
|
email,
|
||||||
} else if (method === "DELETE") {
|
username,
|
||||||
const params = url.searchParams
|
avatarUrl,
|
||||||
if (params.has("id")) {
|
createdAt: createdAt ? new Date(createdAt) : new Date(),
|
||||||
const id = params.get("id") as string
|
generations,
|
||||||
await db.delete(user).where(eq(user.id, id))
|
tier,
|
||||||
return success
|
tierExpiresAt,
|
||||||
} else return invalidRequest
|
lastResetDate,
|
||||||
} else if (method === "PUT") {
|
})
|
||||||
const updateUserSchema = z.object({
|
.returning()
|
||||||
id: z.string(),
|
.get()
|
||||||
name: z.string().optional(),
|
return json({ res })
|
||||||
email: z.string().email().optional(),
|
} else if (method === "DELETE") {
|
||||||
username: z.string().optional(),
|
const params = url.searchParams
|
||||||
avatarUrl: z.string().optional(),
|
if (params.has("id")) {
|
||||||
generations: z.number().optional(),
|
const id = params.get("id") as string
|
||||||
})
|
await db.delete(user).where(eq(user.id, id))
|
||||||
|
return success
|
||||||
|
} else return invalidRequest
|
||||||
|
} else if (method === "PUT") {
|
||||||
|
const updateUserSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string().optional(),
|
||||||
|
email: z.string().email().optional(),
|
||||||
|
username: z.string().optional(),
|
||||||
|
avatarUrl: z.string().optional(),
|
||||||
|
generations: z.number().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const validatedData = updateUserSchema.parse(body)
|
const validatedData = updateUserSchema.parse(body)
|
||||||
|
|
||||||
const { id, username, ...updateData } = validatedData
|
const { id, username, ...updateData } = validatedData
|
||||||
|
|
||||||
// If username is being updated, check for existing username
|
// If username is being updated, check for existing username
|
||||||
if (username) {
|
if (username) {
|
||||||
const existingUser = await db
|
const existingUser = await db
|
||||||
.select()
|
.select()
|
||||||
.from(user)
|
.from(user)
|
||||||
.where(eq(user.username, username))
|
.where(eq(user.username, username))
|
||||||
.get()
|
.get()
|
||||||
if (existingUser && existingUser.id !== id) {
|
if (existingUser && existingUser.id !== id) {
|
||||||
return json({ error: "Username already exists" }, { status: 409 })
|
return json({ error: "Username already exists" }, { status: 409 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const cleanUpdateData = {
|
const cleanUpdateData = {
|
||||||
...updateData,
|
...updateData,
|
||||||
...(username ? { username } : {}),
|
...(username ? { username } : {}),
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await db
|
const res = await db
|
||||||
.update(user)
|
.update(user)
|
||||||
.set(cleanUpdateData)
|
.set(cleanUpdateData)
|
||||||
.where(eq(user.id, id))
|
.where(eq(user.id, id))
|
||||||
.returning()
|
.returning()
|
||||||
.get()
|
.get()
|
||||||
|
|
||||||
if (!res) {
|
if (!res) {
|
||||||
return json({ error: "User not found" }, { status: 404 })
|
return json({ error: "User not found" }, { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
return json({ res })
|
return json({ res })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof z.ZodError) {
|
if (error instanceof z.ZodError) {
|
||||||
return json({ error: error.errors }, { status: 400 })
|
return json({ error: error.errors }, { status: 400 })
|
||||||
}
|
}
|
||||||
return json({ error: "Internal server error" }, { status: 500 })
|
return json({ error: "Internal server error" }, { status: 500 })
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return methodNotAllowed
|
return methodNotAllowed
|
||||||
}
|
}
|
||||||
} else if (path === "/api/user/check-username") {
|
} else if (path === "/api/user/check-username") {
|
||||||
if (method === "GET") {
|
if (method === "GET") {
|
||||||
const params = url.searchParams
|
const params = url.searchParams
|
||||||
const username = params.get("username")
|
const username = params.get("username")
|
||||||
|
|
||||||
if (!username) return invalidRequest
|
if (!username) return invalidRequest
|
||||||
|
|
||||||
const exists = await db.query.user.findFirst({
|
const exists = await db.query.user.findFirst({
|
||||||
where: (user, { eq }) => eq(user.username, username),
|
where: (user, { eq }) => eq(user.username, username),
|
||||||
})
|
})
|
||||||
|
|
||||||
return json({ exists: !!exists })
|
return json({ exists: !!exists })
|
||||||
}
|
}
|
||||||
return methodNotAllowed
|
return methodNotAllowed
|
||||||
} else if (path === "/api/user/increment-generations" && method === "POST") {
|
} else if (
|
||||||
const schema = z.object({
|
path === "/api/user/increment-generations" &&
|
||||||
userId: z.string(),
|
method === "POST"
|
||||||
})
|
) {
|
||||||
|
const schema = z.object({
|
||||||
|
userId: z.string(),
|
||||||
|
})
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const { userId } = schema.parse(body)
|
const { userId } = schema.parse(body)
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(user)
|
.update(user)
|
||||||
.set({ generations: sql`${user.generations} + 1` })
|
.set({ generations: sql`${user.generations} + 1` })
|
||||||
.where(eq(user.id, userId))
|
.where(eq(user.id, userId))
|
||||||
.get()
|
.get()
|
||||||
|
|
||||||
return success
|
return success
|
||||||
} else if (path === "/api/user/update-tier" && method === "POST") {
|
} else if (path === "/api/user/update-tier" && method === "POST") {
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
userId: z.string(),
|
userId: z.string(),
|
||||||
tier: z.enum(["FREE", "PRO", "ENTERPRISE"]),
|
tier: z.enum(["FREE", "PRO", "ENTERPRISE"]),
|
||||||
tierExpiresAt: z.date(),
|
tierExpiresAt: z.date(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const { userId, tier, tierExpiresAt } = schema.parse(body)
|
const { userId, tier, tierExpiresAt } = schema.parse(body)
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(user)
|
.update(user)
|
||||||
.set({
|
.set({
|
||||||
tier,
|
tier,
|
||||||
tierExpiresAt: tierExpiresAt.getTime(),
|
tierExpiresAt: tierExpiresAt.getTime(),
|
||||||
// Reset generations when upgrading tier
|
// Reset generations when upgrading tier
|
||||||
generations: 0
|
generations: 0,
|
||||||
})
|
})
|
||||||
.where(eq(user.id, userId))
|
.where(eq(user.id, userId))
|
||||||
.get()
|
.get()
|
||||||
|
|
||||||
return success
|
return success
|
||||||
} else if (path === "/api/user/check-reset" && method === "POST") {
|
} else if (path === "/api/user/check-reset" && method === "POST") {
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
userId: z.string(),
|
userId: z.string(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const { userId } = schema.parse(body)
|
const { userId } = schema.parse(body)
|
||||||
|
|
||||||
const dbUser = await db.query.user.findFirst({
|
const dbUser = await db.query.user.findFirst({
|
||||||
where: (user, { eq }) => eq(user.id, userId),
|
where: (user, { eq }) => eq(user.id, userId),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!dbUser) {
|
if (!dbUser) {
|
||||||
return new Response("User not found", { status: 404 })
|
return new Response("User not found", { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const lastReset = dbUser.lastResetDate ? new Date(dbUser.lastResetDate) : new Date(0)
|
const lastReset = dbUser.lastResetDate
|
||||||
|
? new Date(dbUser.lastResetDate)
|
||||||
|
: new Date(0)
|
||||||
|
|
||||||
if (now.getMonth() !== lastReset.getMonth() || now.getFullYear() !== lastReset.getFullYear()) {
|
if (
|
||||||
await db
|
now.getMonth() !== lastReset.getMonth() ||
|
||||||
.update(user)
|
now.getFullYear() !== lastReset.getFullYear()
|
||||||
.set({
|
) {
|
||||||
generations: 0,
|
await db
|
||||||
lastResetDate: now.getTime()
|
.update(user)
|
||||||
})
|
.set({
|
||||||
.where(eq(user.id, userId))
|
generations: 0,
|
||||||
.get()
|
lastResetDate: now.getTime(),
|
||||||
|
})
|
||||||
|
.where(eq(user.id, userId))
|
||||||
|
.get()
|
||||||
|
|
||||||
return new Response("Reset successful", { status: 200 })
|
return new Response("Reset successful", { status: 200 })
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response("No reset needed", { status: 200 })
|
return new Response("No reset needed", { status: 200 })
|
||||||
} else return notFound
|
} else return notFound
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
@ -4,112 +4,112 @@ import { integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core"
|
|||||||
|
|
||||||
// #region Tables
|
// #region Tables
|
||||||
export const user = sqliteTable("user", {
|
export const user = sqliteTable("user", {
|
||||||
id: text("id")
|
id: text("id")
|
||||||
.$defaultFn(() => createId())
|
.$defaultFn(() => createId())
|
||||||
.primaryKey()
|
.primaryKey()
|
||||||
.unique(),
|
.unique(),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
email: text("email").notNull(),
|
email: text("email").notNull(),
|
||||||
username: text("username").notNull().unique(),
|
username: text("username").notNull().unique(),
|
||||||
avatarUrl: text("avatarUrl"),
|
avatarUrl: text("avatarUrl"),
|
||||||
createdAt: integer("createdAt", { mode: "timestamp_ms" }).default(
|
createdAt: integer("createdAt", { mode: "timestamp_ms" }).default(
|
||||||
sql`CURRENT_TIMESTAMP`
|
sql`CURRENT_TIMESTAMP`
|
||||||
),
|
),
|
||||||
generations: integer("generations").default(0),
|
generations: integer("generations").default(0),
|
||||||
tier: text("tier", { enum: ["FREE", "PRO", "ENTERPRISE"] }).default("FREE"),
|
tier: text("tier", { enum: ["FREE", "PRO", "ENTERPRISE"] }).default("FREE"),
|
||||||
tierExpiresAt: integer("tierExpiresAt"),
|
tierExpiresAt: integer("tierExpiresAt"),
|
||||||
lastResetDate: integer("lastResetDate"),
|
lastResetDate: integer("lastResetDate"),
|
||||||
})
|
})
|
||||||
|
|
||||||
export type User = typeof user.$inferSelect
|
export type User = typeof user.$inferSelect
|
||||||
|
|
||||||
export const sandbox = sqliteTable("sandbox", {
|
export const sandbox = sqliteTable("sandbox", {
|
||||||
id: text("id")
|
id: text("id")
|
||||||
.$defaultFn(() => createId())
|
.$defaultFn(() => createId())
|
||||||
.primaryKey()
|
.primaryKey()
|
||||||
.unique(),
|
.unique(),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
type: text("type").notNull(),
|
type: text("type").notNull(),
|
||||||
visibility: text("visibility", { enum: ["public", "private"] }),
|
visibility: text("visibility", { enum: ["public", "private"] }),
|
||||||
createdAt: integer("createdAt", { mode: "timestamp_ms" }).default(
|
createdAt: integer("createdAt", { mode: "timestamp_ms" }).default(
|
||||||
sql`CURRENT_TIMESTAMP`
|
sql`CURRENT_TIMESTAMP`
|
||||||
),
|
),
|
||||||
userId: text("user_id")
|
userId: text("user_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => user.id),
|
.references(() => user.id),
|
||||||
likeCount: integer("likeCount").default(0),
|
likeCount: integer("likeCount").default(0),
|
||||||
viewCount: integer("viewCount").default(0),
|
viewCount: integer("viewCount").default(0),
|
||||||
})
|
})
|
||||||
|
|
||||||
export type Sandbox = typeof sandbox.$inferSelect
|
export type Sandbox = typeof sandbox.$inferSelect
|
||||||
|
|
||||||
export const sandboxLikes = sqliteTable(
|
export const sandboxLikes = sqliteTable(
|
||||||
"sandbox_likes",
|
"sandbox_likes",
|
||||||
{
|
{
|
||||||
userId: text("user_id")
|
userId: text("user_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => user.id),
|
.references(() => user.id),
|
||||||
sandboxId: text("sandbox_id")
|
sandboxId: text("sandbox_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => sandbox.id),
|
.references(() => sandbox.id),
|
||||||
createdAt: integer("createdAt", { mode: "timestamp_ms" }).default(
|
createdAt: integer("createdAt", { mode: "timestamp_ms" }).default(
|
||||||
sql`CURRENT_TIMESTAMP`
|
sql`CURRENT_TIMESTAMP`
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
(table) => ({
|
(table) => ({
|
||||||
pk: primaryKey({ columns: [table.sandboxId, table.userId] }),
|
pk: primaryKey({ columns: [table.sandboxId, table.userId] }),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
export const usersToSandboxes = sqliteTable("users_to_sandboxes", {
|
export const usersToSandboxes = sqliteTable("users_to_sandboxes", {
|
||||||
userId: text("userId")
|
userId: text("userId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => user.id),
|
.references(() => user.id),
|
||||||
sandboxId: text("sandboxId")
|
sandboxId: text("sandboxId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => sandbox.id),
|
.references(() => sandbox.id),
|
||||||
sharedOn: integer("sharedOn", { mode: "timestamp_ms" }),
|
sharedOn: integer("sharedOn", { mode: "timestamp_ms" }),
|
||||||
})
|
})
|
||||||
|
|
||||||
// #region Relations
|
// #region Relations
|
||||||
export const userRelations = relations(user, ({ many }) => ({
|
export const userRelations = relations(user, ({ many }) => ({
|
||||||
sandbox: many(sandbox),
|
sandbox: many(sandbox),
|
||||||
usersToSandboxes: many(usersToSandboxes),
|
usersToSandboxes: many(usersToSandboxes),
|
||||||
likes: many(sandboxLikes),
|
likes: many(sandboxLikes),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
export const sandboxRelations = relations(sandbox, ({ one, many }) => ({
|
export const sandboxRelations = relations(sandbox, ({ one, many }) => ({
|
||||||
author: one(user, {
|
author: one(user, {
|
||||||
fields: [sandbox.userId],
|
fields: [sandbox.userId],
|
||||||
references: [user.id],
|
references: [user.id],
|
||||||
}),
|
}),
|
||||||
usersToSandboxes: many(usersToSandboxes),
|
usersToSandboxes: many(usersToSandboxes),
|
||||||
likes: many(sandboxLikes),
|
likes: many(sandboxLikes),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
export const sandboxLikesRelations = relations(sandboxLikes, ({ one }) => ({
|
export const sandboxLikesRelations = relations(sandboxLikes, ({ one }) => ({
|
||||||
user: one(user, {
|
user: one(user, {
|
||||||
fields: [sandboxLikes.userId],
|
fields: [sandboxLikes.userId],
|
||||||
references: [user.id],
|
references: [user.id],
|
||||||
}),
|
}),
|
||||||
sandbox: one(sandbox, {
|
sandbox: one(sandbox, {
|
||||||
fields: [sandboxLikes.sandboxId],
|
fields: [sandboxLikes.sandboxId],
|
||||||
references: [sandbox.id],
|
references: [sandbox.id],
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
export const usersToSandboxesRelations = relations(
|
export const usersToSandboxesRelations = relations(
|
||||||
usersToSandboxes,
|
usersToSandboxes,
|
||||||
({ one }) => ({
|
({ one }) => ({
|
||||||
group: one(sandbox, {
|
group: one(sandbox, {
|
||||||
fields: [usersToSandboxes.sandboxId],
|
fields: [usersToSandboxes.sandboxId],
|
||||||
references: [sandbox.id],
|
references: [sandbox.id],
|
||||||
}),
|
}),
|
||||||
user: one(user, {
|
user: one(user, {
|
||||||
fields: [usersToSandboxes.userId],
|
fields: [usersToSandboxes.userId],
|
||||||
references: [user.id],
|
references: [user.id],
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
// #endregion
|
// #endregion
|
||||||
|
@ -7,7 +7,6 @@ PORT=4000
|
|||||||
WORKERS_KEY=
|
WORKERS_KEY=
|
||||||
DATABASE_WORKER_URL=
|
DATABASE_WORKER_URL=
|
||||||
STORAGE_WORKER_URL=
|
STORAGE_WORKER_URL=
|
||||||
AI_WORKER_URL=
|
|
||||||
E2B_API_KEY=
|
E2B_API_KEY=
|
||||||
DOKKU_HOST=
|
DOKKU_HOST=
|
||||||
DOKKU_USERNAME=
|
DOKKU_USERNAME=
|
||||||
|
@ -31,9 +31,30 @@ export class DokkuClient extends SSHSocketClient {
|
|||||||
|
|
||||||
// List all deployed Dokku apps
|
// List all deployed Dokku apps
|
||||||
async listApps(): Promise<string[]> {
|
async listApps(): Promise<string[]> {
|
||||||
const response = await this.sendCommand("apps:list")
|
const response = await this.sendCommand("--quiet apps:list")
|
||||||
// Split the output by newline and remove the header
|
return response.output.split("\n")
|
||||||
return response.output.split("\n").slice(1)
|
}
|
||||||
|
|
||||||
|
// Get the creation timestamp of an app
|
||||||
|
async getAppCreatedAt(appName: string): Promise<number> {
|
||||||
|
const response = await this.sendCommand(
|
||||||
|
`apps:report --app-created-at ${appName}`
|
||||||
|
)
|
||||||
|
const createdAt = parseInt(response.output.trim(), 10)
|
||||||
|
|
||||||
|
if (isNaN(createdAt)) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to retrieve creation timestamp for app ${appName}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return createdAt
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if an app exists
|
||||||
|
async appExists(appName: string): Promise<boolean> {
|
||||||
|
const response = await this.sendCommand(`apps:exists ${appName}`)
|
||||||
|
return response.output.includes("App") === false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -92,7 +92,11 @@ export class FileManager {
|
|||||||
|
|
||||||
// Convert local file path to remote path
|
// Convert local file path to remote path
|
||||||
private getRemoteFileId(localId: string): string {
|
private getRemoteFileId(localId: string): string {
|
||||||
return `projects/${this.sandboxId}${localId}`
|
return [
|
||||||
|
"projects",
|
||||||
|
this.sandboxId,
|
||||||
|
localId.startsWith("/") ? localId : localId,
|
||||||
|
].join("/")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert remote file path to local file path
|
// Convert remote file path to local file path
|
||||||
@ -326,13 +330,15 @@ export class FileManager {
|
|||||||
if (Buffer.byteLength(body, "utf-8") > MAX_BODY_SIZE) {
|
if (Buffer.byteLength(body, "utf-8") > MAX_BODY_SIZE) {
|
||||||
throw new Error("File size too large. Please reduce the file size.")
|
throw new Error("File size too large. Please reduce the file size.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save to remote storage
|
||||||
await RemoteFileStorage.saveFile(this.getRemoteFileId(fileId), body)
|
await RemoteFileStorage.saveFile(this.getRemoteFileId(fileId), body)
|
||||||
|
|
||||||
|
// Update local file data cache
|
||||||
let file = this.fileData.find((f) => f.id === fileId)
|
let file = this.fileData.find((f) => f.id === fileId)
|
||||||
if (file) {
|
if (file) {
|
||||||
file.data = body
|
file.data = body
|
||||||
} else {
|
} else {
|
||||||
// If the file wasn't in our cache, add it
|
|
||||||
file = {
|
file = {
|
||||||
id: fileId,
|
id: fileId,
|
||||||
data: body,
|
data: body,
|
||||||
@ -340,7 +346,49 @@ export class FileManager {
|
|||||||
this.fileData.push(file)
|
this.fileData.push(file)
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.sandbox.files.write(path.posix.join(this.dirName, fileId), body)
|
// Save to sandbox filesystem
|
||||||
|
const filePath = path.posix.join(this.dirName, fileId)
|
||||||
|
await this.sandbox.files.write(filePath, body)
|
||||||
|
|
||||||
|
// Instead of updating the entire file structure, just ensure this file exists in it
|
||||||
|
const parts = fileId.split('/').filter(Boolean)
|
||||||
|
let current = this.files
|
||||||
|
let currentPath = ''
|
||||||
|
|
||||||
|
// Navigate/create the path to the file
|
||||||
|
for (let i = 0; i < parts.length - 1; i++) {
|
||||||
|
currentPath += '/' + parts[i]
|
||||||
|
let folder = current.find(
|
||||||
|
(f) => f.type === 'folder' && f.name === parts[i]
|
||||||
|
) as TFolder
|
||||||
|
|
||||||
|
if (!folder) {
|
||||||
|
folder = {
|
||||||
|
id: currentPath,
|
||||||
|
type: 'folder',
|
||||||
|
name: parts[i],
|
||||||
|
children: [],
|
||||||
|
}
|
||||||
|
current.push(folder)
|
||||||
|
}
|
||||||
|
current = folder.children
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add/update the file in the structure if it doesn't exist
|
||||||
|
const fileName = parts[parts.length - 1]
|
||||||
|
const existingFile = current.find(
|
||||||
|
(f) => f.type === 'file' && f.name === fileName
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!existingFile) {
|
||||||
|
current.push({
|
||||||
|
id: fileId,
|
||||||
|
type: 'file',
|
||||||
|
name: fileName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
this.refreshFileList?.(this.files)
|
||||||
this.fixPermissions()
|
this.fixPermissions()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -13,7 +13,10 @@ export class SSHSocketClient {
|
|||||||
private conn: Client
|
private conn: Client
|
||||||
private config: SSHConfig
|
private config: SSHConfig
|
||||||
private socketPath: string
|
private socketPath: string
|
||||||
private isConnected: boolean = false
|
private _isConnected: boolean = false
|
||||||
|
public get isConnected(): boolean {
|
||||||
|
return this._isConnected
|
||||||
|
}
|
||||||
|
|
||||||
// Constructor initializes the SSH client and sets up configuration
|
// Constructor initializes the SSH client and sets up configuration
|
||||||
constructor(config: SSHConfig, socketPath: string) {
|
constructor(config: SSHConfig, socketPath: string) {
|
||||||
@ -34,7 +37,7 @@ export class SSHSocketClient {
|
|||||||
private closeConnection() {
|
private closeConnection() {
|
||||||
console.log("Closing SSH connection...")
|
console.log("Closing SSH connection...")
|
||||||
this.conn.end()
|
this.conn.end()
|
||||||
this.isConnected = false
|
this._isConnected = false
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -44,17 +47,17 @@ export class SSHSocketClient {
|
|||||||
this.conn
|
this.conn
|
||||||
.on("ready", () => {
|
.on("ready", () => {
|
||||||
console.log("SSH connection established")
|
console.log("SSH connection established")
|
||||||
this.isConnected = true
|
this._isConnected = true
|
||||||
resolve()
|
resolve()
|
||||||
})
|
})
|
||||||
.on("error", (err) => {
|
.on("error", (err) => {
|
||||||
console.error("SSH connection error:", err)
|
console.error("SSH connection error:", err)
|
||||||
this.isConnected = false
|
this._isConnected = false
|
||||||
reject(err)
|
reject(err)
|
||||||
})
|
})
|
||||||
.on("close", () => {
|
.on("close", () => {
|
||||||
console.log("SSH connection closed")
|
console.log("SSH connection closed")
|
||||||
this.isConnected = false
|
this._isConnected = false
|
||||||
})
|
})
|
||||||
.connect(this.config)
|
.connect(this.config)
|
||||||
})
|
})
|
||||||
@ -86,10 +89,13 @@ export class SSHSocketClient {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
.on("data", (data: Buffer) => {
|
.on("data", (data: Buffer) => {
|
||||||
|
// Netcat remains open until it is closed, so we close the connection once we receive data.
|
||||||
resolve(data.toString())
|
resolve(data.toString())
|
||||||
|
stream.close()
|
||||||
})
|
})
|
||||||
.stderr.on("data", (data: Buffer) => {
|
.stderr.on("data", (data: Buffer) => {
|
||||||
reject(new Error(data.toString()))
|
reject(new Error(data.toString()))
|
||||||
|
stream.close()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
@ -156,6 +156,40 @@ export class Sandbox {
|
|||||||
return { success: true, apps: await this.dokkuClient.listApps() }
|
return { success: true, apps: await this.dokkuClient.listApps() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle getting app creation timestamp
|
||||||
|
const handleGetAppCreatedAt: SocketHandler = async ({ appName }) => {
|
||||||
|
if (!this.dokkuClient)
|
||||||
|
throw new Error(
|
||||||
|
"Failed to retrieve app creation timestamp: No Dokku client"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
createdAt: await this.dokkuClient.getAppCreatedAt(appName),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle checking if an app exists
|
||||||
|
const handleAppExists: SocketHandler = async ({ appName }) => {
|
||||||
|
if (!this.dokkuClient) {
|
||||||
|
console.log("Failed to check app existence: No Dokku client")
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!this.dokkuClient.isConnected) {
|
||||||
|
console.log(
|
||||||
|
"Failed to check app existence: The Dokku client is not connected"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
exists: await this.dokkuClient.appExists(appName),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Handle deploying code
|
// Handle deploying code
|
||||||
const handleDeploy: SocketHandler = async (_: any) => {
|
const handleDeploy: SocketHandler = async (_: any) => {
|
||||||
if (!this.gitClient) throw Error("No git client")
|
if (!this.gitClient) throw Error("No git client")
|
||||||
@ -253,7 +287,9 @@ export class Sandbox {
|
|||||||
getFolder: handleGetFolder,
|
getFolder: handleGetFolder,
|
||||||
saveFile: handleSaveFile,
|
saveFile: handleSaveFile,
|
||||||
moveFile: handleMoveFile,
|
moveFile: handleMoveFile,
|
||||||
list: handleListApps,
|
listApps: handleListApps,
|
||||||
|
getAppCreatedAt: handleGetAppCreatedAt,
|
||||||
|
getAppExists: handleAppExists,
|
||||||
deploy: handleDeploy,
|
deploy: handleDeploy,
|
||||||
createFile: handleCreateFile,
|
createFile: handleCreateFile,
|
||||||
createFolder: handleCreateFolder,
|
createFolder: handleCreateFolder,
|
||||||
|
@ -146,6 +146,8 @@ io.on("connection", async (socket) => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
socket.emit("ready")
|
||||||
|
|
||||||
// Handle disconnection event
|
// Handle disconnection event
|
||||||
socket.on("disconnect", async () => {
|
socket.on("disconnect", async () => {
|
||||||
try {
|
try {
|
||||||
|
@ -3,18 +3,21 @@ CLERK_SECRET_KEY=
|
|||||||
NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY=
|
NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY=
|
||||||
LIVEBLOCKS_SECRET_KEY=
|
LIVEBLOCKS_SECRET_KEY=
|
||||||
|
|
||||||
NEXT_PUBLIC_SERVER_URL=http://localhost:4000
|
NEXT_PUBLIC_SERVER_PORT=4000
|
||||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||||
|
NEXT_PUBLIC_API_URL=http://localhost:4000
|
||||||
|
NEXT_PUBLIC_SERVER_URL=http://localhost:4000
|
||||||
|
|
||||||
# Set WORKER_URLs after deploying the workers.
|
# Set WORKER_URLs after deploying the workers.
|
||||||
# Set NEXT_PUBLIC_WORKERS_KEY to be the same as KEY in /backend/storage/wrangler.toml.
|
# 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=https://database.your-worker.workers.dev
|
||||||
NEXT_PUBLIC_DATABASE_WORKER_URL=
|
NEXT_PUBLIC_STORAGE_WORKER_URL=https://storage.your-worker.workers.dev
|
||||||
NEXT_PUBLIC_STORAGE_WORKER_URL=
|
NEXT_PUBLIC_WORKERS_KEY=SUPERDUPERSECRET
|
||||||
NEXT_PUBLIC_AI_WORKER_URL=
|
|
||||||
NEXT_PUBLIC_WORKERS_KEY=
|
|
||||||
|
|
||||||
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
|
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
|
||||||
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
|
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
|
||||||
NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/dashboard
|
NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/dashboard
|
||||||
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/dashboard
|
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/dashboard
|
||||||
|
|
||||||
|
ANTHROPIC_API_KEY=
|
||||||
|
OPENAI_API_KEY=
|
@ -1,12 +1,59 @@
|
|||||||
import { currentUser } from "@clerk/nextjs"
|
import {
|
||||||
import { Anthropic } from "@anthropic-ai/sdk"
|
ignoredFiles,
|
||||||
import { TIERS } from "@/lib/tiers"
|
ignoredFolders,
|
||||||
|
} from "@/components/editor/AIChat/lib/ignored-paths"
|
||||||
import { templateConfigs } from "@/lib/templates"
|
import { templateConfigs } from "@/lib/templates"
|
||||||
|
import { TIERS } from "@/lib/tiers"
|
||||||
|
import { TFile, TFolder } from "@/lib/types"
|
||||||
|
import { Anthropic } from "@anthropic-ai/sdk"
|
||||||
|
import { currentUser } from "@clerk/nextjs"
|
||||||
|
|
||||||
const anthropic = new Anthropic({
|
const anthropic = new Anthropic({
|
||||||
apiKey: process.env.ANTHROPIC_API_KEY!,
|
apiKey: process.env.ANTHROPIC_API_KEY!,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Format file structure for context
|
||||||
|
function formatFileStructure(
|
||||||
|
items: (TFile | TFolder)[] | undefined,
|
||||||
|
prefix = ""
|
||||||
|
): string {
|
||||||
|
if (!items || !Array.isArray(items)) {
|
||||||
|
return "No files available"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort items to show folders first, then files
|
||||||
|
const sortedItems = [...items].sort((a, b) => {
|
||||||
|
if (a.type === b.type) return a.name.localeCompare(b.name)
|
||||||
|
return a.type === "folder" ? -1 : 1
|
||||||
|
})
|
||||||
|
|
||||||
|
return sortedItems
|
||||||
|
.map((item) => {
|
||||||
|
if (
|
||||||
|
item.type === "file" &&
|
||||||
|
!ignoredFiles.some(
|
||||||
|
(pattern) =>
|
||||||
|
item.name.endsWith(pattern.replace("*", "")) ||
|
||||||
|
item.name === pattern
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return `${prefix}├── ${item.name}`
|
||||||
|
} else if (
|
||||||
|
item.type === "folder" &&
|
||||||
|
!ignoredFolders.some((folder) => folder === item.name)
|
||||||
|
) {
|
||||||
|
const folderContent = formatFileStructure(
|
||||||
|
item.children,
|
||||||
|
`${prefix}│ `
|
||||||
|
)
|
||||||
|
return `${prefix}├── ${item.name}/\n${folderContent}`
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
const user = await currentUser()
|
const user = await currentUser()
|
||||||
@ -43,7 +90,8 @@ export async function POST(request: Request) {
|
|||||||
const userData = await dbUser.json()
|
const userData = await dbUser.json()
|
||||||
|
|
||||||
// Get tier settings
|
// Get tier settings
|
||||||
const tierSettings = TIERS[userData.tier as keyof typeof TIERS] || TIERS.FREE
|
const tierSettings =
|
||||||
|
TIERS[userData.tier as keyof typeof TIERS] || TIERS.FREE
|
||||||
if (userData.generations >= tierSettings.generations) {
|
if (userData.generations >= tierSettings.generations) {
|
||||||
return new Response(
|
return new Response(
|
||||||
`AI generation limit reached for your ${userData.tier || "FREE"} tier`,
|
`AI generation limit reached for your ${userData.tier || "FREE"} tier`,
|
||||||
@ -58,27 +106,32 @@ export async function POST(request: Request) {
|
|||||||
isEditMode,
|
isEditMode,
|
||||||
fileName,
|
fileName,
|
||||||
line,
|
line,
|
||||||
templateType
|
templateType,
|
||||||
|
files,
|
||||||
|
projectName,
|
||||||
} = await request.json()
|
} = await request.json()
|
||||||
|
|
||||||
// Get template configuration
|
// Get template configuration
|
||||||
const templateConfig = templateConfigs[templateType]
|
const templateConfig = templateConfigs[templateType]
|
||||||
|
|
||||||
// Create template context
|
// Create template context
|
||||||
const templateContext = templateConfig ? `
|
const templateContext = templateConfig
|
||||||
|
? `
|
||||||
Project Template: ${templateConfig.name}
|
Project Template: ${templateConfig.name}
|
||||||
|
|
||||||
File Structure:
|
Current File Structure:
|
||||||
${Object.entries(templateConfig.fileStructure)
|
${files ? formatFileStructure(files) : "No files available"}
|
||||||
.map(([path, info]) => `${path} - ${info.description}`)
|
|
||||||
.join('\n')}
|
|
||||||
|
|
||||||
Conventions:
|
Conventions:
|
||||||
${templateConfig.conventions.join('\n')}
|
${templateConfig.conventions.join("\n")}
|
||||||
|
|
||||||
Dependencies:
|
Dependencies:
|
||||||
${JSON.stringify(templateConfig.dependencies, null, 2)}
|
${JSON.stringify(templateConfig.dependencies, null, 2)}
|
||||||
` : ''
|
|
||||||
|
Scripts:
|
||||||
|
${JSON.stringify(templateConfig.scripts, null, 2)}
|
||||||
|
`
|
||||||
|
: ""
|
||||||
|
|
||||||
// Create system message based on mode
|
// Create system message based on mode
|
||||||
let systemMessage
|
let systemMessage
|
||||||
@ -100,13 +153,23 @@ Instructions: ${messages[0].content}
|
|||||||
|
|
||||||
Respond only with the modified code that can directly replace the existing code.`
|
Respond only with the modified code that can directly replace the existing code.`
|
||||||
} else {
|
} else {
|
||||||
systemMessage = `You are an intelligent programming assistant for a ${templateType} project. 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:
|
systemMessage = `You are an intelligent programming assistant for a ${templateType} project. Please respond to the following request concisely. When providing code:
|
||||||
|
|
||||||
\`\`\`python
|
1. Format it using triple backticks (\`\`\`) with the appropriate language identifier.
|
||||||
print("Hello, World!")
|
2. Always specify the complete file path in the format:
|
||||||
\`\`\`
|
${projectName}/filepath/to/file.ext
|
||||||
|
|
||||||
Provide a clear and concise explanation along with any code snippets. Keep your response brief and to the point.
|
3. If creating a new file, specify the path as:
|
||||||
|
${projectName}/filepath/to/file.ext (new file)
|
||||||
|
|
||||||
|
4. Format your code blocks as:
|
||||||
|
|
||||||
|
${projectName}/filepath/to/file.ext
|
||||||
|
\`\`\`language
|
||||||
|
code here
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
If multiple files are involved, repeat the format for each file. Provide a clear and concise explanation along with any code snippets. Keep your response brief and to the point.
|
||||||
|
|
||||||
This is the project template:
|
This is the project template:
|
||||||
${templateContext}
|
${templateContext}
|
||||||
@ -146,7 +209,10 @@ ${activeFileContent ? `Active File Content:\n${activeFileContent}\n` : ""}`
|
|||||||
new ReadableStream({
|
new ReadableStream({
|
||||||
async start(controller) {
|
async start(controller) {
|
||||||
for await (const chunk of stream) {
|
for await (const chunk of stream) {
|
||||||
if (chunk.type === "content_block_delta" && chunk.delta.type === "text_delta") {
|
if (
|
||||||
|
chunk.type === "content_block_delta" &&
|
||||||
|
chunk.delta.type === "text_delta"
|
||||||
|
) {
|
||||||
controller.enqueue(encoder.encode(chunk.delta.text))
|
controller.enqueue(encoder.encode(chunk.delta.text))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
69
frontend/app/api/merge/route.ts
Normal file
69
frontend/app/api/merge/route.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import OpenAI from "openai"
|
||||||
|
|
||||||
|
const openai = new OpenAI({
|
||||||
|
apiKey: process.env.OPENAI_API_KEY,
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const { originalCode, newCode, fileName } = await request.json()
|
||||||
|
|
||||||
|
const systemPrompt = `You are a code merging assistant. Your task is to merge the new code snippet with the original file content while:
|
||||||
|
1. Preserving the original file's functionality
|
||||||
|
2. Ensuring proper integration of the new code
|
||||||
|
3. Maintaining consistent style and formatting
|
||||||
|
4. Resolving any potential conflicts
|
||||||
|
5. Output ONLY the raw code without any:
|
||||||
|
- Code fence markers (\`\`\`)
|
||||||
|
- Language identifiers (typescript, javascript, etc.)
|
||||||
|
- Explanations or comments
|
||||||
|
- Markdown formatting
|
||||||
|
|
||||||
|
The output should be the exact code that will replace the existing code, nothing more and nothing less.
|
||||||
|
|
||||||
|
Important: When merging, preserve the original code structure as much as possible. Only make necessary changes to integrate the new code while maintaining the original code's organization and style.`
|
||||||
|
|
||||||
|
const mergedCode = `Original file (${fileName}):\n${originalCode}\n\nNew code to merge:\n${newCode}`
|
||||||
|
|
||||||
|
const response = await openai.chat.completions.create({
|
||||||
|
model: "gpt-4o",
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: systemPrompt },
|
||||||
|
{ role: "user", content: mergedCode },
|
||||||
|
],
|
||||||
|
prediction: {
|
||||||
|
type: "content",
|
||||||
|
content: mergedCode,
|
||||||
|
},
|
||||||
|
stream: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Clean and stream response
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
return new Response(
|
||||||
|
new ReadableStream({
|
||||||
|
async start(controller) {
|
||||||
|
let buffer = ""
|
||||||
|
for await (const chunk of response) {
|
||||||
|
if (chunk.choices[0]?.delta?.content) {
|
||||||
|
buffer += chunk.choices[0].delta.content
|
||||||
|
// Clean any code fence markers that might appear in the stream
|
||||||
|
const cleanedContent = buffer
|
||||||
|
.replace(/^```[\w-]*\n|```\s*$/gm, "") // Remove code fences
|
||||||
|
.replace(/^(javascript|typescript|python|html|css)\n/gm, "") // Remove language identifiers
|
||||||
|
controller.enqueue(encoder.encode(cleanedContent))
|
||||||
|
buffer = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
controller.close()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Merge error:", error)
|
||||||
|
return new Response(
|
||||||
|
error instanceof Error ? error.message : "Failed to merge code",
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -175,3 +175,23 @@
|
|||||||
.tab-scroll::-webkit-scrollbar {
|
.tab-scroll::-webkit-scrollbar {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.added-line-decoration {
|
||||||
|
background-color: rgba(0, 255, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.removed-line-decoration {
|
||||||
|
background-color: rgba(255, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.added-line-glyph {
|
||||||
|
background-color: #28a745;
|
||||||
|
width: 4px !important;
|
||||||
|
margin-left: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.removed-line-glyph {
|
||||||
|
background-color: #dc3545;
|
||||||
|
width: 4px !important;
|
||||||
|
margin-left: 3px;
|
||||||
|
}
|
||||||
|
77
frontend/components/editor/AIChat/ApplyButton.tsx
Normal file
77
frontend/components/editor/AIChat/ApplyButton.tsx
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import { Check, Loader2 } from "lucide-react"
|
||||||
|
import { useState } from "react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { Button } from "../../ui/button"
|
||||||
|
|
||||||
|
interface ApplyButtonProps {
|
||||||
|
code: string
|
||||||
|
activeFileName: string
|
||||||
|
activeFileContent: string
|
||||||
|
editorRef: { current: any }
|
||||||
|
onApply: (mergedCode: string, originalCode: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ApplyButton({
|
||||||
|
code,
|
||||||
|
activeFileName,
|
||||||
|
activeFileContent,
|
||||||
|
editorRef,
|
||||||
|
onApply,
|
||||||
|
}: ApplyButtonProps) {
|
||||||
|
const [isApplying, setIsApplying] = useState(false)
|
||||||
|
|
||||||
|
const handleApply = async () => {
|
||||||
|
setIsApplying(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/merge", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
originalCode: activeFileContent,
|
||||||
|
newCode: String(code),
|
||||||
|
fileName: activeFileName,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await response.text())
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body?.getReader()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
let mergedCode = ""
|
||||||
|
|
||||||
|
if (reader) {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
mergedCode += decoder.decode(value, { stream: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onApply(mergedCode.trim(), activeFileContent)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error applying code:", error)
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "Failed to apply code changes"
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setIsApplying(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
onClick={handleApply}
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="p-1 h-6"
|
||||||
|
disabled={isApplying}
|
||||||
|
>
|
||||||
|
{isApplying ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Check className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
@ -13,6 +13,13 @@ export default function ChatMessage({
|
|||||||
setContext,
|
setContext,
|
||||||
setIsContextExpanded,
|
setIsContextExpanded,
|
||||||
socket,
|
socket,
|
||||||
|
handleApplyCode,
|
||||||
|
activeFileName,
|
||||||
|
activeFileContent,
|
||||||
|
editorRef,
|
||||||
|
mergeDecorationsCollection,
|
||||||
|
setMergeDecorationsCollection,
|
||||||
|
selectFile,
|
||||||
}: MessageProps) {
|
}: MessageProps) {
|
||||||
// State for expanded message index
|
// State for expanded message index
|
||||||
const [expandedMessageIndex, setExpandedMessageIndex] = useState<
|
const [expandedMessageIndex, setExpandedMessageIndex] = useState<
|
||||||
@ -104,7 +111,14 @@ export default function ChatMessage({
|
|||||||
const components = createMarkdownComponents(
|
const components = createMarkdownComponents(
|
||||||
renderCopyButton,
|
renderCopyButton,
|
||||||
renderMarkdownElement,
|
renderMarkdownElement,
|
||||||
askAboutCode
|
askAboutCode,
|
||||||
|
activeFileName,
|
||||||
|
activeFileContent,
|
||||||
|
editorRef,
|
||||||
|
handleApplyCode,
|
||||||
|
selectFile,
|
||||||
|
mergeDecorationsCollection,
|
||||||
|
setMergeDecorationsCollection,
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -201,7 +215,8 @@ export default function ChatMessage({
|
|||||||
|
|
||||||
// Parse context to tabs for context tabs component
|
// Parse context to tabs for context tabs component
|
||||||
function parseContextToTabs(context: string) {
|
function parseContextToTabs(context: string) {
|
||||||
const sections = context.split(/(?=File |Code from )/)
|
// Use specific regex patterns to avoid matching import statements
|
||||||
|
const sections = context.split(/(?=File |Code from |Image \d{1,2}:)/)
|
||||||
return sections
|
return sections
|
||||||
.map((section, index) => {
|
.map((section, index) => {
|
||||||
const lines = section.trim().split("\n")
|
const lines = section.trim().split("\n")
|
||||||
@ -211,16 +226,29 @@ function parseContextToTabs(context: string) {
|
|||||||
// Remove code block markers for display
|
// Remove code block markers for display
|
||||||
content = content.replace(/^```[\w-]*\n/, "").replace(/\n```$/, "")
|
content = content.replace(/^```[\w-]*\n/, "").replace(/\n```$/, "")
|
||||||
|
|
||||||
// Determine if the context is a file or code
|
// Determine the type of context
|
||||||
const isFile = titleLine.startsWith("File ")
|
const isFile = titleLine.startsWith("File ")
|
||||||
const name = titleLine.replace(/^(File |Code from )/, "").replace(":", "")
|
const isImage = titleLine.startsWith("Image ")
|
||||||
|
const type = isFile ? "file" : isImage ? "image" : "code"
|
||||||
|
const name = titleLine
|
||||||
|
.replace(/^(File |Code from |Image )/, "")
|
||||||
|
.replace(":", "")
|
||||||
|
.trim()
|
||||||
|
|
||||||
|
// Skip if the content is empty or if it's just an import statement
|
||||||
|
if (!content || content.trim().startsWith('from "')) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `context-${index}`,
|
id: `context-${index}`,
|
||||||
type: isFile ? ("file" as const) : ("code" as const),
|
type: type as "file" | "code" | "image",
|
||||||
name: name,
|
name: name,
|
||||||
content: content,
|
content: content,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.filter((tab) => tab.content.length > 0)
|
.filter(
|
||||||
|
(tab): tab is NonNullable<typeof tab> =>
|
||||||
|
tab !== null && tab.content.length > 0
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { useSocket } from "@/context/SocketContext"
|
import { useSocket } from "@/context/SocketContext"
|
||||||
import { TFile } from "@/lib/types"
|
import { TFile } from "@/lib/types"
|
||||||
import { X, ChevronDown } from "lucide-react"
|
import { ChevronDown, X } from "lucide-react"
|
||||||
import { nanoid } from "nanoid"
|
import { nanoid } from "nanoid"
|
||||||
import { useEffect, useRef, useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
import LoadingDots from "../../ui/LoadingDots"
|
import LoadingDots from "../../ui/LoadingDots"
|
||||||
@ -18,6 +18,11 @@ export default function AIChat({
|
|||||||
lastCopiedRangeRef,
|
lastCopiedRangeRef,
|
||||||
files,
|
files,
|
||||||
templateType,
|
templateType,
|
||||||
|
handleApplyCode,
|
||||||
|
selectFile,
|
||||||
|
mergeDecorationsCollection,
|
||||||
|
setMergeDecorationsCollection,
|
||||||
|
projectName,
|
||||||
}: AIChatProps) {
|
}: AIChatProps) {
|
||||||
// Initialize socket and messages
|
// Initialize socket and messages
|
||||||
const { socket } = useSocket()
|
const { socket } = useSocket()
|
||||||
@ -75,8 +80,8 @@ export default function AIChat({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = chatContainerRef.current
|
const container = chatContainerRef.current
|
||||||
if (container) {
|
if (container) {
|
||||||
container.addEventListener('scroll', handleScroll)
|
container.addEventListener("scroll", handleScroll)
|
||||||
return () => container.removeEventListener('scroll', handleScroll)
|
return () => container.removeEventListener("scroll", handleScroll)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@ -125,6 +130,8 @@ export default function AIChat({
|
|||||||
} else if (tab.type === "code") {
|
} else if (tab.type === "code") {
|
||||||
const cleanContent = formatCodeContent(tab.content)
|
const cleanContent = formatCodeContent(tab.content)
|
||||||
return `Code from ${tab.name}:\n\`\`\`typescript\n${cleanContent}\n\`\`\``
|
return `Code from ${tab.name}:\n\`\`\`typescript\n${cleanContent}\n\`\`\``
|
||||||
|
} else if (tab.type === "image") {
|
||||||
|
return `Image ${tab.name}:\n${tab.content}`
|
||||||
}
|
}
|
||||||
return `${tab.name}:\n${tab.content}`
|
return `${tab.name}:\n${tab.content}`
|
||||||
})
|
})
|
||||||
@ -146,10 +153,10 @@ export default function AIChat({
|
|||||||
abortControllerRef,
|
abortControllerRef,
|
||||||
activeFileContent,
|
activeFileContent,
|
||||||
false,
|
false,
|
||||||
templateType
|
templateType,
|
||||||
|
files,
|
||||||
|
projectName
|
||||||
)
|
)
|
||||||
// Clear context tabs after sending
|
|
||||||
setContextTabs([])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set context for the chat
|
// Set context for the chat
|
||||||
@ -167,6 +174,22 @@ export default function AIChat({
|
|||||||
addContextTab("code", name, context, range)
|
addContextTab("code", name, context, range)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// update context tabs when file contents change
|
||||||
|
useEffect(() => {
|
||||||
|
setContextTabs((prevTabs) =>
|
||||||
|
prevTabs.map((tab) => {
|
||||||
|
if (tab.type === "file" && tab.name === activeFileName) {
|
||||||
|
const fileExt = tab.name.split(".").pop() || "txt"
|
||||||
|
return {
|
||||||
|
...tab,
|
||||||
|
content: `\`\`\`${fileExt}\n${activeFileContent}\n\`\`\``,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tab
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}, [activeFileContent, activeFileName])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-screen w-full">
|
<div className="flex flex-col h-screen w-full">
|
||||||
<div className="flex justify-between items-center p-2 border-b">
|
<div className="flex justify-between items-center p-2 border-b">
|
||||||
@ -197,6 +220,13 @@ export default function AIChat({
|
|||||||
setContext={setContext}
|
setContext={setContext}
|
||||||
setIsContextExpanded={setIsContextExpanded}
|
setIsContextExpanded={setIsContextExpanded}
|
||||||
socket={socket}
|
socket={socket}
|
||||||
|
handleApplyCode={handleApplyCode}
|
||||||
|
activeFileName={activeFileName}
|
||||||
|
activeFileContent={activeFileContent}
|
||||||
|
editorRef={editorRef}
|
||||||
|
mergeDecorationsCollection={mergeDecorationsCollection}
|
||||||
|
setMergeDecorationsCollection={setMergeDecorationsCollection}
|
||||||
|
selectFile={selectFile}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{isLoading && <LoadingDots />}
|
{isLoading && <LoadingDots />}
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { TFile, TFolder } from "@/lib/types"
|
||||||
import React from "react"
|
import React from "react"
|
||||||
|
|
||||||
// Stringify content for chat message component
|
// Stringify content for chat message component
|
||||||
@ -91,7 +92,9 @@ export const handleSend = async (
|
|||||||
abortControllerRef: React.MutableRefObject<AbortController | null>,
|
abortControllerRef: React.MutableRefObject<AbortController | null>,
|
||||||
activeFileContent: string,
|
activeFileContent: string,
|
||||||
isEditMode: boolean = false,
|
isEditMode: boolean = false,
|
||||||
templateType: string
|
templateType: string,
|
||||||
|
files: (TFile | TFolder)[],
|
||||||
|
projectName: string
|
||||||
) => {
|
) => {
|
||||||
// Return if input is empty and context is null
|
// Return if input is empty and context is null
|
||||||
if (input.trim() === "" && !context) return
|
if (input.trim() === "" && !context) return
|
||||||
@ -131,22 +134,22 @@ export const handleSend = async (
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
// Fetch AI response for chat message component
|
// Fetch AI response for chat message component
|
||||||
const response = await fetch("/api/ai",
|
const response = await fetch("/api/ai", {
|
||||||
{
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
messages: anthropicMessages,
|
messages: anthropicMessages,
|
||||||
context: context || undefined,
|
context: context || undefined,
|
||||||
activeFileContent: activeFileContent,
|
activeFileContent: activeFileContent,
|
||||||
isEditMode: isEditMode,
|
isEditMode: isEditMode,
|
||||||
templateType: templateType,
|
templateType: templateType,
|
||||||
}),
|
files: files,
|
||||||
signal: abortControllerRef.current.signal,
|
projectName: projectName,
|
||||||
}
|
}),
|
||||||
)
|
signal: abortControllerRef.current.signal,
|
||||||
|
})
|
||||||
|
|
||||||
// Throw error if response is not ok
|
// Throw error if response is not ok
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@ -201,7 +204,8 @@ export const handleSend = async (
|
|||||||
console.error("Error fetching AI response:", error)
|
console.error("Error fetching AI response:", error)
|
||||||
const errorMessage = {
|
const errorMessage = {
|
||||||
role: "assistant" as const,
|
role: "assistant" as const,
|
||||||
content: error.message || "Sorry, I encountered an error. Please try again.",
|
content:
|
||||||
|
error.message || "Sorry, I encountered an error. Please try again.",
|
||||||
}
|
}
|
||||||
setMessages((prev) => [...prev, errorMessage])
|
setMessages((prev) => [...prev, errorMessage])
|
||||||
}
|
}
|
||||||
@ -239,3 +243,11 @@ export const looksLikeCode = (text: string): boolean => {
|
|||||||
|
|
||||||
return codeIndicators.some((pattern) => pattern.test(text))
|
return codeIndicators.some((pattern) => pattern.test(text))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add this new function after looksLikeCode function
|
||||||
|
export const isFilePath = (text: string): boolean => {
|
||||||
|
// Match patterns like next/styles/SignIn.module.css or path/to/file.ext (new file)
|
||||||
|
const pattern =
|
||||||
|
/^(?:[a-zA-Z0-9_.-]+\/)*[a-zA-Z0-9_.-]+\.[a-zA-Z0-9]+(\s+\(new file\))?$/
|
||||||
|
return pattern.test(text)
|
||||||
|
}
|
||||||
|
@ -1,15 +1,26 @@
|
|||||||
import { CornerUpLeft } from "lucide-react"
|
import { useSocket } from "@/context/SocketContext"
|
||||||
|
import { TTab } from "@/lib/types"
|
||||||
|
import { Check, CornerUpLeft, FileText, X } from "lucide-react"
|
||||||
|
import monaco from "monaco-editor"
|
||||||
import { Components } from "react-markdown"
|
import { Components } from "react-markdown"
|
||||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"
|
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"
|
||||||
import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism"
|
import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism"
|
||||||
import { Button } from "../../../ui/button"
|
import { Button } from "../../../ui/button"
|
||||||
import { stringifyContent } from "./chatUtils"
|
import ApplyButton from "../ApplyButton"
|
||||||
|
import { isFilePath, stringifyContent } from "./chatUtils"
|
||||||
|
|
||||||
// Create markdown components for chat message component
|
// Create markdown components for chat message component
|
||||||
export const createMarkdownComponents = (
|
export const createMarkdownComponents = (
|
||||||
renderCopyButton: (text: any) => JSX.Element,
|
renderCopyButton: (text: any) => JSX.Element,
|
||||||
renderMarkdownElement: (props: any) => JSX.Element,
|
renderMarkdownElement: (props: any) => JSX.Element,
|
||||||
askAboutCode: (code: any) => void
|
askAboutCode: (code: any) => void,
|
||||||
|
activeFileName: string,
|
||||||
|
activeFileContent: string,
|
||||||
|
editorRef: any,
|
||||||
|
handleApplyCode: (mergedCode: string, originalCode: string) => void,
|
||||||
|
selectFile: (tab: TTab) => void,
|
||||||
|
mergeDecorationsCollection?: monaco.editor.IEditorDecorationsCollection,
|
||||||
|
setMergeDecorationsCollection?: (collection: undefined) => void
|
||||||
): Components => ({
|
): Components => ({
|
||||||
code: ({
|
code: ({
|
||||||
node,
|
node,
|
||||||
@ -25,39 +36,92 @@ export const createMarkdownComponents = (
|
|||||||
const match = /language-(\w+)/.exec(className || "")
|
const match = /language-(\w+)/.exec(className || "")
|
||||||
|
|
||||||
return match ? (
|
return match ? (
|
||||||
<div className="relative border border-input rounded-md my-4">
|
<div className="relative border border-input rounded-md mt-8 my-2 translate-y-[-1rem]">
|
||||||
<div className="absolute top-0 left-0 px-2 py-1 text-xs font-semibold text-gray-200 bg-#1e1e1e rounded-tl">
|
<div className="absolute top-0 left-0 px-2 py-1 text-xs font-semibold text-gray-200 rounded-tl">
|
||||||
{match[1]}
|
{match[1]}
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute top-0 right-0 flex">
|
<div className="sticky top-0 right-0 flex justify-end z-10">
|
||||||
{renderCopyButton(children)}
|
<div className="flex border border-input shadow-lg bg-background rounded-md">
|
||||||
<Button
|
{renderCopyButton(children)}
|
||||||
onClick={(e) => {
|
<div className="w-px bg-input"></div>
|
||||||
e.preventDefault()
|
{!mergeDecorationsCollection ? (
|
||||||
e.stopPropagation()
|
<ApplyButton
|
||||||
askAboutCode(children)
|
code={String(children)}
|
||||||
}}
|
activeFileName={activeFileName}
|
||||||
size="sm"
|
activeFileContent={activeFileContent}
|
||||||
variant="ghost"
|
editorRef={editorRef}
|
||||||
className="p-1 h-6"
|
onApply={handleApplyCode}
|
||||||
>
|
/>
|
||||||
<CornerUpLeft className="w-4 h-4" />
|
) : (
|
||||||
</Button>
|
<>
|
||||||
</div>
|
<Button
|
||||||
<div className="pt-6">
|
onClick={() => {
|
||||||
<SyntaxHighlighter
|
if (
|
||||||
style={vscDarkPlus as any}
|
setMergeDecorationsCollection &&
|
||||||
language={match[1]}
|
mergeDecorationsCollection &&
|
||||||
PreTag="div"
|
editorRef?.current
|
||||||
customStyle={{
|
) {
|
||||||
margin: 0,
|
mergeDecorationsCollection?.clear()
|
||||||
padding: "0.5rem",
|
setMergeDecorationsCollection(undefined)
|
||||||
fontSize: "0.875rem",
|
}
|
||||||
}}
|
}}
|
||||||
>
|
size="sm"
|
||||||
{stringifyContent(children)}
|
variant="ghost"
|
||||||
</SyntaxHighlighter>
|
className="p-1 h-6"
|
||||||
|
title="Accept Changes"
|
||||||
|
>
|
||||||
|
<Check className="w-4 h-4 text-green-500" />
|
||||||
|
</Button>
|
||||||
|
<div className="w-px bg-input"></div>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (editorRef?.current && mergeDecorationsCollection) {
|
||||||
|
const model = editorRef.current.getModel()
|
||||||
|
if (model && (model as any).originalContent) {
|
||||||
|
editorRef.current?.setValue(
|
||||||
|
(model as any).originalContent
|
||||||
|
)
|
||||||
|
mergeDecorationsCollection.clear()
|
||||||
|
setMergeDecorationsCollection?.(undefined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="p-1 h-6"
|
||||||
|
title="Discard Changes"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4 text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="w-px bg-input"></div>
|
||||||
|
<Button
|
||||||
|
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>
|
</div>
|
||||||
|
<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}>
|
<code className={className} {...props}>
|
||||||
@ -66,8 +130,62 @@ export const createMarkdownComponents = (
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
// Render markdown elements
|
// Render markdown elements
|
||||||
p: ({ node, children, ...props }) =>
|
p: ({ node, children, ...props }) => {
|
||||||
renderMarkdownElement({ node, children, ...props }),
|
const content = stringifyContent(children)
|
||||||
|
const { socket } = useSocket()
|
||||||
|
|
||||||
|
if (isFilePath(content)) {
|
||||||
|
const isNewFile = content.endsWith("(new file)")
|
||||||
|
const filePath = (
|
||||||
|
isNewFile ? content.replace(" (new file)", "") : content
|
||||||
|
)
|
||||||
|
.split("/")
|
||||||
|
.filter((part, index) => index !== 0)
|
||||||
|
.join("/")
|
||||||
|
|
||||||
|
const handleFileClick = () => {
|
||||||
|
if (isNewFile) {
|
||||||
|
socket?.emit(
|
||||||
|
"createFile",
|
||||||
|
{
|
||||||
|
name: filePath,
|
||||||
|
},
|
||||||
|
(response: any) => {
|
||||||
|
if (response.success) {
|
||||||
|
const tab: TTab = {
|
||||||
|
id: filePath,
|
||||||
|
name: filePath.split("/").pop() || "",
|
||||||
|
saved: true,
|
||||||
|
type: "file",
|
||||||
|
}
|
||||||
|
selectFile(tab)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
const tab: TTab = {
|
||||||
|
id: filePath,
|
||||||
|
name: filePath.split("/").pop() || "",
|
||||||
|
saved: true,
|
||||||
|
type: "file",
|
||||||
|
}
|
||||||
|
selectFile(tab)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={handleFileClick}
|
||||||
|
className="group flex items-center gap-2 px-2 py-1 bg-secondary/50 rounded-md my-2 text-xs hover:bg-secondary cursor-pointer w-fit"
|
||||||
|
>
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
<span className="font-mono group-hover:underline">{content}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderMarkdownElement({ node, children, ...props })
|
||||||
|
},
|
||||||
h1: ({ node, children, ...props }) =>
|
h1: ({ node, children, ...props }) =>
|
||||||
renderMarkdownElement({ node, children, ...props }),
|
renderMarkdownElement({ node, children, ...props }),
|
||||||
h2: ({ node, children, ...props }) =>
|
h2: ({ node, children, ...props }) =>
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { TemplateConfig } from "@/lib/templates"
|
import { TemplateConfig } from "@/lib/templates"
|
||||||
import { TFile, TFolder } from "@/lib/types"
|
import { TFile, TFolder, TTab } from "@/lib/types"
|
||||||
import * as monaco from "monaco-editor"
|
import * as monaco from "monaco-editor"
|
||||||
import { Socket } from "socket.io-client"
|
import { Socket } from "socket.io-client"
|
||||||
|
|
||||||
@ -56,6 +56,11 @@ export interface AIChatProps {
|
|||||||
files: (TFile | TFolder)[]
|
files: (TFile | TFolder)[]
|
||||||
templateType: string
|
templateType: string
|
||||||
templateConfig?: TemplateConfig
|
templateConfig?: TemplateConfig
|
||||||
|
projectName: string
|
||||||
|
handleApplyCode: (mergedCode: string, originalCode: string) => void
|
||||||
|
mergeDecorationsCollection?: monaco.editor.IEditorDecorationsCollection
|
||||||
|
setMergeDecorationsCollection?: (collection: undefined) => void
|
||||||
|
selectFile: (tab: TTab) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// Chat input props interface
|
// Chat input props interface
|
||||||
@ -105,6 +110,13 @@ export interface MessageProps {
|
|||||||
) => void
|
) => void
|
||||||
setIsContextExpanded: (isExpanded: boolean) => void
|
setIsContextExpanded: (isExpanded: boolean) => void
|
||||||
socket: Socket | null
|
socket: Socket | null
|
||||||
|
handleApplyCode: (mergedCode: string, originalCode: string) => void
|
||||||
|
activeFileName: string
|
||||||
|
activeFileContent: string
|
||||||
|
editorRef: any
|
||||||
|
mergeDecorationsCollection?: monaco.editor.IEditorDecorationsCollection
|
||||||
|
setMergeDecorationsCollection?: (collection: undefined) => void
|
||||||
|
selectFile: (tab: TTab) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// Context tabs props interface
|
// Context tabs props interface
|
||||||
|
@ -66,15 +66,17 @@ export default function GenerateInput({
|
|||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
messages: [{
|
messages: [
|
||||||
role: "user",
|
{
|
||||||
content: regenerate ? currentPrompt : input
|
role: "user",
|
||||||
}],
|
content: regenerate ? currentPrompt : input,
|
||||||
|
},
|
||||||
|
],
|
||||||
context: null,
|
context: null,
|
||||||
activeFileContent: data.code,
|
activeFileContent: data.code,
|
||||||
isEditMode: true,
|
isEditMode: true,
|
||||||
fileName: data.fileName,
|
fileName: data.fileName,
|
||||||
line: data.line
|
line: data.line,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -104,6 +104,13 @@ export default function CodeEditor({
|
|||||||
// Added this state to track the most recent content for each file
|
// Added this state to track the most recent content for each file
|
||||||
const [fileContents, setFileContents] = useState<Record<string, string>>({})
|
const [fileContents, setFileContents] = useState<Record<string, string>>({})
|
||||||
|
|
||||||
|
// Apply Button merger decoration state
|
||||||
|
const [mergeDecorations, setMergeDecorations] = useState<
|
||||||
|
monaco.editor.IModelDeltaDecoration[]
|
||||||
|
>([])
|
||||||
|
const [mergeDecorationsCollection, setMergeDecorationsCollection] =
|
||||||
|
useState<monaco.editor.IEditorDecorationsCollection>()
|
||||||
|
|
||||||
// Editor state
|
// Editor state
|
||||||
const [editorLanguage, setEditorLanguage] = useState("plaintext")
|
const [editorLanguage, setEditorLanguage] = useState("plaintext")
|
||||||
const [cursorLine, setCursorLine] = useState(0)
|
const [cursorLine, setCursorLine] = useState(0)
|
||||||
@ -375,6 +382,57 @@ export default function CodeEditor({
|
|||||||
})
|
})
|
||||||
}, [editorRef])
|
}, [editorRef])
|
||||||
|
|
||||||
|
// handle apply code
|
||||||
|
const handleApplyCode = useCallback(
|
||||||
|
(mergedCode: string, originalCode: string) => {
|
||||||
|
if (!editorRef) return
|
||||||
|
|
||||||
|
const model = editorRef.getModel()
|
||||||
|
if (!model) return // Store original content
|
||||||
|
;(model as any).originalContent = originalCode
|
||||||
|
|
||||||
|
// Calculate the full range of the document
|
||||||
|
const fullRange = model.getFullModelRange()
|
||||||
|
|
||||||
|
// Create decorations before applying the edit
|
||||||
|
const originalLines = originalCode.split("\n")
|
||||||
|
const mergedLines = mergedCode.split("\n")
|
||||||
|
const decorations: monaco.editor.IModelDeltaDecoration[] = []
|
||||||
|
|
||||||
|
for (
|
||||||
|
let i = 0;
|
||||||
|
i < Math.max(originalLines.length, mergedLines.length);
|
||||||
|
i++
|
||||||
|
) {
|
||||||
|
// Only highlight new lines (green highlights)
|
||||||
|
if (i >= originalLines.length || originalLines[i] !== mergedLines[i]) {
|
||||||
|
decorations.push({
|
||||||
|
range: new monaco.Range(i + 1, 1, i + 1, 1),
|
||||||
|
options: {
|
||||||
|
isWholeLine: true,
|
||||||
|
className: "added-line-decoration",
|
||||||
|
glyphMarginClassName: "added-line-glyph",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute the edit operation
|
||||||
|
editorRef.executeEdits("apply-code", [
|
||||||
|
{
|
||||||
|
range: fullRange,
|
||||||
|
text: mergedCode,
|
||||||
|
forceMoveMarkers: true,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
// Apply decorations after the edit
|
||||||
|
const newDecorations = editorRef.createDecorationsCollection(decorations)
|
||||||
|
setMergeDecorationsCollection(newDecorations)
|
||||||
|
},
|
||||||
|
[editorRef]
|
||||||
|
)
|
||||||
|
|
||||||
// Generate widget effect
|
// Generate widget effect
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (generate.show) {
|
if (generate.show) {
|
||||||
@ -735,31 +793,32 @@ export default function CodeEditor({
|
|||||||
setGenerate((prev) => ({ ...prev, show: false }))
|
setGenerate((prev) => ({ ...prev, show: false }))
|
||||||
|
|
||||||
// Check if the tab already exists in the list of open tabs
|
// Check if the tab already exists in the list of open tabs
|
||||||
const exists = tabs.find((t) => t.id === tab.id)
|
const existingTab = tabs.find((t) => t.id === tab.id)
|
||||||
setTabs((prev) => {
|
|
||||||
if (exists) {
|
|
||||||
// If the tab exists, make it the active tab
|
|
||||||
setActiveFileId(exists.id)
|
|
||||||
return prev
|
|
||||||
}
|
|
||||||
// If the tab doesn't exist, add it to the list of tabs and make it active
|
|
||||||
return [...prev, tab]
|
|
||||||
})
|
|
||||||
|
|
||||||
// If the file's content is already cached, set it as the active content
|
if (existingTab) {
|
||||||
if (fileContents[tab.id]) {
|
// If the tab exists, just make it active
|
||||||
setActiveFileContent(fileContents[tab.id])
|
setActiveFileId(existingTab.id)
|
||||||
|
if (fileContents[existingTab.id]) {
|
||||||
|
setActiveFileContent(fileContents[existingTab.id])
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Otherwise, fetch the content of the file and cache it
|
// If the tab doesn't exist, add it to the list and make it active
|
||||||
debouncedGetFile(tab.id, (response: string) => {
|
setTabs((prev) => [...prev, tab])
|
||||||
setFileContents((prev) => ({ ...prev, [tab.id]: response }))
|
|
||||||
setActiveFileContent(response)
|
// Fetch content if not cached
|
||||||
})
|
if (!fileContents[tab.id]) {
|
||||||
|
debouncedGetFile(tab.id, (response: string) => {
|
||||||
|
setFileContents((prev) => ({ ...prev, [tab.id]: response }))
|
||||||
|
setActiveFileContent(response)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setActiveFileContent(fileContents[tab.id])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set the editor language based on the file type
|
// Set the editor language based on the file type
|
||||||
setEditorLanguage(processFileType(tab.name))
|
setEditorLanguage(processFileType(tab.name))
|
||||||
// Set the active file ID to the new tab
|
// Set the active file ID
|
||||||
setActiveFileId(tab.id)
|
setActiveFileId(tab.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1087,62 +1146,62 @@ export default function CodeEditor({
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : // Note clerk.loaded is required here due to a bug: https://github.com/clerk/javascript/issues/1643
|
) : // Note clerk.loaded is required here due to a bug: https://github.com/clerk/javascript/issues/1643
|
||||||
clerk.loaded ? (
|
clerk.loaded ? (
|
||||||
<>
|
<>
|
||||||
{/* {provider && userInfo ? (
|
{/* {provider && userInfo ? (
|
||||||
<Cursors yProvider={provider} userInfo={userInfo} />
|
<Cursors yProvider={provider} userInfo={userInfo} />
|
||||||
) : null} */}
|
) : null} */}
|
||||||
<Editor
|
<Editor
|
||||||
height="100%"
|
height="100%"
|
||||||
language={editorLanguage}
|
language={editorLanguage}
|
||||||
beforeMount={handleEditorWillMount}
|
beforeMount={handleEditorWillMount}
|
||||||
onMount={handleEditorMount}
|
onMount={handleEditorMount}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
// If the new content is different from the cached content, update it
|
// If the new content is different from the cached content, update it
|
||||||
if (value !== fileContents[activeFileId]) {
|
if (value !== fileContents[activeFileId]) {
|
||||||
setActiveFileContent(value ?? "") // Update the active file content
|
setActiveFileContent(value ?? "") // Update the active file content
|
||||||
// Mark the file as unsaved by setting 'saved' to false
|
// Mark the file as unsaved by setting 'saved' to false
|
||||||
setTabs((prev) =>
|
setTabs((prev) =>
|
||||||
prev.map((tab) =>
|
prev.map((tab) =>
|
||||||
tab.id === activeFileId
|
tab.id === activeFileId
|
||||||
? { ...tab, saved: false }
|
? { ...tab, saved: false }
|
||||||
: tab
|
: tab
|
||||||
)
|
|
||||||
)
|
)
|
||||||
} else {
|
)
|
||||||
// If the content matches the cached content, mark the file as saved
|
} else {
|
||||||
setTabs((prev) =>
|
// If the content matches the cached content, mark the file as saved
|
||||||
prev.map((tab) =>
|
setTabs((prev) =>
|
||||||
tab.id === activeFileId
|
prev.map((tab) =>
|
||||||
? { ...tab, saved: true }
|
tab.id === activeFileId
|
||||||
: tab
|
? { ...tab, saved: true }
|
||||||
)
|
: tab
|
||||||
)
|
)
|
||||||
}
|
)
|
||||||
}}
|
}
|
||||||
options={{
|
}}
|
||||||
tabSize: 2,
|
options={{
|
||||||
minimap: {
|
tabSize: 2,
|
||||||
enabled: false,
|
minimap: {
|
||||||
},
|
enabled: false,
|
||||||
padding: {
|
},
|
||||||
bottom: 4,
|
padding: {
|
||||||
top: 4,
|
bottom: 4,
|
||||||
},
|
top: 4,
|
||||||
scrollBeyondLastLine: false,
|
},
|
||||||
fixedOverflowWidgets: true,
|
scrollBeyondLastLine: false,
|
||||||
fontFamily: "var(--font-geist-mono)",
|
fixedOverflowWidgets: true,
|
||||||
}}
|
fontFamily: "var(--font-geist-mono)",
|
||||||
theme={theme === "light" ? "vs" : "vs-dark"}
|
}}
|
||||||
value={activeFileContent}
|
theme={theme === "light" ? "vs" : "vs-dark"}
|
||||||
/>
|
value={activeFileContent}
|
||||||
</>
|
/>
|
||||||
) : (
|
</>
|
||||||
<div className="w-full h-full flex items-center justify-center text-xl font-medium text-muted-foreground/50 select-none">
|
) : (
|
||||||
<Loader2 className="animate-spin w-6 h-6 mr-3" />
|
<div className="w-full h-full flex items-center justify-center text-xl font-medium text-muted-foreground/50 select-none">
|
||||||
Waiting for Clerk to load...
|
<Loader2 className="animate-spin w-6 h-6 mr-3" />
|
||||||
</div>
|
Waiting for Clerk to load...
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
<ResizableHandle />
|
<ResizableHandle />
|
||||||
@ -1234,6 +1293,11 @@ export default function CodeEditor({
|
|||||||
lastCopiedRangeRef={lastCopiedRangeRef}
|
lastCopiedRangeRef={lastCopiedRangeRef}
|
||||||
files={files}
|
files={files}
|
||||||
templateType={sandboxData.type}
|
templateType={sandboxData.type}
|
||||||
|
projectName={sandboxData.name}
|
||||||
|
handleApplyCode={handleApplyCode}
|
||||||
|
mergeDecorationsCollection={mergeDecorationsCollection}
|
||||||
|
setMergeDecorationsCollection={setMergeDecorationsCollection}
|
||||||
|
selectFile={selectFile}
|
||||||
/>
|
/>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
</>
|
</>
|
||||||
|
@ -9,7 +9,7 @@ import {
|
|||||||
import { useTerminal } from "@/context/TerminalContext"
|
import { useTerminal } from "@/context/TerminalContext"
|
||||||
import { Sandbox, User } from "@/lib/types"
|
import { Sandbox, User } from "@/lib/types"
|
||||||
import { Globe } from "lucide-react"
|
import { Globe } from "lucide-react"
|
||||||
import { useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
|
|
||||||
export default function DeployButtonModal({
|
export default function DeployButtonModal({
|
||||||
userData,
|
userData,
|
||||||
@ -18,8 +18,21 @@ export default function DeployButtonModal({
|
|||||||
userData: User
|
userData: User
|
||||||
data: Sandbox
|
data: Sandbox
|
||||||
}) {
|
}) {
|
||||||
const { deploy } = useTerminal()
|
const { deploy, getAppExists } = useTerminal()
|
||||||
const [isDeploying, setIsDeploying] = useState(false)
|
const [isDeploying, setIsDeploying] = useState(false)
|
||||||
|
const [isDeployed, setIsDeployed] = useState(false)
|
||||||
|
const [deployButtonVisible, setDeployButtonEnabled] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkDeployment = async () => {
|
||||||
|
if (getAppExists) {
|
||||||
|
const exists = await getAppExists(data.id)
|
||||||
|
setDeployButtonEnabled(exists.success)
|
||||||
|
setIsDeployed((exists.success && exists.exists) ?? false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
checkDeployment()
|
||||||
|
}, [data.id, getAppExists])
|
||||||
|
|
||||||
const handleDeploy = () => {
|
const handleDeploy = () => {
|
||||||
if (isDeploying) {
|
if (isDeploying) {
|
||||||
@ -30,6 +43,7 @@ export default function DeployButtonModal({
|
|||||||
setIsDeploying(true)
|
setIsDeploying(true)
|
||||||
deploy(() => {
|
deploy(() => {
|
||||||
setIsDeploying(false)
|
setIsDeploying(false)
|
||||||
|
setIsDeployed(true)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -38,10 +52,12 @@ export default function DeployButtonModal({
|
|||||||
<>
|
<>
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button variant="outline">
|
{deployButtonVisible && (
|
||||||
<Globe className="w-4 h-4 mr-2" />
|
<Button variant="outline">
|
||||||
Deploy
|
<Globe className="w-4 h-4 mr-2" />
|
||||||
</Button>
|
Deploy
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent
|
<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"
|
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"
|
||||||
@ -52,8 +68,9 @@ export default function DeployButtonModal({
|
|||||||
<DeploymentOption
|
<DeploymentOption
|
||||||
icon={<Globe className="text-gray-500 w-5 h-5" />}
|
icon={<Globe className="text-gray-500 w-5 h-5" />}
|
||||||
domain={`${data.id}.gitwit.app`}
|
domain={`${data.id}.gitwit.app`}
|
||||||
timestamp="Deployed 1h ago"
|
timestamp="Deployed 1m ago"
|
||||||
user={userData.name}
|
user={userData.name}
|
||||||
|
isDeployed={isDeployed}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@ -61,7 +78,7 @@ export default function DeployButtonModal({
|
|||||||
className="mt-4 w-full bg-[#0a0a0a] text-white hover:bg-[#262626]"
|
className="mt-4 w-full bg-[#0a0a0a] text-white hover:bg-[#262626]"
|
||||||
onClick={handleDeploy}
|
onClick={handleDeploy}
|
||||||
>
|
>
|
||||||
{isDeploying ? "Deploying..." : "Update"}
|
{isDeploying ? "Deploying..." : isDeployed ? "Update" : "Deploy"}
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
@ -74,27 +91,33 @@ function DeploymentOption({
|
|||||||
domain,
|
domain,
|
||||||
timestamp,
|
timestamp,
|
||||||
user,
|
user,
|
||||||
|
isDeployed,
|
||||||
}: {
|
}: {
|
||||||
icon: React.ReactNode
|
icon: React.ReactNode
|
||||||
domain: string
|
domain: string
|
||||||
timestamp: string
|
timestamp: string
|
||||||
user: string
|
user: string
|
||||||
|
isDeployed: boolean
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2 w-full text-left p-2 rounded-md border border-gray-700 bg-gray-900">
|
<div className="flex flex-col gap-2 w-full text-left p-2 rounded-md border border-gray-700 bg-gray-900">
|
||||||
<div className="flex items-start gap-2 relative">
|
<div className="flex items-start gap-2 relative">
|
||||||
<div className="flex-shrink-0">{icon}</div>
|
<div className="flex-shrink-0">{icon}</div>
|
||||||
<a
|
{isDeployed ? (
|
||||||
href={`https://${domain}`}
|
<a
|
||||||
target="_blank"
|
href={`https://${domain}`}
|
||||||
rel="noopener noreferrer"
|
target="_blank"
|
||||||
className="font-semibold text-gray-300 hover:underline"
|
rel="noopener noreferrer"
|
||||||
>
|
className="font-semibold text-gray-300 hover:underline"
|
||||||
{domain}
|
>
|
||||||
</a>
|
{domain}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="font-semibold text-gray-300">{domain}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-400 mt-0 ml-7">
|
<p className="text-sm text-gray-400 mt-0 ml-7">
|
||||||
{timestamp} • {user}
|
{isDeployed ? `${timestamp} • ${user}` : "Never deployed"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
@ -7,7 +7,7 @@ import "./xterm.css"
|
|||||||
import { debounce } from "@/lib/utils"
|
import { debounce } from "@/lib/utils"
|
||||||
import { Loader2 } from "lucide-react"
|
import { Loader2 } from "lucide-react"
|
||||||
import { useTheme } from "next-themes"
|
import { useTheme } from "next-themes"
|
||||||
import { ElementRef, useEffect, useRef } from "react"
|
import { ElementRef, useEffect, useRef, useCallback } from "react"
|
||||||
import { Socket } from "socket.io-client"
|
import { Socket } from "socket.io-client"
|
||||||
export default function EditorTerminal({
|
export default function EditorTerminal({
|
||||||
socket,
|
socket,
|
||||||
@ -28,7 +28,6 @@ export default function EditorTerminal({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!terminalContainerRef.current) return
|
if (!terminalContainerRef.current) return
|
||||||
// console.log("new terminal", id, term ? "reusing" : "creating");
|
|
||||||
|
|
||||||
const terminal = new Terminal({
|
const terminal = new Terminal({
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
@ -37,13 +36,54 @@ export default function EditorTerminal({
|
|||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
lineHeight: 1.5,
|
lineHeight: 1.5,
|
||||||
letterSpacing: 0,
|
letterSpacing: 0,
|
||||||
|
allowTransparency: true,
|
||||||
|
rightClickSelectsWord: true,
|
||||||
|
allowProposedApi: true, // for custom key events
|
||||||
|
})
|
||||||
|
|
||||||
|
// right-click paste handler
|
||||||
|
const handleContextMenu = (e: MouseEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
navigator.clipboard.readText().then((text) => {
|
||||||
|
if (text) {
|
||||||
|
socket.emit("terminalData", { id, data: text })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
terminalContainerRef.current.addEventListener(
|
||||||
|
"contextmenu",
|
||||||
|
handleContextMenu
|
||||||
|
)
|
||||||
|
|
||||||
|
// keyboard paste handler
|
||||||
|
terminal.attachCustomKeyEventHandler((event: KeyboardEvent) => {
|
||||||
|
if (event.type === "keydown") {
|
||||||
|
if (
|
||||||
|
(event.ctrlKey || event.metaKey) &&
|
||||||
|
event.key.toLowerCase() === "v"
|
||||||
|
) {
|
||||||
|
event.preventDefault()
|
||||||
|
navigator.clipboard.readText().then((text) => {
|
||||||
|
if (text) {
|
||||||
|
socket.emit("terminalData", { id, data: text })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
setTerm(terminal)
|
setTerm(terminal)
|
||||||
const dispose = () => {
|
|
||||||
|
return () => {
|
||||||
terminal.dispose()
|
terminal.dispose()
|
||||||
|
terminalContainerRef.current?.removeEventListener(
|
||||||
|
"contextmenu",
|
||||||
|
handleContextMenu
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return dispose
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -81,7 +121,6 @@ export default function EditorTerminal({
|
|||||||
|
|
||||||
const { width, height } = entry.contentRect
|
const { width, height } = entry.contentRect
|
||||||
|
|
||||||
// Only call fit if the size has actually changed
|
|
||||||
if (
|
if (
|
||||||
width !== terminalContainerRef.current.offsetWidth ||
|
width !== terminalContainerRef.current.offsetWidth ||
|
||||||
height !== terminalContainerRef.current.offsetHeight
|
height !== terminalContainerRef.current.offsetHeight
|
||||||
@ -92,10 +131,9 @@ export default function EditorTerminal({
|
|||||||
console.error("Error during fit:", err)
|
console.error("Error during fit:", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 50) // Debounce for 50ms
|
}, 50)
|
||||||
)
|
)
|
||||||
|
|
||||||
// start observing for resize
|
|
||||||
resizeObserver.observe(terminalContainerRef.current)
|
resizeObserver.observe(terminalContainerRef.current)
|
||||||
return () => {
|
return () => {
|
||||||
disposableOnData.dispose()
|
disposableOnData.dispose()
|
||||||
@ -124,6 +162,7 @@ export default function EditorTerminal({
|
|||||||
ref={terminalContainerRef}
|
ref={terminalContainerRef}
|
||||||
style={{ display: visible ? "block" : "none" }}
|
style={{ display: visible ? "block" : "none" }}
|
||||||
className="w-full h-full text-left"
|
className="w-full h-full text-left"
|
||||||
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
{term === null ? (
|
{term === null ? (
|
||||||
<div className="flex items-center text-muted-foreground p-2">
|
<div className="flex items-center text-muted-foreground p-2">
|
||||||
|
@ -231,7 +231,10 @@ function ProfileCard({
|
|||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<p className="text-xs text-muted-foreground">{joinedAt}</p>
|
<p className="text-xs text-muted-foreground">{joinedAt}</p>
|
||||||
{typeof generations === "number" && (
|
{typeof generations === "number" && (
|
||||||
<SubscriptionBadge generations={generations} tier={tier as keyof typeof TIERS} />
|
<SubscriptionBadge
|
||||||
|
generations={generations}
|
||||||
|
tier={tier as keyof typeof TIERS}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@ -445,7 +448,13 @@ const StatsItem = ({ icon: Icon, label }: StatsItemProps) => (
|
|||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
// #region Sub Badge
|
// #region Sub Badge
|
||||||
const SubscriptionBadge = ({ generations, tier = "FREE" }: { generations: number, tier?: keyof typeof TIERS }) => {
|
const SubscriptionBadge = ({
|
||||||
|
generations,
|
||||||
|
tier = "FREE",
|
||||||
|
}: {
|
||||||
|
generations: number
|
||||||
|
tier?: keyof typeof TIERS
|
||||||
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<Badge variant="secondary" className="text-sm cursor-pointer">
|
<Badge variant="secondary" className="text-sm cursor-pointer">
|
||||||
|
@ -10,7 +10,7 @@ import {
|
|||||||
import { User } from "@/lib/types"
|
import { User } from "@/lib/types"
|
||||||
import { useClerk } from "@clerk/nextjs"
|
import { useClerk } from "@clerk/nextjs"
|
||||||
import {
|
import {
|
||||||
Crown,
|
Crown,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
LogOut,
|
LogOut,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
@ -43,7 +43,11 @@ const TIER_INFO = {
|
|||||||
},
|
},
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export default function UserButton({ userData: initialUserData }: { userData: User }) {
|
export default function UserButton({
|
||||||
|
userData: initialUserData,
|
||||||
|
}: {
|
||||||
|
userData: User
|
||||||
|
}) {
|
||||||
const [userData, setUserData] = useState<User>(initialUserData)
|
const [userData, setUserData] = useState<User>(initialUserData)
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
const { signOut } = useClerk()
|
const { signOut } = useClerk()
|
||||||
@ -57,7 +61,7 @@ export default function UserButton({ userData: initialUserData }: { userData: Us
|
|||||||
headers: {
|
headers: {
|
||||||
Authorization: `${process.env.NEXT_PUBLIC_WORKERS_KEY}`,
|
Authorization: `${process.env.NEXT_PUBLIC_WORKERS_KEY}`,
|
||||||
},
|
},
|
||||||
cache: 'no-store'
|
cache: "no-store",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@ -75,9 +79,12 @@ export default function UserButton({ userData: initialUserData }: { userData: Us
|
|||||||
}
|
}
|
||||||
}, [isOpen])
|
}, [isOpen])
|
||||||
|
|
||||||
const tierInfo = TIER_INFO[userData.tier as keyof typeof TIER_INFO] || TIER_INFO.FREE
|
const tierInfo =
|
||||||
|
TIER_INFO[userData.tier as keyof typeof TIER_INFO] || TIER_INFO.FREE
|
||||||
const TierIcon = tierInfo.icon
|
const TierIcon = tierInfo.icon
|
||||||
const usagePercentage = Math.floor((userData.generations || 0) * 100 / tierInfo.limit)
|
const usagePercentage = Math.floor(
|
||||||
|
((userData.generations || 0) * 100) / tierInfo.limit
|
||||||
|
)
|
||||||
|
|
||||||
const handleUpgrade = async () => {
|
const handleUpgrade = async () => {
|
||||||
router.push(`/@${userData.username}`)
|
router.push(`/@${userData.username}`)
|
||||||
@ -98,7 +105,6 @@ export default function UserButton({ userData: initialUserData }: { userData: Us
|
|||||||
|
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
|
||||||
<DropdownMenuItem className="cursor-pointer" asChild>
|
<DropdownMenuItem className="cursor-pointer" asChild>
|
||||||
<Link href={"/dashboard"}>
|
<Link href={"/dashboard"}>
|
||||||
<LayoutDashboard className="mr-2 size-4" />
|
<LayoutDashboard className="mr-2 size-4" />
|
||||||
@ -114,12 +120,13 @@ export default function UserButton({ userData: initialUserData }: { userData: Us
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
|
||||||
<div className="py-1.5 px-2 w-full">
|
<div className="py-1.5 px-2 w-full">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<TierIcon className={`h-4 w-4 ${tierInfo.color}`} />
|
<TierIcon className={`h-4 w-4 ${tierInfo.color}`} />
|
||||||
<span className="text-sm font-medium">{userData.tier || "FREE"} Plan</span>
|
<span className="text-sm font-medium">
|
||||||
|
{userData.tier || "FREE"} Plan
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{(userData.tier === "FREE" || userData.tier === "PRO") && (
|
{(userData.tier === "FREE" || userData.tier === "PRO") && (
|
||||||
<Button
|
<Button
|
||||||
@ -139,16 +146,20 @@ export default function UserButton({ userData: initialUserData }: { userData: Us
|
|||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div className="flex items-center justify-between text-sm text-muted-foreground mb-2">
|
<div className="flex items-center justify-between text-sm text-muted-foreground mb-2">
|
||||||
<span>AI Usage</span>
|
<span>AI Usage</span>
|
||||||
<span>{userData.generations}/{tierInfo.limit}</span>
|
<span>
|
||||||
|
{userData.generations}/{tierInfo.limit}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-full w-full h-2 overflow-hidden bg-secondary mb-1">
|
<div className="rounded-full w-full h-2 overflow-hidden bg-secondary mb-1">
|
||||||
<div
|
<div
|
||||||
className={`h-full rounded-full transition-all duration-300 ${
|
className={`h-full rounded-full transition-all duration-300 ${
|
||||||
usagePercentage > 90 ? 'bg-red-500' :
|
usagePercentage > 90
|
||||||
usagePercentage > 75 ? 'bg-yellow-500' :
|
? "bg-red-500"
|
||||||
tierInfo.color.replace('text-', 'bg-')
|
: usagePercentage > 75
|
||||||
}`}
|
? "bg-yellow-500"
|
||||||
|
: tierInfo.color.replace("text-", "bg-")
|
||||||
|
}`}
|
||||||
style={{
|
style={{
|
||||||
width: `${Math.min(usagePercentage, 100)}%`,
|
width: `${Math.min(usagePercentage, 100)}%`,
|
||||||
}}
|
}}
|
||||||
@ -173,4 +184,3 @@ export default function UserButton({ userData: initialUserData }: { userData: Us
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -20,6 +20,9 @@ interface TerminalContextType {
|
|||||||
createNewTerminal: (command?: string) => Promise<void>
|
createNewTerminal: (command?: string) => Promise<void>
|
||||||
closeTerminal: (id: string) => void
|
closeTerminal: (id: string) => void
|
||||||
deploy: (callback: () => void) => void
|
deploy: (callback: () => void) => void
|
||||||
|
getAppExists:
|
||||||
|
| ((appName: string) => Promise<{ success: boolean; exists?: boolean }>)
|
||||||
|
| null
|
||||||
}
|
}
|
||||||
|
|
||||||
const TerminalContext = createContext<TerminalContextType | undefined>(
|
const TerminalContext = createContext<TerminalContextType | undefined>(
|
||||||
@ -35,6 +38,19 @@ export const TerminalProvider: React.FC<{ children: React.ReactNode }> = ({
|
|||||||
>([])
|
>([])
|
||||||
const [activeTerminalId, setActiveTerminalId] = useState<string>("")
|
const [activeTerminalId, setActiveTerminalId] = useState<string>("")
|
||||||
const [creatingTerminal, setCreatingTerminal] = useState<boolean>(false)
|
const [creatingTerminal, setCreatingTerminal] = useState<boolean>(false)
|
||||||
|
const [isSocketReady, setIsSocketReady] = useState<boolean>(false)
|
||||||
|
|
||||||
|
// Listen for the "ready" signal from the socket
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (socket) {
|
||||||
|
socket.on("ready", () => {
|
||||||
|
setIsSocketReady(true)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (socket) socket.off("ready")
|
||||||
|
}
|
||||||
|
}, [socket])
|
||||||
|
|
||||||
const createNewTerminal = async (command?: string): Promise<void> => {
|
const createNewTerminal = async (command?: string): Promise<void> => {
|
||||||
if (!socket) return
|
if (!socket) return
|
||||||
@ -78,6 +94,20 @@ export const TerminalProvider: React.FC<{ children: React.ReactNode }> = ({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getAppExists = async (
|
||||||
|
appName: string
|
||||||
|
): Promise<{ success: boolean; exists?: boolean }> => {
|
||||||
|
console.log("Is there a socket: " + !!socket)
|
||||||
|
if (!socket) {
|
||||||
|
console.error("Couldn't check if app exists: No socket")
|
||||||
|
return { success: false }
|
||||||
|
}
|
||||||
|
const response: { success: boolean; exists?: boolean } = await new Promise(
|
||||||
|
(resolve) => socket.emit("getAppExists", { appName }, resolve)
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
const value = {
|
const value = {
|
||||||
terminals,
|
terminals,
|
||||||
setTerminals,
|
setTerminals,
|
||||||
@ -88,6 +118,7 @@ export const TerminalProvider: React.FC<{ children: React.ReactNode }> = ({
|
|||||||
createNewTerminal,
|
createNewTerminal,
|
||||||
closeTerminal,
|
closeTerminal,
|
||||||
deploy,
|
deploy,
|
||||||
|
getAppExists: isSocketReady ? getAppExists : null,
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -38,6 +38,6 @@ export const projectTemplates: {
|
|||||||
name: "PHP",
|
name: "PHP",
|
||||||
description: "PHP development environment",
|
description: "PHP development environment",
|
||||||
icon: "/project-icons/php.svg",
|
icon: "/project-icons/php.svg",
|
||||||
disabled: false
|
disabled: false,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
@ -1,290 +1,290 @@
|
|||||||
export interface TemplateConfig {
|
export interface TemplateConfig {
|
||||||
id: string
|
id: string
|
||||||
name: string,
|
name: string
|
||||||
runCommand: string,
|
runCommand: string
|
||||||
fileStructure: {
|
fileStructure: {
|
||||||
[key: string]: {
|
[key: string]: {
|
||||||
purpose: string
|
purpose: string
|
||||||
description: string
|
description: string
|
||||||
}
|
|
||||||
}
|
|
||||||
conventions: string[]
|
|
||||||
dependencies?: {
|
|
||||||
[key: string]: string
|
|
||||||
}
|
|
||||||
scripts?: {
|
|
||||||
[key: string]: string
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
conventions: string[]
|
||||||
export const templateConfigs: { [key: string]: TemplateConfig } = {
|
dependencies?: {
|
||||||
reactjs: {
|
[key: string]: string
|
||||||
id: "reactjs",
|
}
|
||||||
name: "React",
|
scripts?: {
|
||||||
runCommand: "npm run dev",
|
[key: string]: string
|
||||||
fileStructure: {
|
}
|
||||||
"src/": {
|
}
|
||||||
purpose: "source",
|
|
||||||
description: "Contains all React components and application logic"
|
export const templateConfigs: { [key: string]: TemplateConfig } = {
|
||||||
},
|
reactjs: {
|
||||||
"src/components/": {
|
id: "reactjs",
|
||||||
purpose: "components",
|
name: "React",
|
||||||
description: "Reusable React components"
|
runCommand: "npm run dev",
|
||||||
},
|
fileStructure: {
|
||||||
"src/lib/": {
|
"src/": {
|
||||||
purpose: "utilities",
|
purpose: "source",
|
||||||
description: "Utility functions and shared code"
|
description: "Contains all React components and application logic",
|
||||||
},
|
},
|
||||||
"src/App.tsx": {
|
"src/components/": {
|
||||||
purpose: "entry",
|
purpose: "components",
|
||||||
description: "Main application component"
|
description: "Reusable React components",
|
||||||
},
|
},
|
||||||
"src/index.tsx": {
|
"src/lib/": {
|
||||||
purpose: "entry",
|
purpose: "utilities",
|
||||||
description: "Application entry point"
|
description: "Utility functions and shared code",
|
||||||
},
|
},
|
||||||
"src/index.css": {
|
"src/App.tsx": {
|
||||||
purpose: "styles",
|
purpose: "entry",
|
||||||
description: "Global CSS styles"
|
description: "Main application component",
|
||||||
},
|
},
|
||||||
"public/": {
|
"src/index.tsx": {
|
||||||
purpose: "static",
|
purpose: "entry",
|
||||||
description: "Static assets and index.html"
|
description: "Application entry point",
|
||||||
},
|
},
|
||||||
"tsconfig.json": {
|
"src/index.css": {
|
||||||
purpose: "config",
|
purpose: "styles",
|
||||||
description: "TypeScript configuration"
|
description: "Global CSS styles",
|
||||||
},
|
},
|
||||||
"vite.config.ts": {
|
"public/": {
|
||||||
purpose: "config",
|
purpose: "static",
|
||||||
description: "Vite bundler configuration"
|
description: "Static assets and index.html",
|
||||||
},
|
},
|
||||||
"package.json": {
|
"tsconfig.json": {
|
||||||
purpose: "config",
|
purpose: "config",
|
||||||
description: "Project dependencies and scripts"
|
description: "TypeScript configuration",
|
||||||
}
|
},
|
||||||
},
|
"vite.config.ts": {
|
||||||
conventions: [
|
purpose: "config",
|
||||||
"Use functional components with hooks",
|
description: "Vite bundler configuration",
|
||||||
"Follow React naming conventions (PascalCase for components)",
|
},
|
||||||
"Keep components small and focused",
|
"package.json": {
|
||||||
"Use TypeScript for type safety"
|
purpose: "config",
|
||||||
],
|
description: "Project dependencies and scripts",
|
||||||
dependencies: {
|
},
|
||||||
"@radix-ui/react-icons": "^1.3.0",
|
},
|
||||||
"@radix-ui/react-slot": "^1.1.0",
|
conventions: [
|
||||||
"class-variance-authority": "^0.7.0",
|
"Use functional components with hooks",
|
||||||
"clsx": "^2.1.1",
|
"Follow React naming conventions (PascalCase for components)",
|
||||||
"lucide-react": "^0.441.0",
|
"Keep components small and focused",
|
||||||
"react": "^18.3.1",
|
"Use TypeScript for type safety",
|
||||||
"react-dom": "^18.3.1",
|
],
|
||||||
"tailwind-merge": "^2.5.2",
|
dependencies: {
|
||||||
"tailwindcss-animate": "^1.0.7"
|
"@radix-ui/react-icons": "^1.3.0",
|
||||||
},
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
scripts: {
|
"class-variance-authority": "^0.7.0",
|
||||||
"dev": "vite",
|
clsx: "^2.1.1",
|
||||||
"build": "tsc && vite build",
|
"lucide-react": "^0.441.0",
|
||||||
"preview": "vite preview",
|
react: "^18.3.1",
|
||||||
}
|
"react-dom": "^18.3.1",
|
||||||
},
|
"tailwind-merge": "^2.5.2",
|
||||||
// Next.js template config
|
"tailwindcss-animate": "^1.0.7",
|
||||||
nextjs: {
|
},
|
||||||
id: "nextjs",
|
scripts: {
|
||||||
name: "NextJS",
|
dev: "vite",
|
||||||
runCommand: "npm run dev",
|
build: "tsc && vite build",
|
||||||
fileStructure: {
|
preview: "vite preview",
|
||||||
"pages/": {
|
},
|
||||||
purpose: "routing",
|
},
|
||||||
description: "Page components and API routes"
|
// Next.js template config
|
||||||
},
|
nextjs: {
|
||||||
"pages/api/": {
|
id: "nextjs",
|
||||||
purpose: "api",
|
name: "NextJS",
|
||||||
description: "API route handlers"
|
runCommand: "npm run dev",
|
||||||
},
|
fileStructure: {
|
||||||
"pages/_app.tsx": {
|
"pages/": {
|
||||||
purpose: "entry",
|
purpose: "routing",
|
||||||
description: "Application wrapper component"
|
description: "Page components and API routes",
|
||||||
},
|
},
|
||||||
"pages/index.tsx": {
|
"pages/api/": {
|
||||||
purpose: "page",
|
purpose: "api",
|
||||||
description: "Homepage component"
|
description: "API route handlers",
|
||||||
},
|
},
|
||||||
"public/": {
|
"pages/_app.tsx": {
|
||||||
purpose: "static",
|
purpose: "entry",
|
||||||
description: "Static assets and files"
|
description: "Application wrapper component",
|
||||||
},
|
},
|
||||||
"styles/": {
|
"pages/index.tsx": {
|
||||||
purpose: "styles",
|
purpose: "page",
|
||||||
description: "CSS modules and global styles"
|
description: "Homepage component",
|
||||||
},
|
},
|
||||||
"styles/globals.css": {
|
"public/": {
|
||||||
purpose: "styles",
|
purpose: "static",
|
||||||
description: "Global CSS styles"
|
description: "Static assets and files",
|
||||||
},
|
},
|
||||||
"styles/Home.module.css": {
|
"styles/": {
|
||||||
purpose: "styles",
|
purpose: "styles",
|
||||||
description: "Homepage-specific styles"
|
description: "CSS modules and global styles",
|
||||||
},
|
},
|
||||||
"next.config.js": {
|
"styles/globals.css": {
|
||||||
purpose: "config",
|
purpose: "styles",
|
||||||
description: "Next.js configuration"
|
description: "Global CSS styles",
|
||||||
},
|
},
|
||||||
"next-env.d.ts": {
|
"styles/Home.module.css": {
|
||||||
purpose: "types",
|
purpose: "styles",
|
||||||
description: "Next.js TypeScript declarations"
|
description: "Homepage-specific styles",
|
||||||
},
|
},
|
||||||
"tsconfig.json": {
|
"next.config.js": {
|
||||||
purpose: "config",
|
purpose: "config",
|
||||||
description: "TypeScript configuration"
|
description: "Next.js configuration",
|
||||||
},
|
},
|
||||||
"package.json": {
|
"next-env.d.ts": {
|
||||||
purpose: "config",
|
purpose: "types",
|
||||||
description: "Project dependencies and scripts"
|
description: "Next.js TypeScript declarations",
|
||||||
}
|
},
|
||||||
},
|
"tsconfig.json": {
|
||||||
conventions: [
|
purpose: "config",
|
||||||
"Use file-system based routing",
|
description: "TypeScript configuration",
|
||||||
"Keep API routes in pages/api",
|
},
|
||||||
"Use CSS Modules for component styles",
|
"package.json": {
|
||||||
"Follow Next.js data fetching patterns"
|
purpose: "config",
|
||||||
],
|
description: "Project dependencies and scripts",
|
||||||
dependencies: {
|
},
|
||||||
"next": "^14.1.0",
|
},
|
||||||
"react": "^18.2.0",
|
conventions: [
|
||||||
"react-dom": "18.2.0",
|
"Use file-system based routing",
|
||||||
"tailwindcss": "^3.4.1"
|
"Keep API routes in pages/api",
|
||||||
},
|
"Use CSS Modules for component styles",
|
||||||
scripts: {
|
"Follow Next.js data fetching patterns",
|
||||||
"dev": "next dev",
|
],
|
||||||
"build": "next build",
|
dependencies: {
|
||||||
"start": "next start",
|
next: "^14.1.0",
|
||||||
"lint": "next lint",
|
react: "^18.2.0",
|
||||||
}
|
"react-dom": "18.2.0",
|
||||||
},
|
tailwindcss: "^3.4.1",
|
||||||
// Streamlit template config
|
},
|
||||||
streamlit: {
|
scripts: {
|
||||||
id: "streamlit",
|
dev: "next dev",
|
||||||
name: "Streamlit",
|
build: "next build",
|
||||||
runCommand: "./venv/bin/streamlit run main.py --server.runOnSave true",
|
start: "next start",
|
||||||
fileStructure: {
|
lint: "next lint",
|
||||||
"main.py": {
|
},
|
||||||
purpose: "entry",
|
},
|
||||||
description: "Main Streamlit application file"
|
// Streamlit template config
|
||||||
},
|
streamlit: {
|
||||||
"requirements.txt": {
|
id: "streamlit",
|
||||||
purpose: "dependencies",
|
name: "Streamlit",
|
||||||
description: "Python package dependencies"
|
runCommand: "./venv/bin/streamlit run main.py --server.runOnSave true",
|
||||||
},
|
fileStructure: {
|
||||||
"Procfile": {
|
"main.py": {
|
||||||
purpose: "deployment",
|
purpose: "entry",
|
||||||
description: "Deployment configuration for hosting platforms"
|
description: "Main Streamlit application file",
|
||||||
},
|
},
|
||||||
"venv/": {
|
"requirements.txt": {
|
||||||
purpose: "environment",
|
purpose: "dependencies",
|
||||||
description: "Python virtual environment directory"
|
description: "Python package dependencies",
|
||||||
}
|
},
|
||||||
},
|
Procfile: {
|
||||||
conventions: [
|
purpose: "deployment",
|
||||||
"Use Streamlit components for UI",
|
description: "Deployment configuration for hosting platforms",
|
||||||
"Follow PEP 8 style guide",
|
},
|
||||||
"Keep dependencies in requirements.txt",
|
"venv/": {
|
||||||
"Use virtual environment for isolation"
|
purpose: "environment",
|
||||||
],
|
description: "Python virtual environment directory",
|
||||||
dependencies: {
|
},
|
||||||
"streamlit": "^1.40.0",
|
},
|
||||||
"altair": "^5.5.0"
|
conventions: [
|
||||||
},
|
"Use Streamlit components for UI",
|
||||||
scripts: {
|
"Follow PEP 8 style guide",
|
||||||
"start": "streamlit run main.py",
|
"Keep dependencies in requirements.txt",
|
||||||
"dev": "./venv/bin/streamlit run main.py --server.runOnSave true"
|
"Use virtual environment for isolation",
|
||||||
}
|
],
|
||||||
},
|
dependencies: {
|
||||||
// HTML template config
|
streamlit: "^1.40.0",
|
||||||
vanillajs: {
|
altair: "^5.5.0",
|
||||||
id: "vanillajs",
|
},
|
||||||
name: "HTML/JS",
|
scripts: {
|
||||||
runCommand: "npm run dev",
|
start: "streamlit run main.py",
|
||||||
fileStructure: {
|
dev: "./venv/bin/streamlit run main.py --server.runOnSave true",
|
||||||
"index.html": {
|
},
|
||||||
purpose: "entry",
|
},
|
||||||
description: "Main HTML entry point"
|
// HTML template config
|
||||||
},
|
vanillajs: {
|
||||||
"style.css": {
|
id: "vanillajs",
|
||||||
purpose: "styles",
|
name: "HTML/JS",
|
||||||
description: "Global CSS styles"
|
runCommand: "npm run dev",
|
||||||
},
|
fileStructure: {
|
||||||
"script.js": {
|
"index.html": {
|
||||||
purpose: "scripts",
|
purpose: "entry",
|
||||||
description: "JavaScript application logic"
|
description: "Main HTML entry point",
|
||||||
},
|
},
|
||||||
"package.json": {
|
"style.css": {
|
||||||
purpose: "config",
|
purpose: "styles",
|
||||||
description: "Project dependencies and scripts"
|
description: "Global CSS styles",
|
||||||
},
|
},
|
||||||
"package-lock.json": {
|
"script.js": {
|
||||||
purpose: "config",
|
purpose: "scripts",
|
||||||
description: "Locked dependency versions"
|
description: "JavaScript application logic",
|
||||||
},
|
},
|
||||||
"vite.config.js": {
|
"package.json": {
|
||||||
purpose: "config",
|
purpose: "config",
|
||||||
description: "Vite bundler configuration"
|
description: "Project dependencies and scripts",
|
||||||
}
|
},
|
||||||
},
|
"package-lock.json": {
|
||||||
conventions: [
|
purpose: "config",
|
||||||
"Use semantic HTML elements",
|
description: "Locked dependency versions",
|
||||||
"Keep CSS modular and organized",
|
},
|
||||||
"Write clean, modular JavaScript",
|
"vite.config.js": {
|
||||||
"Follow modern ES6+ practices"
|
purpose: "config",
|
||||||
],
|
description: "Vite bundler configuration",
|
||||||
dependencies: {
|
},
|
||||||
"vite": "^5.0.12"
|
},
|
||||||
},
|
conventions: [
|
||||||
scripts: {
|
"Use semantic HTML elements",
|
||||||
"dev": "vite",
|
"Keep CSS modular and organized",
|
||||||
"build": "vite build",
|
"Write clean, modular JavaScript",
|
||||||
"preview": "vite preview"
|
"Follow modern ES6+ practices",
|
||||||
}
|
],
|
||||||
},
|
dependencies: {
|
||||||
// PHP template config
|
vite: "^5.0.12",
|
||||||
php: {
|
},
|
||||||
id: "php",
|
scripts: {
|
||||||
name: "PHP",
|
dev: "vite",
|
||||||
runCommand: "npx vite",
|
build: "vite build",
|
||||||
fileStructure: {
|
preview: "vite preview",
|
||||||
"index.php": {
|
},
|
||||||
purpose: "entry",
|
},
|
||||||
description: "Main PHP entry point"
|
// PHP template config
|
||||||
},
|
php: {
|
||||||
"package.json": {
|
id: "php",
|
||||||
purpose: "config",
|
name: "PHP",
|
||||||
description: "Frontend dependencies and scripts"
|
runCommand: "npx vite",
|
||||||
},
|
fileStructure: {
|
||||||
"package-lock.json": {
|
"index.php": {
|
||||||
purpose: "config",
|
purpose: "entry",
|
||||||
description: "Locked dependency versions"
|
description: "Main PHP entry point",
|
||||||
},
|
},
|
||||||
"vite.config.js": {
|
"package.json": {
|
||||||
purpose: "config",
|
purpose: "config",
|
||||||
description: "Vite configuration for frontend assets"
|
description: "Frontend dependencies and scripts",
|
||||||
},
|
},
|
||||||
"node_modules/": {
|
"package-lock.json": {
|
||||||
purpose: "dependencies",
|
purpose: "config",
|
||||||
description: "Frontend dependency files"
|
description: "Locked dependency versions",
|
||||||
}
|
},
|
||||||
},
|
"vite.config.js": {
|
||||||
conventions: [
|
purpose: "config",
|
||||||
"Follow PSR-12 coding standards",
|
description: "Vite configuration for frontend assets",
|
||||||
"Use modern PHP 8+ features",
|
},
|
||||||
"Organize assets with Vite",
|
"node_modules/": {
|
||||||
"Keep PHP logic separate from presentation"
|
purpose: "dependencies",
|
||||||
],
|
description: "Frontend dependency files",
|
||||||
dependencies: {
|
},
|
||||||
"vite": "^5.0.0"
|
},
|
||||||
},
|
conventions: [
|
||||||
scripts: {
|
"Follow PSR-12 coding standards",
|
||||||
"dev": "vite",
|
"Use modern PHP 8+ features",
|
||||||
"build": "vite build",
|
"Organize assets with Vite",
|
||||||
"preview": "vite preview"
|
"Keep PHP logic separate from presentation",
|
||||||
}
|
],
|
||||||
}
|
dependencies: {
|
||||||
|
vite: "^5.0.0",
|
||||||
|
},
|
||||||
|
scripts: {
|
||||||
|
dev: "vite",
|
||||||
|
build: "vite build",
|
||||||
|
preview: "vite preview",
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
@ -1,19 +1,19 @@
|
|||||||
export const TIERS = {
|
export const TIERS = {
|
||||||
FREE: {
|
FREE: {
|
||||||
// generations: 100,
|
// generations: 100,
|
||||||
// maxTokens: 1024,
|
// maxTokens: 1024,
|
||||||
generations: 1000,
|
generations: 1000,
|
||||||
maxTokens: 4096,
|
maxTokens: 4096,
|
||||||
model: "claude-3-5-sonnet-20240620",
|
model: "claude-3-5-sonnet-20240620",
|
||||||
},
|
},
|
||||||
PRO: {
|
PRO: {
|
||||||
generations: 500,
|
generations: 500,
|
||||||
maxTokens: 2048,
|
maxTokens: 2048,
|
||||||
model: "claude-3-5-sonnet-20240620",
|
model: "claude-3-5-sonnet-20240620",
|
||||||
},
|
},
|
||||||
ENTERPRISE: {
|
ENTERPRISE: {
|
||||||
generations: 1000,
|
generations: 1000,
|
||||||
maxTokens: 4096,
|
maxTokens: 4096,
|
||||||
model: "claude-3-5-sonnet-20240620",
|
model: "claude-3-5-sonnet-20240620",
|
||||||
},
|
},
|
||||||
}
|
}
|
60
frontend/package-lock.json
generated
60
frontend/package-lock.json
generated
@ -54,6 +54,7 @@
|
|||||||
"monaco-themes": "^0.4.4",
|
"monaco-themes": "^0.4.4",
|
||||||
"next": "14.1.3",
|
"next": "14.1.3",
|
||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
|
"openai": "^4.73.1",
|
||||||
"posthog-js": "^1.147.0",
|
"posthog-js": "^1.147.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
@ -7492,6 +7493,65 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/openai": {
|
||||||
|
"version": "4.73.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/openai/-/openai-4.73.1.tgz",
|
||||||
|
"integrity": "sha512-nWImDJBcUsqrhy7yJScXB4+iqjzbUEgzfA3un/6UnHFdwWhjX24oztj69Ped/njABfOdLcO/F7CeWTI5dt8Xmg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "^18.11.18",
|
||||||
|
"@types/node-fetch": "^2.6.4",
|
||||||
|
"abort-controller": "^3.0.0",
|
||||||
|
"agentkeepalive": "^4.2.1",
|
||||||
|
"form-data-encoder": "1.7.2",
|
||||||
|
"formdata-node": "^4.3.2",
|
||||||
|
"node-fetch": "^2.6.7"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"openai": "bin/cli"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"zod": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/openai/node_modules/@types/node": {
|
||||||
|
"version": "18.19.67",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.67.tgz",
|
||||||
|
"integrity": "sha512-wI8uHusga+0ZugNp0Ol/3BqQfEcCCNfojtO6Oou9iVNGPTL6QNSdnUdqq85fRgIorLhLMuPIKpsN98QE9Nh+KQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~5.26.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/openai/node_modules/@types/node-fetch": {
|
||||||
|
"version": "2.6.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.12.tgz",
|
||||||
|
"integrity": "sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*",
|
||||||
|
"form-data": "^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/openai/node_modules/form-data": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"asynckit": "^0.4.0",
|
||||||
|
"combined-stream": "^1.0.8",
|
||||||
|
"mime-types": "^2.1.12"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ora": {
|
"node_modules/ora": {
|
||||||
"version": "6.3.1",
|
"version": "6.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/ora/-/ora-6.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/ora/-/ora-6.3.1.tgz",
|
||||||
|
@ -55,6 +55,7 @@
|
|||||||
"monaco-themes": "^0.4.4",
|
"monaco-themes": "^0.4.4",
|
||||||
"next": "14.1.3",
|
"next": "14.1.3",
|
||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
|
"openai": "^4.73.1",
|
||||||
"posthog-js": "^1.147.0",
|
"posthog-js": "^1.147.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
|
Reference in New Issue
Block a user