From f3521c5f700fd9791ce4fd02d621237cd820a1f7 Mon Sep 17 00:00:00 2001 From: Narwhal Date: Sat, 8 Feb 2025 14:07:59 -0600 Subject: [PATCH 1/5] feat: getting project by chatId and show load status in code engine --- backend/src/chat/chat.model.ts | 5 + backend/src/chat/chat.service.ts | 18 ++- backend/src/project/project-packages.model.ts | 8 +- backend/src/project/project.model.ts | 12 +- backend/src/project/project.module.ts | 7 +- backend/src/project/project.resolver.ts | 19 ++- backend/src/project/project.service.ts | 146 ++++++++++++------ frontend/src/app/(main)/Home.tsx | 37 +++-- frontend/src/app/(main)/MainLayout.tsx | 56 ++++--- frontend/src/app/api/project/route.ts | 2 +- .../components/code-engine/code-engine.tsx | 57 ++++++- .../components/code-engine/file-structure.tsx | 21 ++- .../components/code-engine/project-context.ts | 15 -- .../code-engine/project-context.tsx | 133 ++++++++++++++++ frontend/src/components/enum.ts | 1 + frontend/src/components/project-modal.tsx | 73 +++++++++ frontend/src/components/sidebar.tsx | 41 ++++- frontend/src/graphql/request.ts | 30 ++++ frontend/src/graphql/schema.gql | 5 +- frontend/src/graphql/type.tsx | 105 +++++++------ frontend/src/utils/requests.ts | 73 ++++++++- 21 files changed, 677 insertions(+), 187 deletions(-) delete mode 100644 frontend/src/components/code-engine/project-context.ts create mode 100644 frontend/src/components/code-engine/project-context.tsx create mode 100644 frontend/src/components/project-modal.tsx diff --git a/backend/src/chat/chat.model.ts b/backend/src/chat/chat.model.ts index 78da6062..e611ea9f 100644 --- a/backend/src/chat/chat.model.ts +++ b/backend/src/chat/chat.model.ts @@ -3,6 +3,7 @@ import { Entity, PrimaryGeneratedColumn, Column, ManyToOne } from 'typeorm'; import { Message } from 'src/chat/message.model'; import { SystemBaseModel } from 'src/system-base-model/system-base.model'; import { User } from 'src/user/user.model'; +import { Project } from 'src/project/project.model'; export enum StreamStatus { STREAMING = 'streaming', @@ -44,6 +45,10 @@ export class Chat extends SystemBaseModel { @ManyToOne(() => User, (user) => user.chats) @Field(() => User) user: User; + + @ManyToOne(() => Project, (project) => project.chats) + @Field(() => Project, { nullable: true }) + project: Project; } @ObjectType('ChatCompletionDeltaType') diff --git a/backend/src/chat/chat.service.ts b/backend/src/chat/chat.service.ts index d400d098..20f4d663 100644 --- a/backend/src/chat/chat.service.ts +++ b/backend/src/chat/chat.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { ChatCompletionChunk, Chat } from './chat.model'; import { Message, MessageRole } from 'src/chat/message.model'; import { InjectRepository } from '@nestjs/typeorm'; @@ -73,12 +73,20 @@ export class ChatService { async getChatDetails(chatId: string): Promise { const chat = await this.chatRepository.findOne({ where: { id: chatId, isDeleted: false }, - relations: ['messages'], + relations: ['project'], }); - if (chat) { - // Filter out messages that are soft-deleted - chat.messages = chat.messages.filter((message) => !message.isDeleted); + if (!chat) { + throw new NotFoundException('Chat not found'); + } + + try { + const messages = chat.messages || []; + chat.messages = messages.filter((message: any) => !message.isDeleted); + console.log(chat); + } catch (error) { + console.error('Error parsing messages JSON:', error); + chat.messages = []; } return chat; diff --git a/backend/src/project/project-packages.model.ts b/backend/src/project/project-packages.model.ts index 934554aa..e5bc980a 100644 --- a/backend/src/project/project-packages.model.ts +++ b/backend/src/project/project-packages.model.ts @@ -10,6 +10,10 @@ export class ProjectPackages extends SystemBaseModel { @PrimaryGeneratedColumn() id: string; + @Field() + @Column({ nullable: false }) + name: string; + @Field() @Column('text') content: string; @@ -18,6 +22,8 @@ export class ProjectPackages extends SystemBaseModel { @Column() version: string; - @ManyToMany(() => Project, (project) => project.projectPackages) + @ManyToMany(() => Project, (project) => project.projectPackages, { + nullable: true, + }) projects: Project[]; } diff --git a/backend/src/project/project.model.ts b/backend/src/project/project.model.ts index 599a675d..5e3b1796 100644 --- a/backend/src/project/project.model.ts +++ b/backend/src/project/project.model.ts @@ -8,9 +8,11 @@ import { JoinColumn, ManyToMany, JoinTable, + OneToMany, } from 'typeorm'; import { User } from 'src/user/user.model'; import { ProjectPackages } from './project-packages.model'; +import { Chat } from 'src/chat/chat.model'; @Entity() @ObjectType() @@ -29,7 +31,7 @@ export class Project extends SystemBaseModel { @Field(() => ID) @Column() - userId: number; + userId: string; @ManyToOne(() => User) @JoinColumn({ name: 'user_id' }) @@ -52,4 +54,12 @@ export class Project extends SystemBaseModel { }, }) projectPackages: ProjectPackages[]; + + @Field(() => [Chat]) + @OneToMany(() => Chat, (chat) => chat.project, { + cascade: true, // Automatically save related chats + lazy: true, // Load chats only when accessed + onDelete: 'CASCADE', // Delete chats when user is deleted + }) + chats: Chat[]; } diff --git a/backend/src/project/project.module.ts b/backend/src/project/project.module.ts index 90b22b83..c0be9389 100644 --- a/backend/src/project/project.module.ts +++ b/backend/src/project/project.module.ts @@ -6,13 +6,16 @@ import { ProjectService } from './project.service'; import { ProjectsResolver } from './project.resolver'; import { AuthModule } from '../auth/auth.module'; import { ProjectGuard } from '../guard/project.guard'; +import { ChatService } from 'src/chat/chat.service'; +import { User } from 'src/user/user.model'; +import { Chat } from 'src/chat/chat.model'; @Module({ imports: [ - TypeOrmModule.forFeature([Project, ProjectPackages]), + TypeOrmModule.forFeature([Project, Chat, User, ProjectPackages]), AuthModule, // Import AuthModule to provide JwtService to the ProjectGuard ], - providers: [ProjectService, ProjectsResolver, ProjectGuard], + providers: [ChatService, ProjectService, ProjectsResolver, ProjectGuard], exports: [ProjectService, ProjectGuard], }) export class ProjectModule {} diff --git a/backend/src/project/project.resolver.ts b/backend/src/project/project.resolver.ts index d9f4b7f6..840747d6 100644 --- a/backend/src/project/project.resolver.ts +++ b/backend/src/project/project.resolver.ts @@ -6,6 +6,7 @@ import { CreateProjectInput, IsValidProjectInput } from './dto/project.input'; import { UseGuards } from '@nestjs/common'; import { ProjectGuard } from '../guard/project.guard'; import { GetUserIdFromToken } from '../decorator/get-auth-token.decorator'; +import { Chat } from 'src/chat/chat.model'; @Resolver(() => Project) export class ProjectsResolver { @@ -13,7 +14,7 @@ export class ProjectsResolver { @Query(() => [Project]) async getUserProjects( - @GetUserIdFromToken() userId: number, + @GetUserIdFromToken() userId: string, ): Promise { return this.projectsService.getProjectsByUser(userId); } @@ -27,12 +28,16 @@ export class ProjectsResolver { return this.projectsService.getProjectById(projectId); } - @Mutation(() => Project) - async createPorject( - @GetUserIdFromToken() userId: number, + @Mutation(() => Chat) + async createProject( + @GetUserIdFromToken() userId: string, @Args('createProjectInput') createProjectInput: CreateProjectInput, - ): Promise { - return this.projectsService.createProject(createProjectInput, userId); + ): Promise { + const resChat = await this.projectsService.createProject( + createProjectInput, + userId, + ); + return resChat; } @Mutation(() => Boolean) @@ -43,7 +48,7 @@ export class ProjectsResolver { @Query(() => Boolean) async isValidateProject( - @GetUserIdFromToken() userId: number, + @GetUserIdFromToken() userId: string, @Args('isValidProject') input: IsValidProjectInput, ): Promise { return this.projectsService.isValidProject(userId, input); diff --git a/backend/src/project/project.service.ts b/backend/src/project/project.service.ts index cc70180e..7dc5780a 100644 --- a/backend/src/project/project.service.ts +++ b/backend/src/project/project.service.ts @@ -21,6 +21,8 @@ import { import { OpenAIModelProvider } from 'src/common/model-provider/openai-model-provider'; import { MessageRole } from 'src/chat/message.model'; import { BuilderContext } from 'src/build-system/context'; +import { ChatService } from 'src/chat/chat.service'; +import { Chat } from 'src/chat/chat.model'; @Injectable() export class ProjectService { @@ -30,11 +32,14 @@ export class ProjectService { constructor( @InjectRepository(Project) private projectsRepository: Repository, + @InjectRepository(Chat) + private chatRepository: Repository, @InjectRepository(ProjectPackages) private projectPackagesRepository: Repository, + private chatService: ChatService, ) {} - async getProjectsByUser(userId: number): Promise { + async getProjectsByUser(userId: string): Promise { const projects = await this.projectsRepository.find({ where: { userId: userId, isDeleted: false }, relations: ['projectPackages'], @@ -70,58 +75,96 @@ export class ProjectService { return project; } - // staring build the project + // binding project and chats + async bindProjectAndChat(project: Project, chat: Chat): Promise { + await this.projectsRepository.manager.connection.synchronize(); + await this.chatRepository.manager.connection.synchronize(); + if (!chat) { + this.logger.error('chat is undefined'); + return false; + } + try { + chat.project = project; + if (!project.chats) { + project.chats = []; + } + const chatArray = await project.chats; + chatArray.push(chat); + console.log(chat); + console.log(project); + await this.projectsRepository.save(project); + await this.chatRepository.save(chat); + + return true; + } catch (error) { + console.error('Error binding project and chat:', error); + return false; + } + } + async createProject( input: CreateProjectInput, - userId: number, - ): Promise { - if (input.projectName === '') { - this.logger.debug( - 'Project name not exist in input, generating project name', - ); - const nameGenerationPrompt = await generateProjectNamePrompt( - input.description, - ); - const response = await this.model.chatSync({ - messages: [ - { - role: MessageRole.System, - content: - 'You are a specialized project name generator. Respond only with the generated name.', - }, - { - role: MessageRole.User, - content: nameGenerationPrompt, - }, - ], - }); - input.projectName = response; - this.logger.debug(`Generated project name: ${input.projectName}`); - } + userId: string, + ): Promise { + const defaultChatPromise = await this.chatService.createChat(userId, { + title: input.projectName || 'Default Project Chat', + }); + + const projectPromise = (async () => { + try { + const nameGenerationPrompt = await generateProjectNamePrompt( + input.description, + ); + const response = await this.model.chatSync({ + model: 'gpt-4o', + messages: [ + { + role: MessageRole.System, + content: + 'You are a specialized project name generator. Respond only with the generated name.', + }, + { + role: MessageRole.User, + content: nameGenerationPrompt, + }, + ], + }); - // Build project sequence and get project path - const sequence = buildProjectSequenceByProject(input); - const context = new BuilderContext(sequence, sequence.id); - const projectPath = await context.execute(); + if (input.projectName === '') { + this.logger.debug( + 'Project name not exist in input, generating project name', + ); + input.projectName = response; + this.logger.debug(`Generated project name: ${input.projectName}`); + } - // Create new project entity - const project = new Project(); - project.projectName = input.projectName; - project.projectPath = projectPath; - project.userId = userId; + const sequence = buildProjectSequenceByProject(input); + const context = new BuilderContext(sequence, sequence.id); + const projectPath = await context.execute(); - // Transform input packages to ProjectPackages entities - const projectPackages = await this.transformInputToProjectPackages( - input.packages, - ); - project.projectPackages = projectPackages; + const project = new Project(); + project.projectName = input.projectName; + project.projectPath = projectPath; + project.userId = userId; - try { - return await this.projectsRepository.save(project); - } catch (error) { - this.logger.error('Error creating project:', error); - throw new InternalServerErrorException('Error creating the project.'); - } + project.projectPackages = await this.transformInputToProjectPackages( + input.packages, + ); + + const savedProject = await this.projectsRepository.save(project); + + const defaultChat = await defaultChatPromise; + await this.bindProjectAndChat(savedProject, defaultChat); + + console.log('Binded project and chats'); + return savedProject; + } catch (error) { + this.logger.error('Error creating project:', error); + throw new InternalServerErrorException('Error creating the project.'); + } + })(); + + return defaultChatPromise; } private async transformInputToProjectPackages( @@ -166,9 +209,10 @@ export class ProjectService { return transformedPackages; } catch (error) { this.logger.error('Error transforming packages:', error); - throw new InternalServerErrorException( - 'Error processing project packages.', - ); + // throw new InternalServerErrorException( + // 'Error processing project packages.', + // ); + return Promise.resolve([]); } } @@ -203,7 +247,7 @@ export class ProjectService { } async isValidProject( - userId: number, + userId: string, input: IsValidProjectInput, ): Promise { try { diff --git a/frontend/src/app/(main)/Home.tsx b/frontend/src/app/(main)/Home.tsx index 9b2d079e..80058783 100644 --- a/frontend/src/app/(main)/Home.tsx +++ b/frontend/src/app/(main)/Home.tsx @@ -1,14 +1,24 @@ // app/page.tsx or components/Home.tsx 'use client'; -import React, { useEffect, useState, useRef, useCallback } from 'react'; +import React, { + useEffect, + useState, + useRef, + useCallback, + useContext, +} from 'react'; import { ResizablePanelGroup, ResizablePanel, ResizableHandle, } from '@/components/ui/resizable'; import { CodeEngine } from '@/components/code-engine/code-engine'; -import { GET_CHAT_HISTORY } from '@/graphql/request'; -import { useQuery } from '@apollo/client'; +import { + CREATE_CHAT, + CREATE_PROJECT, + GET_CHAT_HISTORY, +} from '@/graphql/request'; +import { useMutation, useQuery } from '@apollo/client'; import { toast } from 'sonner'; import { EventEnum } from '@/components/enum'; import { useModels } from '../hooks/useModels'; @@ -25,18 +35,12 @@ export default function Home() { const [messages, setMessages] = useState([]); const [input, setInput] = useState(''); const formRef = useRef(null); - const { models } = useModels(); const [selectedModel, setSelectedModel] = useState(models[0] || 'gpt-4o'); + const { projects, curProject, setCurProject } = useContext(ProjectContext); const { refetchChats } = useChatList(); - //TODO: adding project id from .codefox/projects - const [projectId, setProjectId] = useState( - '2025-02-02-dfca4698-6e9b-4aab-9fcb-98e9526e5f21' - ); - const [filePath, setFilePath] = useState('frontend/vite.config.ts'); - // Apollo query to fetch chat history useQuery(GET_CHAT_HISTORY, { variables: { chatId }, @@ -72,6 +76,7 @@ export default function Home() { // Callback to switch to the settings view const updateSetting = () => setChatId(EventEnum.SETTING); + const updateProject = () => setChatId(EventEnum.NEW_PROJECT); // Effect to initialize chat ID and refresh the chat list based on URL parameters useEffect(() => { @@ -84,8 +89,10 @@ export default function Home() { window.addEventListener(EventEnum.CHAT, updateChatId); window.addEventListener(EventEnum.NEW_CHAT, cleanChatId); window.addEventListener(EventEnum.SETTING, updateSetting); + window.addEventListener(EventEnum.NEW_PROJECT, updateProject); return () => { window.removeEventListener(EventEnum.CHAT, updateChatId); + window.removeEventListener(EventEnum.NEW_PROJECT, updateProject); window.removeEventListener(EventEnum.NEW_CHAT, cleanChatId); window.removeEventListener(EventEnum.SETTING, updateSetting); }; @@ -126,21 +133,17 @@ export default function Home() { - {projectId ? ( + {curProject ? ( - - - + ) : ( -

Forgot to input project id

+ <> )} ); diff --git a/frontend/src/app/(main)/MainLayout.tsx b/frontend/src/app/(main)/MainLayout.tsx index 36ded8e1..f539eb98 100644 --- a/frontend/src/app/(main)/MainLayout.tsx +++ b/frontend/src/app/(main)/MainLayout.tsx @@ -10,16 +10,26 @@ import { import { SidebarProvider } from '@/components/ui/sidebar'; import { ChatSideBar } from '@/components/sidebar'; import { useChatList } from '../hooks/useChatList'; +import ProjectModal from '@/components/project-modal'; +import { GET_USER_PROJECTS } from '@/utils/requests'; +import { useQuery } from '@apollo/client'; +import { + ProjectContext, + ProjectProvider, +} from '@/components/code-engine/project-context'; export default function MainLayout({ children, }: { children: React.ReactNode; }) { + const [isModalOpen, setIsModalOpen] = useState(false); const [isCollapsed, setIsCollapsed] = useState(false); const [isMobile, setIsMobile] = useState(false); const defaultLayout = [25, 75]; // [sidebar, main] + const { data, refetch } = useQuery(GET_USER_PROJECTS); const navCollapsedSize = 5; + const { chats, loading, @@ -63,26 +73,34 @@ export default function MainLayout({ }} className="h-screen items-stretch w-full" > - - + + + setIsModalOpen(false)} + refetchProjects={refetch} + > + - - {children} - - + + {children} + + + ); diff --git a/frontend/src/app/api/project/route.ts b/frontend/src/app/api/project/route.ts index 07a4ad27..847fd7b5 100644 --- a/frontend/src/app/api/project/route.ts +++ b/frontend/src/app/api/project/route.ts @@ -4,7 +4,7 @@ import { FileReader } from '@/utils/file-reader'; export async function GET(req: Request) { const { searchParams } = new URL(req.url); - const projectId = searchParams.get('id'); + const projectId = searchParams.get('path'); if (!projectId) { return NextResponse.json({ error: 'Missing projectId' }, { status: 400 }); diff --git a/frontend/src/components/code-engine/code-engine.tsx b/frontend/src/components/code-engine/code-engine.tsx index 3cc62e3a..ab05d60b 100644 --- a/frontend/src/components/code-engine/code-engine.tsx +++ b/frontend/src/components/code-engine/code-engine.tsx @@ -3,12 +3,13 @@ import { Button } from '@/components/ui/button'; import Editor from '@monaco-editor/react'; import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; -import { motion } from 'framer-motion'; +import { motion, AnimatePresence } from 'framer-motion'; import { Code as CodeIcon, Copy, Eye, GitFork, + Loader, Share2, Terminal, } from 'lucide-react'; @@ -19,10 +20,10 @@ import FileExplorerButton from './file-explorer-button'; import FileStructure from './file-structure'; import { ProjectContext } from './project-context'; -export function CodeEngine() { +export function CodeEngine({ chatId }: { chatId: string }) { // Initialize state, refs, and context const editorRef = useRef(null); - const { projectId, filePath } = useContext(ProjectContext); + const { curProject, filePath, pollChatProject } = useContext(ProjectContext); const [preCode, setPrecode] = useState('// some comment'); const [newCode, setCode] = useState('// some comment'); const [saving, setSaving] = useState(false); @@ -33,6 +34,8 @@ export function CodeEngine() { Record> >({}); const theme = useTheme(); + + const [isProjectFinished, setIsProjectFinished] = useState(false); const [activeTab, setActiveTab] = useState<'preview' | 'code' | 'console'>( 'code' ); @@ -43,14 +46,36 @@ export function CodeEngine() { // Set the editor DOM node's position for layout control editorInstance.getDomNode().style.position = 'absolute'; }; + useEffect(() => { + async function checkChatProject() { + if (curProject?.id) { + const linkedProject = await pollChatProject(chatId); + console.log(linkedProject); + setIsProjectFinished(!!linkedProject); + // setIsProjectFinished(false); + // if ( + // chatId == '6730a482-2935-47f0-ad7a-43e7f17dc121' || + // chatId == '806b1498-65e0-4e4f-8724-42a8177610e0' + // ) { + // await new Promise((resolve) => setTimeout(resolve, 10000)); + // } + // setIsProjectFinished(true); + } + } + checkChatProject(); + }, [curProject, pollChatProject]); // Effect: Fetch file content when filePath or projectId changes useEffect(() => { async function getCode() { + const file_node = fileStructureData[`root/${filePath}`]; + if (filePath == '' || !file_node) return; + const isFolder = file_node.isFolder; + if (isFolder) return; try { setIsLoading(true); const res = await fetch( - `/api/file?path=${encodeURIComponent(`${projectId}/${filePath}`)}` + `/api/file?path=${encodeURIComponent(`${curProject.projectPath}/${filePath}`)}` ).then((res) => res.json()); setCode(res.content); setPrecode(res.content); @@ -61,13 +86,15 @@ export function CodeEngine() { } } getCode(); - }, [filePath, projectId]); + }, [filePath, curProject]); // Effect: Fetch file structure when projectId changes useEffect(() => { async function fetchFiles() { try { - const response = await fetch(`/api/project?id=${projectId}`); + const response = await fetch( + `/api/project?path=${curProject.projectPath}` + ); const data = await response.json(); setFileStructureData(data.res || {}); } catch (error) { @@ -75,7 +102,7 @@ export function CodeEngine() { } } fetchFiles(); - }, [projectId]); + }, [curProject]); // Reset code to previous state and update editor const handleReset = () => { @@ -92,7 +119,7 @@ export function CodeEngine() { credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - filePath: `${projectId}/${filePath}`, + filePath: `${curProject.projectPath}/${filePath}`, newContent: JSON.stringify(value), }), }); @@ -235,6 +262,20 @@ export function CodeEngine() { // Render the CodeEngine layout return (
+ + {!isProjectFinished && ( + + + + )} + {/* Header Bar */} diff --git a/frontend/src/components/code-engine/file-structure.tsx b/frontend/src/components/code-engine/file-structure.tsx index 776e52dc..a84d68fc 100644 --- a/frontend/src/components/code-engine/file-structure.tsx +++ b/frontend/src/components/code-engine/file-structure.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useContext } from 'react'; +import { useContext, useEffect, useState } from 'react'; import { StaticTreeDataProvider, Tree, @@ -25,10 +25,21 @@ export default function FileStructure({ }) { const { setFilePath } = useContext(ProjectContext); - const dataProvider = new StaticTreeDataProvider(data, (item, newName) => ({ - ...item, - data: newName, - })); + const [dataProvider, setDataprovider] = useState( + new StaticTreeDataProvider(data, (item, newName) => ({ + ...item, + data: newName, + })) + ); + useEffect(() => { + setDataprovider( + new StaticTreeDataProvider(data, (item, newName) => ({ + ...item, + data: newName, + })) + ); + }, [data]); + return (

File Explorer

diff --git a/frontend/src/components/code-engine/project-context.ts b/frontend/src/components/code-engine/project-context.ts deleted file mode 100644 index 5b8891d7..00000000 --- a/frontend/src/components/code-engine/project-context.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { createContext } from 'react'; - -export interface ProjectContextType { - projectId: string; - setProjectId: React.Dispatch>; - filePath: string | null; - setFilePath: React.Dispatch>; -} - -export const ProjectContext = createContext({ - projectId: '', - setProjectId: () => {}, - filePath: null, - setFilePath: () => {}, -}); diff --git a/frontend/src/components/code-engine/project-context.tsx b/frontend/src/components/code-engine/project-context.tsx new file mode 100644 index 00000000..5a15642a --- /dev/null +++ b/frontend/src/components/code-engine/project-context.tsx @@ -0,0 +1,133 @@ +import React, { + createContext, + useState, + ReactNode, + useCallback, + useMemo, +} from 'react'; +import { useLazyQuery, useMutation, useQuery } from '@apollo/client'; +import { CREATE_PROJECT, GET_CHAT_DETAILS } from '@/graphql/request'; +import { Project } from '../project-modal'; +import { GET_USER_PROJECTS } from '@/utils/requests'; +import { useAuth } from '@/app/hooks/useAuth'; + +export interface ProjectContextType { + projects: Project[]; + setProjects: React.Dispatch>; + curProject: Project | undefined; + setCurProject: React.Dispatch>; + filePath: string | null; + setFilePath: React.Dispatch>; + createNewProject: (projectName: string, description: string) => Promise; + pollChatProject: (chatId: string) => Promise; +} + +export const ProjectContext = createContext( + undefined +); + +export function ProjectProvider({ children }: { children: ReactNode }) { + const { validateToken } = useAuth(); + const [projects, setProjects] = useState([]); + const [curProject, setCurProject] = useState(undefined); + const [filePath, setFilePath] = useState(null); + const [chatProjectCache, setChatProjectCache] = useState< + Map + >(new Map()); + const MAX_RETRIES = 20; + + useQuery(GET_USER_PROJECTS, { + onCompleted: (data) => setProjects(data.getUserProjects), + onError: (error) => console.error('Error fetching projects:', error), + }); + + const [createProject] = useMutation(CREATE_PROJECT, { + onCompleted: (data) => { + setProjects((prev) => + prev.some((p) => p.id === data.createProject.id) + ? prev + : [...prev, data.createProject] + ); + }, + onError: (error) => alert(error), + }); + + const [getChatDetail] = useLazyQuery(GET_CHAT_DETAILS, { + fetchPolicy: 'network-only', + context: { Authorization: `Bearer ${validateToken}` }, + }); + + const createNewProject = useCallback( + async (projectName: string, description: string) => { + if (!projectName || !description) + return alert('Please fill in all fields!'); + try { + createProject({ + variables: { + createProjectInput: { + projectName, + description, + databaseType: 'MySQL', + packages: [], + }, + }, + }); + } catch (err) { + console.error('Failed to create project:', err); + } + }, + [createProject] + ); + + const pollChatProject = useCallback( + async (chatId: string): Promise => { + if (chatProjectCache.has(chatId)) { + return chatProjectCache.get(chatId) || null; + } + + let retries = 0; + while (retries < MAX_RETRIES) { + try { + console.log('testing ' + chatId); + const { data } = await getChatDetail({ variables: { chatId } }); + + if (data?.getChatDetails?.project) { + setChatProjectCache((prev) => + new Map(prev).set(chatId, data.getChatDetails.project) + ); + return data.getChatDetails.project; + } + } catch (error) { + console.error('Error polling chat:', error); + } + + await new Promise((resolve) => setTimeout(resolve, 5000)); + retries++; + } + + setChatProjectCache((prev) => new Map(prev).set(chatId, null)); + return null; + }, + [getChatDetail, chatProjectCache] + ); + + const contextValue = useMemo( + () => ({ + projects, + setProjects, + curProject, + setCurProject, + filePath, + setFilePath, + createNewProject, + pollChatProject, + }), + [projects, curProject, filePath, createNewProject, pollChatProject] + ); + + return ( + + {children} + + ); +} diff --git a/frontend/src/components/enum.ts b/frontend/src/components/enum.ts index f59e6629..bcf26e75 100644 --- a/frontend/src/components/enum.ts +++ b/frontend/src/components/enum.ts @@ -2,4 +2,5 @@ export enum EventEnum { NEW_CHAT = 'newchat', // event name 'newchat', help to monitor the change of url SETTING = 'setting', CHAT = 'chat', + NEW_PROJECT = 'project', } diff --git a/frontend/src/components/project-modal.tsx b/frontend/src/components/project-modal.tsx new file mode 100644 index 00000000..75dbcc68 --- /dev/null +++ b/frontend/src/components/project-modal.tsx @@ -0,0 +1,73 @@ +import React, { useContext, useState } from 'react'; +import { useMutation } from '@apollo/client'; +import { gql } from '@apollo/client'; +import { CREATE_PROJECT } from '@/graphql/request'; +import { ProjectContext, ProjectProvider } from './code-engine/project-context'; + +export interface Project { + id: string; + projectName: string; + projectPath: string; + createdAt: number; + updatedAt: number; + isActive: boolean; + isDeleted: boolean; + userId: string; + projectPackages: ProjectPackage[]; +} + +interface ProjectPackage { + name: string; + version: string; +} + +const ProjectModal = ({ isOpen, onClose, refetchProjects }) => { + const [projectName, setProjectName] = useState(''); + const [description, setDescription] = useState(''); + const { createNewProject } = useContext(ProjectContext); + if (!isOpen) return null; // Don't render the modal when it's closed + + return ( +
+
+

Create New Project

+
createNewProject(projectName, description)}> +
+ + setProjectName(e.target.value)} + /> +
+
+ +