diff --git a/backend/src/chat/chat.controller.ts b/backend/src/chat/chat.controller.ts index c1a5e6ef..e9e67ee1 100644 --- a/backend/src/chat/chat.controller.ts +++ b/backend/src/chat/chat.controller.ts @@ -22,13 +22,6 @@ export class ChatController { @GetAuthToken() userId: string, ) { try { - // Save user's message first - await this.chatService.saveMessage( - chatDto.chatId, - chatDto.message, - MessageRole.User, - ); - if (chatDto.stream) { // Streaming response res.setHeader('Content-Type', 'text/event-stream'); @@ -39,6 +32,7 @@ export class ChatController { chatId: chatDto.chatId, message: chatDto.message, model: chatDto.model, + role: MessageRole.User, }); let fullResponse = ''; @@ -51,13 +45,6 @@ export class ChatController { } } - // Save the complete message - await this.chatService.saveMessage( - chatDto.chatId, - fullResponse, - MessageRole.Assistant, - ); - res.write('data: [DONE]\n\n'); res.end(); } else { @@ -66,15 +53,8 @@ export class ChatController { chatId: chatDto.chatId, message: chatDto.message, model: chatDto.model, + role: MessageRole.User, }); - - // Save the complete message - await this.chatService.saveMessage( - chatDto.chatId, - response, - MessageRole.Assistant, - ); - res.json({ content: response }); } } catch (error) { diff --git a/backend/src/chat/chat.model.ts b/backend/src/chat/chat.model.ts index d734ac5c..4ecac50c 100644 --- a/backend/src/chat/chat.model.ts +++ b/backend/src/chat/chat.model.ts @@ -75,11 +75,11 @@ class ChatCompletionDelta { @ObjectType('ChatCompletionChoiceType') class ChatCompletionChoice { - @Field() - index: number; + @Field({ nullable: true }) + index: number | null; - @Field(() => ChatCompletionDelta) - delta: ChatCompletionDelta; + @Field(() => ChatCompletionDelta, { nullable: true }) + delta: ChatCompletionDelta | null; @Field({ nullable: true }) finishReason: string | null; @@ -90,14 +90,14 @@ export class ChatCompletionChunk { @Field() id: string; - @Field() - object: string; + @Field({ nullable: true }) + object: string | null; - @Field() - created: number; + @Field({ nullable: true }) + created: number | null; - @Field() - model: string; + @Field({ nullable: true }) + model: string | null; @Field({ nullable: true }) systemFingerprint: string | null; diff --git a/backend/src/chat/chat.resolver.ts b/backend/src/chat/chat.resolver.ts index 991ad6f7..d26526e5 100644 --- a/backend/src/chat/chat.resolver.ts +++ b/backend/src/chat/chat.resolver.ts @@ -1,8 +1,8 @@ import { Resolver, Subscription, Args, Query, Mutation } from '@nestjs/graphql'; -import { Chat, ChatCompletionChunk } from './chat.model'; +import { Chat, ChatCompletionChunk, StreamStatus } from './chat.model'; import { ChatProxyService, ChatService } from './chat.service'; import { UserService } from 'src/user/user.service'; -import { Message, MessageRole } from './message.model'; +import { Message } from './message.model'; import { ChatInput, NewChatInput, @@ -12,6 +12,7 @@ import { GetUserIdFromToken } from 'src/decorator/get-auth-token.decorator'; import { Inject, Logger } from '@nestjs/common'; import { JWTAuth } from 'src/decorator/jwt-auth.decorator'; import { PubSubEngine } from 'graphql-subscriptions'; +import { Project } from 'src/project/project.model'; @Resolver('Chat') export class ChatResolver { private readonly logger = new Logger('ChatResolver'); @@ -31,45 +32,65 @@ export class ChatResolver { resolve: (payload) => payload.chatStream, }) async chatStream(@Args('input') input: ChatInput) { - return this.pubSub.asyncIterator(`chat_stream_${input.chatId}`); + const asyncIterator = this.pubSub.asyncIterator( + `chat_stream_${input.chatId}`, + ); + return asyncIterator; } - @Mutation(() => Boolean) @JWTAuth() - async triggerChatStream(@Args('input') input: ChatInput): Promise { + async saveMessage(@Args('input') input: ChatInput): Promise { try { await this.chatService.saveMessage( input.chatId, input.message, - MessageRole.User, + input.role, ); - + return true; + } catch (error) { + this.logger.error('Error in saveMessage:', error); + throw error; + } + } + @Mutation(() => Boolean) + @JWTAuth() + async triggerChatStream(@Args('input') input: ChatInput): Promise { + try { const iterator = this.chatProxyService.streamChat(input); let accumulatedContent = ''; - for await (const chunk of iterator) { - if (chunk) { - const enhancedChunk = { - ...chunk, - chatId: input.chatId, - }; + try { + for await (const chunk of iterator) { + console.log('received chunk:', chunk); + if (chunk) { + const enhancedChunk = { + ...chunk, + chatId: input.chatId, + }; + + await this.pubSub.publish(`chat_stream_${input.chatId}`, { + chatStream: enhancedChunk, + }); + + if (chunk.choices?.[0]?.delta?.content) { + accumulatedContent += chunk.choices[0].delta.content; + } + } + } + } finally { + const finalChunk = await iterator.return(); + console.log('finalChunk:', finalChunk); + if (finalChunk.value?.status === StreamStatus.DONE) { await this.pubSub.publish(`chat_stream_${input.chatId}`, { - chatStream: enhancedChunk, + chatStream: { + ...finalChunk.value, + chatId: input.chatId, + }, }); - - if (chunk.choices[0]?.delta?.content) { - accumulatedContent += chunk.choices[0].delta.content; - } } } - await this.chatService.saveMessage( - input.chatId, - accumulatedContent, - MessageRole.Assistant, - ); - return true; } catch (error) { this.logger.error('Error in triggerChatStream:', error); @@ -108,6 +129,19 @@ export class ChatResolver { return this.chatService.getChatDetails(chatId); } + @JWTAuth() + @Query(() => Project, { nullable: true }) + async getCurProject(@Args('chatId') chatId: string): Promise { + try { + const response = await this.chatService.getProjectByChatId(chatId); + this.logger.log('Loaded project:', response); + return response; + } catch (error) { + this.logger.error('Failed to fetch project:', error); + throw new Error('Failed to fetch project'); + } + } + @Mutation(() => Chat) @JWTAuth() async createChat( diff --git a/backend/src/chat/chat.service.ts b/backend/src/chat/chat.service.ts index 366b4e20..5078f1a7 100644 --- a/backend/src/chat/chat.service.ts +++ b/backend/src/chat/chat.service.ts @@ -11,6 +11,7 @@ import { } from 'src/chat/dto/chat.input'; import { CustomAsyncIterableIterator } from 'src/common/model-provider/types'; import { OpenAIModelProvider } from 'src/common/model-provider/openai-model-provider'; +import { Project } from 'src/project/project.model'; @Injectable() export class ChatProxyService { @@ -98,6 +99,15 @@ export class ChatService { return chat; } + async getProjectByChatId(chatId: string): Promise { + const chat = await this.chatRepository.findOne({ + where: { id: chatId, isDeleted: false }, + relations: ['project'], + }); + + return chat ? chat.project : null; + } + async createChat(userId: string, newChatInput: NewChatInput): Promise { const user = await this.userRepository.findOne({ where: { id: userId } }); if (!user) { diff --git a/backend/src/chat/dto/chat.input.ts b/backend/src/chat/dto/chat.input.ts index feeb738c..617bc23f 100644 --- a/backend/src/chat/dto/chat.input.ts +++ b/backend/src/chat/dto/chat.input.ts @@ -1,5 +1,6 @@ // DTOs for Project APIs import { InputType, Field } from '@nestjs/graphql'; +import { MessageRole } from '../message.model'; @InputType() export class NewChatInput { @@ -26,4 +27,6 @@ export class ChatInput { @Field() model: string; + @Field() + role: MessageRole; } diff --git a/backend/src/common/model-provider/openai-model-provider.ts b/backend/src/common/model-provider/openai-model-provider.ts index f5991cc6..1e92dbaf 100644 --- a/backend/src/common/model-provider/openai-model-provider.ts +++ b/backend/src/common/model-provider/openai-model-provider.ts @@ -118,7 +118,7 @@ export class OpenAIModelProvider implements IModelProvider { let streamIterator: AsyncIterator | null = null; const modelName = model || input.model; const queue = this.getQueueForModel(modelName); - + let oldStreamValue: OpenAIChatCompletionChunk | null = null; const createStream = async () => { if (!stream) { const result = await queue.add(async () => { @@ -145,6 +145,9 @@ export class OpenAIModelProvider implements IModelProvider { const currentIterator = await createStream(); const chunk = await currentIterator.next(); const chunkValue = chunk.value as OpenAIChatCompletionChunk; + console.log('isDone:', chunk.done); + console.log('chunk:', chunk); + if (!chunk.done) oldStreamValue = chunkValue; return { done: chunk.done, value: { @@ -159,9 +162,23 @@ export class OpenAIModelProvider implements IModelProvider { } }, async return() { + console.log(stream); + console.log(streamIterator); + console.log('return() called'); stream = null; streamIterator = null; - return { done: true, value: undefined }; + return { + done: true, + value: { + ...oldStreamValue, + status: StreamStatus.DONE, + choices: [ + { + finishReason: 'stop', + }, + ], + }, + }; }, async throw(error) { stream = null; diff --git a/frontend/src/api/ChatStreamAPI.ts b/frontend/src/api/ChatStreamAPI.ts new file mode 100644 index 00000000..1caff4c9 --- /dev/null +++ b/frontend/src/api/ChatStreamAPI.ts @@ -0,0 +1,71 @@ +import { ChatInputType } from '@/graphql/type'; + +export const startChatStream = async ( + input: ChatInputType, + token: string, + stream: boolean = false // Default to non-streaming for better performance +): Promise => { + if (!token) { + throw new Error('Not authenticated'); + } + const { chatId, message, model } = input; + const response = await fetch('/api/chat', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + chatId, + message, + model, + stream, + }), + }); + + if (!response.ok) { + throw new Error( + `Network response was not ok: ${response.status} ${response.statusText}` + ); + } + // TODO: Handle streaming responses properly + // if (stream) { + // // For streaming responses, aggregate the streamed content + // let fullContent = ''; + // const reader = response.body?.getReader(); + // if (!reader) { + // throw new Error('No reader available'); + // } + + // while (true) { + // const { done, value } = await reader.read(); + // if (done) break; + + // const text = new TextDecoder().decode(value); + // const lines = text.split('\n\n'); + + // for (const line of lines) { + // if (line.startsWith('data: ')) { + // const data = line.slice(5); + // if (data === '[DONE]') break; + // try { + // const { content } = JSON.parse(data); + // if (content) { + // fullContent += content; + // } + // } catch (e) { + // console.error('Error parsing SSE data:', e); + // } + // } + // } + // } + // return fullContent; + // } else { + // // For non-streaming responses, return the content directly + // const data = await response.json(); + // return data.content; + // } + + const data = await response.json(); + return data.content; +}; diff --git a/frontend/src/app/api/filestructure/route.ts b/frontend/src/app/api/filestructure/route.ts new file mode 100644 index 00000000..49c23a72 --- /dev/null +++ b/frontend/src/app/api/filestructure/route.ts @@ -0,0 +1,31 @@ +// app/api/filestructure/route.ts +import { NextResponse } from 'next/server'; +import { FileReader } from '@/utils/file-reader'; + +export async function GET(req: Request) { + const { searchParams } = new URL(req.url); + const projectId = searchParams.get('path'); + + if (!projectId) { + return NextResponse.json({ error: 'Missing projectId' }, { status: 400 }); + } + + try { + const res = await fetchFileStructure(projectId); + return NextResponse.json({ res }); + } catch (error) { + return NextResponse.json( + { error: 'Failed to read project files' }, + { status: 500 } + ); + } +} + +async function fetchFileStructure(projectId) { + const reader = FileReader.getInstance(); + const res = await reader.getAllPaths(projectId); + if (!res || res.length === 0) { + return ''; + } + return res; +} diff --git a/frontend/src/components/chat/code-engine/code-engine.tsx b/frontend/src/components/chat/code-engine/code-engine.tsx index 8ac6da93..03bc65de 100644 --- a/frontend/src/components/chat/code-engine/code-engine.tsx +++ b/frontend/src/components/chat/code-engine/code-engine.tsx @@ -20,7 +20,7 @@ export function CodeEngine({ isProjectReady?: boolean; projectId?: string; }) { - const { curProject, projectLoading, pollChatProject } = + const { curProject, projectLoading, pollChatProject, editorRef } = useContext(ProjectContext); const [localProject, setLocalProject] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -35,8 +35,6 @@ export function CodeEngine({ const [fileStructureData, setFileStructureData] = useState< Record> >({}); - - const editorRef = useRef(null); const projectPathRef = useRef(null); // Poll for project if needed using chatId @@ -198,7 +196,7 @@ export function CodeEngine({ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filePath: `${projectPath}/${filePath}`, - newContent: JSON.stringify(value), + newContent: value, }), }); diff --git a/frontend/src/components/chat/code-engine/project-context.tsx b/frontend/src/components/chat/code-engine/project-context.tsx index dbc6d25e..f1bcbf2f 100644 --- a/frontend/src/components/chat/code-engine/project-context.tsx +++ b/frontend/src/components/chat/code-engine/project-context.tsx @@ -49,6 +49,7 @@ export interface ProjectContextType { ) => Promise<{ domain: string; containerId: string }>; takeProjectScreenshot: (projectId: string, url: string) => Promise; refreshProjects: () => Promise; + editorRef?: React.MutableRefObject; } export const ProjectContext = createContext( @@ -103,6 +104,7 @@ export function ProjectProvider({ children }: { children: ReactNode }) { const [projectLoading, setProjectLoading] = useState(true); const [filePath, setFilePath] = useState(null); const [isLoading, setIsLoading] = useState(false); + const editorRef = useRef(null); interface ChatProjectCacheEntry { project: Project | null; @@ -325,7 +327,7 @@ export function ProjectProvider({ children }: { children: ReactNode }) { onCompleted: (data) => { if (!isMounted.current) return; - setProjects(data.getUserProjects); + setProjects([...data.getUserProjects]); // Trigger state sync after data update const now = Date.now(); @@ -911,6 +913,7 @@ export function ProjectProvider({ children }: { children: ReactNode }) { getWebUrl, takeProjectScreenshot, refreshProjects, + editorRef, }), [ projects, @@ -926,6 +929,7 @@ export function ProjectProvider({ children }: { children: ReactNode }) { getWebUrl, takeProjectScreenshot, refreshProjects, + editorRef, ] ); diff --git a/frontend/src/graphql/request.ts b/frontend/src/graphql/request.ts index e794e7dd..c6f82297 100644 --- a/frontend/src/graphql/request.ts +++ b/frontend/src/graphql/request.ts @@ -173,7 +173,20 @@ export const GET_CHAT_DETAILS = gql` } } `; - +export const GET_CUR_PROJECT = gql` + query GetCurProject($chatId: String!) { + getCurProject(chatId: $chatId) { + id + projectName + projectPath + } + } +`; +export const SAVE_MESSAGE = gql` + mutation SaveMessage($input: ChatInputType!) { + saveMessage(input: $input) + } +`; // Mutation to create a new project export const CREATE_PROJECT = gql` mutation CreateProject($createProjectInput: CreateProjectInput!) { @@ -221,7 +234,11 @@ export const UPDATE_PROJECT_PUBLIC_STATUS = gql` updateProjectPublicStatus(projectId: $projectId, isPublic: $isPublic) { id projectName - isPublic + path + projectPackages { + id + content + } } } `; diff --git a/frontend/src/graphql/schema.gql b/frontend/src/graphql/schema.gql index 6c419a01..28ee099d 100644 --- a/frontend/src/graphql/schema.gql +++ b/frontend/src/graphql/schema.gql @@ -21,17 +21,17 @@ type Chat { } type ChatCompletionChoiceType { - delta: ChatCompletionDeltaType! + delta: ChatCompletionDeltaType finishReason: String - index: Float! + index: Float } type ChatCompletionChunkType { choices: [ChatCompletionChoiceType!]! - created: Float! + created: Float id: String! - model: String! - object: String! + model: String + object: String status: StreamStatus! systemFingerprint: String } @@ -44,6 +44,7 @@ input ChatInputType { chatId: String! message: String! model: String! + role: String! } input CheckTokenInput { @@ -122,6 +123,7 @@ type Mutation { regenerateDescription(input: String!): String! registerUser(input: RegisterUserInput!): User! resendConfirmationEmail(input: ResendEmailInput!): EmailConfirmationResponse! + saveMessage(input: ChatInputType!): Boolean! subscribeToProject(projectId: ID!): Project! triggerChatStream(input: ChatInputType!): Boolean! updateChatTitle(updateChatTitleInput: UpdateChatTitleInput!): Chat @@ -180,6 +182,7 @@ type Query { getAvailableModelTags: [String!] getChatDetails(chatId: String!): Chat getChatHistory(chatId: String!): [Message!]! + getCurProject(chatId: String!): Project getHello: String! getProject(projectId: String!): Project! getRemainingProjectLimit: Int! @@ -198,6 +201,7 @@ type RefreshTokenResponse { } input RegisterUserInput { + confirmPassword: String! email: String! password: String! username: String! diff --git a/frontend/src/graphql/type.tsx b/frontend/src/graphql/type.tsx index 3def174b..edcc54d4 100644 --- a/frontend/src/graphql/type.tsx +++ b/frontend/src/graphql/type.tsx @@ -62,18 +62,18 @@ export type Chat = { export type ChatCompletionChoiceType = { __typename: 'ChatCompletionChoiceType'; - delta: ChatCompletionDeltaType; + delta?: Maybe; finishReason?: Maybe; - index: Scalars['Float']['output']; + index?: Maybe; }; export type ChatCompletionChunkType = { __typename: 'ChatCompletionChunkType'; choices: Array; - created: Scalars['Float']['output']; + created?: Maybe; id: Scalars['String']['output']; - model: Scalars['String']['output']; - object: Scalars['String']['output']; + model?: Maybe; + object?: Maybe; status: StreamStatus; systemFingerprint?: Maybe; }; @@ -87,6 +87,7 @@ export type ChatInputType = { chatId: Scalars['String']['input']; message: Scalars['String']['input']; model: Scalars['String']['input']; + role: Scalars['String']['input']; }; export type CheckTokenInput = { @@ -167,6 +168,7 @@ export type Mutation = { regenerateDescription: Scalars['String']['output']; registerUser: User; resendConfirmationEmail: EmailConfirmationResponse; + saveMessage: Scalars['Boolean']['output']; subscribeToProject: Project; triggerChatStream: Scalars['Boolean']['output']; updateChatTitle?: Maybe; @@ -223,6 +225,10 @@ export type MutationResendConfirmationEmailArgs = { input: ResendEmailInput; }; +export type MutationSaveMessageArgs = { + input: ChatInputType; +}; + export type MutationSubscribeToProjectArgs = { projectId: Scalars['ID']['input']; }; @@ -300,6 +306,7 @@ export type Query = { getAvailableModelTags?: Maybe>; getChatDetails?: Maybe; getChatHistory: Array; + getCurProject?: Maybe; getHello: Scalars['String']['output']; getProject: Project; getRemainingProjectLimit: Scalars['Int']['output']; @@ -328,6 +335,10 @@ export type QueryGetChatHistoryArgs = { chatId: Scalars['String']['input']; }; +export type QueryGetCurProjectArgs = { + chatId: Scalars['String']['input']; +}; + export type QueryGetProjectArgs = { projectId: Scalars['String']['input']; }; @@ -347,6 +358,7 @@ export type RefreshTokenResponse = { }; export type RegisterUserInput = { + confirmPassword: Scalars['String']['input']; email: Scalars['String']['input']; password: Scalars['String']['input']; username: Scalars['String']['input']; @@ -623,7 +635,7 @@ export type ChatCompletionChoiceTypeResolvers< ResolversParentTypes['ChatCompletionChoiceType'] = ResolversParentTypes['ChatCompletionChoiceType'], > = ResolversObject<{ delta?: Resolver< - ResolversTypes['ChatCompletionDeltaType'], + Maybe, ParentType, ContextType >; @@ -632,7 +644,7 @@ export type ChatCompletionChoiceTypeResolvers< ParentType, ContextType >; - index?: Resolver; + index?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; }>; @@ -646,10 +658,10 @@ export type ChatCompletionChunkTypeResolvers< ParentType, ContextType >; - created?: Resolver; + created?: Resolver, ParentType, ContextType>; id?: Resolver; - model?: Resolver; - object?: Resolver; + model?: Resolver, ParentType, ContextType>; + object?: Resolver, ParentType, ContextType>; status?: Resolver; systemFingerprint?: Resolver< Maybe, @@ -802,6 +814,12 @@ export type MutationResolvers< ContextType, RequireFields >; + saveMessage?: Resolver< + ResolversTypes['Boolean'], + ParentType, + ContextType, + RequireFields + >; subscribeToProject?: Resolver< ResolversTypes['Project'], ParentType, @@ -940,6 +958,12 @@ export type QueryResolvers< ContextType, RequireFields >; + getCurProject?: Resolver< + Maybe, + ParentType, + ContextType, + RequireFields + >; getHello?: Resolver; getProject?: Resolver< ResolversTypes['Project'], diff --git a/frontend/src/hooks/multi-agent/agentPrompt.ts b/frontend/src/hooks/multi-agent/agentPrompt.ts new file mode 100644 index 00000000..25c4a2a7 --- /dev/null +++ b/frontend/src/hooks/multi-agent/agentPrompt.ts @@ -0,0 +1,473 @@ +import { Message } from '@/const/MessageType'; +import { getToolUsageMap } from './toolNodes'; +export enum TaskType { + DEBUG = 'debug', + REFACTOR = 'refactor', + OPTIMIZE = 'optimize', + UNRELATED = 'unrelated', +} +export interface AgentContext { + task_type: TaskType | null; // Current task type + request: string; // Original request + projectPath: string; // Project ID + fileStructure: string[]; // Project file structure + fileContents: { + // File content mapping + [key: string]: string; + }; + modifiedFiles: { + // Modified files + [key: string]: string; + }; + requiredFiles: string[]; // Files that need to be read/modified + reviewComments?: string[]; // Code review comments + commitMessage?: string; // Commit message + accumulatedThoughts: string[]; // Accumulated thinking processes + setFilePath: (path: string) => void; // Set current file path in editor + editorRef: React.MutableRefObject; // Monaco editor reference for direct updates + currentStep?: { + // Current execution step + tool?: string; // Tool being used + status?: string; // Execution status + description?: string; // Step description + }; + setMessages: React.Dispatch>; + saveMessage: any; + token?: string; +} +export const systemPrompt = (): string => { + return `# System Instructions +You are an AI assistant. When responding, you **must** adhere to the following rules: + +1. **Strictly Follow Output Format** + - Every response **must be wrapped inside** an XML element named \`\`. + - The content inside \`\` must be **a valid JSON object**. + - The JSON structure must exactly match the expected format. + +2. **Do Not Add Extra Information** + - **Do not return explanations**, introductory text, or markdown formatting. + - **Do not return JSON outside of the wrapper.** + +3. **Example of Expected Output Format** +{ + "files": ["frontend/src/hooks/useChatStream.ts", "frontend/src/components/ChatInput.tsx"], + "thinking_process": "The bug description suggests an issue with state updates in the chat stream. Since 'useChatStream.ts' handles chat messages, it is the primary suspect, along with 'ChatInput.tsx', which interacts with the message state." +} + - The JSON must contain **only the relevant fields**. + - The **"thinking_process"** field must provide an analysis of why the chosen files are relevant. + +4. **If Asked to Modify Code** + - The output must be formatted as follows: + - IMPORTANT: Code content must be RAW, not JSON-encoded strings! +{ + "modified_files": { + "frontend/src/hooks/useChatStream.ts": "import React from 'react';\n\nexport function Component() {\n return
Content
;\n}", + "frontend/src/components/ChatInput.tsx": "import { useState } from 'react';\n\nexport function Input() {\n // Code content...\n}" + }, + "thinking_process": "After reviewing the bug description, I identified that the issue is caused by an incomplete dependency array in useEffect, preventing state updates. I have modified the code accordingly." +}
+ - The **"modified_files"** field must contain a dictionary where the keys are file paths and the values are the updated code content. + +5. **If Asked to Commit Changes** + - The response must follow this format: +{ + "commit_message": "Fix issue where messages were not being saved due to missing state update in useChatStream.ts", + "thinking_process": "The primary fix addresses a missing state update in useChatStream.ts, which was preventing messages from being saved. The commit message reflects this fix concisely." +} + - The **"commit_message"** must be clear and descriptive. + - The **"thinking_process"** must explain the reasoning behind the commit message. + +# **IMPORTANT1:** +Failure to strictly follow these rules will result in an invalid response. **Your response must be a well-formed JSON object wrapped inside \`\` with the required fields.**\ +PLEASE DO NOT RETURN RAW JSON OBJECTS WITHOUT WRAPPING THEM IN \`\` like \`\`\`json +{xxxxx} +\`\`\` THIS IS PROHIBITED!!!!!!!!!. +# **IMPORTANT2:** +ONLY IF U NEED TO READ FILES OR ANY RESPONSE RELATED TO PROJECT PATH: Do not contain project id like that:"files": [ + "2025-03-07-19-42-56-518-d6b7469c-1269-4380-973d-fcd62d55b787/backend/index.js", + "2025-03-07-19-42-56-518-d6b7469c-1269-4380-973d-fcd62d55b787/backend/schema.sql", + "2025-03-07-19-42-56-518-d6b7469c-1269-4380-973d-fcd62d55b787/frontend/src/index.tsx" + ], + please return files you need like "files": [ + "backend/index.js", + "backend/schema.sql", + "frontend/src/index.tsx" + ], +`; +}; + +export const leaderPrompt = (message: string): string => { + return ( + systemPrompt() + + `You are a professional requirements analyst responsible for analyzing a mafia user's requirements for a CS project. If you fail to conduct a proper analysis, they will take action against your family. + +The user's request is as follows: +${message} + +Please categorize the user's task into one of the following types: + +debug: Fixing errors in the code (such as compilation errors, runtime errors, or type errors). +optimize: Improving the performance, structure, or readability of the code (without fixing a bug). +unrelated: If the request is unrelated to code or the CS project. + +After the analysis is complete, strictly follow the mafia's instructions and return the JSON structure wrapped inside a single XML element. The XML element must be named , and its content should be a valid JSON object. + +When generating the JSON response, **ensure that the description in JSON is as detailed as possible** by specifying: +1. **The affected module, file, or function** +2. **The operation the user wants to perform** +3. **The specific issue or error they encountered** +4. **How they expect the problem to be resolved** + +### **AI Thought Process:** +First, I will determine if the request is code-related by checking: + +1. Is this about development/programming? + - Contains technical terms (function, code, error, component, etc.) + - Mentions file paths or code elements + - Discusses software functionality + +2. Is this a general question? + - About weather, time, or general knowledge + - Personal inquiries or casual conversation + - Questions unrelated to development + +For code-related tasks: +- If about errors/bugs -> DEBUG +- If about improvements -> OPTIMIZE +- If about code structure -> REFACTOR + +For non-code tasks: +- Mark as UNRELATED +- Explain why it's not a development task +- Suggest using a general assistant instead + +### **Output Format** +{ + "task_type": "debug" | "optimize" | "unrelated", + "description": "Examples:\n\n1. Code task: In 'src/project/utils.ts', the user encountered a TypeScript module error that needs debugging.\n\n2. Unrelated task: The user asked about weather in Madison. This is not a code or development related question, but rather a general weather inquiry that should be handled by a general assistant.", + "thinking_process": "After analyzing the request, I identified that the issue involves a missing module import, which prevents compilation. This falls under debugging since it requires fixing a dependency resolution problem." +} + +Otherwise, I cannot guarantee the safety of your family. :(` + ); +}; +export const refactorPrompt = (message: string, file_structure: string[]) => { + return ( + systemPrompt() + + `You are a seasoned software engineer known for your expertise in code refactoring. Your mission is to refactor the codebase to improve its structure, readability, and maintainability. + and also provide a detailed refactoring description. +--- +### **Mission Objective:** +- The user has requested a **Code Refactor** and provided a description along with the project's file structure. +- Your task is to analyze the code and provide a detailed refactoring plan. + +--- + +### **User-Provided Information:** +- **Refactor Description:** + ${message} +Project File Structure: +${file_structure} + +Task Requirements: +Identify the parts of the code that need refactoring. +Provide a detailed plan on how to refactor the code. +Ensure that the refactored code is more efficient, readable, and maintainable. + +### **AI Thought Process:** +To determine how best to refactor the code, I will analyze the request to identify **redundant logic, overly complex structures, or repetitive code** that should be modularized. +If multiple components are using similar logic, I will suggest extracting that logic into **utility functions or custom hooks**. If a function or component is too large, I will propose **breaking it down into smaller, reusable parts**. +Finally, I will ensure that the refactoring plan maintains the same functionality while improving readability and maintainability. + +### **Output Format** +{ + "files": ["frontend/src/hooks/useChatStream.ts", "frontend/src/utils/chatUtils.ts"], + "description": "The user requested refactoring of message handling logic. Since chat state management is handled in 'useChatStream.ts', I suggest extracting reusable logic into a new utility file, 'chatUtils.ts'.", + "thinking_process": "The user requested refactoring of message handling logic. Since chat state management is handled in 'useChatStream.ts', I suggest extracting reusable logic into a new utility file, 'chatUtils.ts'." +} + +Failure is Not an Option!` + ); +}; +export const optimizePrompt = (message: string, file_structure: string[]) => { + return ( + systemPrompt() + + `You are a code performance optimization expert. Your mission is to analyze the codebase and identify performance bottlenecks. + and also provide a detailed optimization desciption. +--- + +### **Mission Objective:** +- The user has requested a **Code Optimization** and provided a description along with the project's file structure. +- Your task is to analyze the code and provide a detailed optimization plan. + +--- + +### **User-Provided Information:** +- **Optimization Description:** + ${message} +- **Project File Structure:** + ${file_structure} + +### **AI Thought Process:** +To optimize the code effectively, I will first analyze the provided description to identify **potential performance bottlenecks**. +I will look for keywords related to **unnecessary re-renders, expensive computations, API inefficiencies, or memory leaks**. +If the issue involves **React components**, I will check for optimizations using **React.memo, useMemo, or useCallback** to reduce re-renders. +If the issue is related to **API calls**, I will explore caching strategies, **batching requests, or reducing redundant fetch calls**. +Once I determine the likely causes of inefficiency, I will propose specific solutions for optimizing the affected areas. + +--- + +### **Output Format** +{ + "files": ["frontend/src/components/index.tsx", "frontend/src/utils/chat.ts"], + "description" : "The user's optimization request suggests that the ChatList component is re-rendering too frequently. Since ChatList.tsx is responsible for displaying messages, applying React.memo to prevent unnecessary updates will likely improve performance. Additionally, optimizing state updates in useChatStream.ts will help reduce redundant renders.", + "thinking_process": "The user's optimization request suggests that the ChatList component is re-rendering too frequently. Since ChatList.tsx is responsible for displaying messages, applying React.memo to prevent unnecessary updates will likely improve performance. Additionally, optimizing state updates in useChatStream.ts will help reduce redundant renders." +} + +Failure is Not an Option! If you fail, the codebase will remain inefficient and slow.` + ); +}; +export const editFilePrompt = ( + description: string, + file_content: { [key: string]: string } +) => { + return ( + systemPrompt() + + `You are a senior software engineer with extensive experience in React and TypeScript. Your mission is to edit the code files while preserving all existing functionality and structure. + +--- + +### **Mission Objective:** +- The user has provided a description of the issue and the relevant code files. +- Your task is to edit the code files to fix the issue while maintaining all existing code functionality. +- You must preserve all existing imports, components, functions, and features unless explicitly told to remove them. +- Any modifications should be surgical and focused only on the specific issue being fixed. + +--- + +### **User-Provided Information:** +- **Description:** + ${description} +- **Code Content:** + ${JSON.stringify(file_content, null, 2)} + +### **AI Thought Process:** +1. First, I will carefully analyze the provided description to understand **the specific issue that needs to be fixed**. +2. I will examine the code files while noting all existing: + - Imports and dependencies + - Component structures and hierarchies + - State management and hooks + - Props and type definitions + - Existing functionality and features +3. I will identify the minimal set of changes needed to fix the issue. +4. When making modifications, I will: + - Preserve all existing imports + - Maintain component structure and naming + - Keep all existing functionality intact + - Only modify code directly related to the issue +5. Before finalizing, I will verify that: + - All original features are preserved + - The fix addresses the specific issue + - Code style and standards are maintained + - No unintended side effects are introduced + +--- + +### **Strict Requirements:** +1. NEVER remove existing imports unless explicitly told to +2. NEVER remove existing components or functions unless explicitly told to +3. NEVER simplify or reduce existing code unless explicitly told to +4. ALL changes must be surgical and focused only on the specific issue +5. Return the COMPLETE updated code with ALL original functionality preserved + +--- + +### **Output Format** +{ + "modified_files": { + "frontend/src/components/file.tsx": "import React from 'react';\nimport { useState } from 'react';\n\nexport function Component() {\n const [state, setState] = useState(null);\n return
Content
;\n}", + "frontend/src/utils/file.ts": "export function helperFunction() {\n // Complete implementation\n return true;\n}" + }, + "thinking_process": "After analyzing the code, I identified the specific issue. The fix has been implemented while preserving all existing functionality, including [list specific preserved features]. The changes only affect [describe specific changes], and all other code remains intact." +}
+ +Failure is Not an Option! The code must be fixed while preserving ALL existing functionality.` + ); +}; +export const codeReviewPrompt = (message: string) => { + return ( + systemPrompt() + + `You are a senior code reviewer. Your mission is to review the code changes made by the user. + +--- + +### **Mission Objective:** +- The user has provided the code changes. +- Your task is to review the code changes and ensure they are correct. + +--- + +### **User-Provided Information:** +- **Code Changes:** + ${message} + +### **AI Thought Process:** +To conduct a thorough code review, I will first analyze the provided changes to understand **the intended functionality** and ensure they align with best practices. +I will check for **correctness** by verifying whether the modifications actually fix the described issue or implement the expected feature. +If the changes involve **state management**, I will ensure updates are handled correctly to prevent **stale state issues or unnecessary re-renders**. +For **API-related modifications**, I will confirm that error handling is in place to prevent unexpected failures. +Additionally, I will assess **code readability, maintainability, and adherence to project conventions**. +If any issues are found, I will provide actionable feedback on how to improve the code. + +--- + +### **Output Format** +{ + "review_result": "Correct Fix" | "Still has issues", + "comments": [ + "Ensure error handling is in place for failed API requests.", + "Consider using React.memo to prevent unnecessary re-renders in ChatList." + ], + "thinking_process": "The code correctly fixes the issue by adding a new mutation, but it lacks proper error handling for failed API responses. I suggest adding a try-catch block to improve robustness." +} + +Failure is Not an Option! If you fail, the code changes will not be reviewed.` + ); +}; +export const commitChangesPrompt = (message: string) => { + return ( + systemPrompt() + + `You are a Git version control assistant. Your mission is to commit the code changes to the repository. + +--- + +### **Mission Objective:** +- The user has provided code changes that need to be committed. +- Your task is to analyze the changes and generate a clear, descriptive commit message. + +--- + +### **User-Provided Information:** +- **Code Changes:** + ${message} + +### **AI Thought Process:** +To generate an effective commit message, I will first analyze the provided code changes to determine **the primary functionality being modified**. +If the changes involve **bug fixes**, I will craft a message that clearly describes the issue being resolved. +If the changes add **new features**, I will ensure the commit message concisely explains the functionality introduced. +For **refactoring or optimizations**, I will highlight the improvements made, such as **performance enhancements or code restructuring**. +The final commit message will follow **conventional commit standards** to ensure clarity and maintainability in version control. + +--- + +### **Output Format** +{ + "commit_message": "Fix issue where messages were not being saved due to missing state update in useChatStream.ts", + "thinking_process": "After analyzing the code changes, I determined that the primary fix addresses a missing state update in useChatStream.ts, which was preventing messages from being saved. The commit message reflects this fix concisely." +} + +Failure is Not an Option! If you fail, the changes will not be committed.` + ); +}; + +export const confirmationPrompt = ( + taskType: string, + context: AgentContext +): string => { + const toolUsageMap = getToolUsageMap(); + const toolsDescription = Object.entries(toolUsageMap) + .map(([toolName, usage], index) => { + return `${index + 1}. ${toolName}: + - Use for: ${usage.useFor} + - Input: ${usage.input} + - Output: ${usage.output} + - When to use: ${usage.whenToUse} + - Next step: ${usage.nextStep || 'Task completion'}`; + }) + .join('\n\n'); + + return ( + systemPrompt() + + `You are a task manager AI responsible for coordinating the development workflow. Your job is to analyze the current state and decide the next action. + +### Current Context: +1. Original Request: ${context.request} +2. Task Type: ${context.task_type} +3. Current Step: ${JSON.stringify(context.currentStep, null, 2)} +4. Project Files: ${JSON.stringify(context.fileStructure, null, 2)} +5. Modified Files: ${JSON.stringify(context.modifiedFiles, null, 2)} +6. File Contents: ${JSON.stringify(context.fileContents, null, 2)} +${context.reviewComments ? `6. Review Comments: ${JSON.stringify(context.reviewComments, null, 2)}` : ''} +${context.commitMessage ? `7. Commit Message: ${context.commitMessage}` : ''} + +### Available Tools: +${toolsDescription} + +### Decision Making Process: +IMPORTANT: If task_type is already specified (${context.task_type}), DO NOT use taskTool - skip directly to file handling! + +1. Start here: + - If task_type is NOT specified: use taskTool first + - If task_type IS specified: skip to step 2 +2. Before any file modifications: + - If context.fileContents is empty, MUST use readFileTool first + - NEVER proceed to editFileTool without file contents + - Always check Object.keys(context.fileContents).length before editing +3. Once file contents are ready: + - Use editFileTool to make necessary changes + - Use codeReviewTool to verify changes +4. After successful review: + - Use applyChangesTool to apply changes + - Use commitChangesTool to prepare commit +5. Critical Rules (In Order of Priority): + - FIRST: If task_type is specified (current: ${context.task_type}), NEVER use taskTool + - SECOND: NEVER edit files without contents (check context.fileContents) + - THIRD: If fileContents empty but needed, use readFileTool next + - FOURTH: Only proceed to editFileTool when file contents are available + - LAST: If any step fails, return to appropriate previous step + +### Output Format: +{ + "completed": boolean, + "next_step": { + "tool": "taskTool" | "editFileTool" | "codeReviewTool" | "applyChangesTool" | "commitChangesTool", + "description": "Detailed description of what needs to be done", + "files": ["file/paths/if/needed"] + }, + "thinking_process": "Explanation of why this step is needed and how it helps progress the task" +} + +Make your decision based on the current context and ensure a logical progression through the development workflow.` + ); +}; + +export const findbugPrompt = (message: string, file_structure: string[]) => { + return ( + systemPrompt() + + `You are an elite Apex Legends player, but hackers have hijacked your account! They demand that you find the root cause of a critical bug in your project before they wipe all your skins and reset your rank. + +--- + +### **Mission Objective:** +The user has encountered a **Bug** and provided a description along with the project's file structure. +Your task is to analyze the potential source of the Bug and return a **list of affected file paths** where the issue might be occurring and also provide detailed description about that bug. + +--- + +### **User-Provided Information:** +- **Bug Description:** + ${message} +- **Project File Structure:** + ${file_structure} + + +### **Output Format** +{ + "files": ["frontend/src/components/chat/ChatList.tsx", "frontend/src/utils/chat.ts"], + "description": "The bug occurs when the chat messages are not updating in real-time. The user expects the messages to be displayed instantly after sending a message, but there is a delay in the update.", + "thinking_process": "Based on the bug description, the issue involves React's state updates not propagating correctly. This suggests that the problem is likely in the useChatStream.ts hook or the context provider managing state." +} + +Failure is Not an Option! If you fail, the hackers will report you to EA.` + ); +}; diff --git a/frontend/src/hooks/multi-agent/managerAgent.ts b/frontend/src/hooks/multi-agent/managerAgent.ts new file mode 100644 index 00000000..a66a597e --- /dev/null +++ b/frontend/src/hooks/multi-agent/managerAgent.ts @@ -0,0 +1,208 @@ +import { ChatInputType } from '@/graphql/type'; +import { startChatStream } from '@/api/ChatStreamAPI'; +import { findToolNode } from './toolNodes'; +import { confirmationPrompt, AgentContext, TaskType } from './agentPrompt'; +import path from 'path'; +import { parseXmlToJson } from '@/utils/parser'; +import { Message } from '@/const/MessageType'; +import { toast } from 'sonner'; + +/** + * Normalize file paths. + */ +function normalizePath(filePath: string): string { + return path.normalize(filePath).replace(/\\/g, '/'); +} + +/** + * Validate and normalize file list. + */ +function validateFiles(files: string[]): string[] { + return files.map((file) => { + const normalized = normalizePath(file); + if (!normalized || normalized.includes('..')) { + throw new Error(`Invalid file path: ${file}`); + } + return normalized; + }); +} + +/** + * The Manager Agent is responsible for: + * 1. Initializing the context. + * 2. Letting AI determine each step of the process. + * 3. Executing the tool selected by AI. + * 4. Repeating until AI determines the task is complete. + */ +export async function managerAgent( + input: ChatInputType, + setMessages: React.Dispatch>, + projectPath: string, + saveMessage: any, + token: string, + refreshProjects: () => Promise, + setFilePath: (path: string) => void, + editorRef: React.MutableRefObject +): Promise { + try { + // Initialize context + const context: AgentContext = { + task_type: undefined, // Will be set after task analysis + request: input.message, // Store the original request + projectPath, + fileStructure: [], + fileContents: {}, + modifiedFiles: {}, + requiredFiles: [], // Initialize empty array for required files + accumulatedThoughts: [], // Initialize empty array for thoughts + currentStep: { + tool: undefined, + status: 'initializing', + description: 'Starting task analysis', + }, + setMessages, + saveMessage, + token, + setFilePath, + editorRef, + }; + + // Retrieve project file structure + const response = await fetch( + `/api/filestructure?path=${encodeURIComponent(projectPath)}`, + { + method: 'GET', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + } + ); + const data = await response.json(); + context.fileStructure = validateFiles(data.res); + + const MAX_ITERATIONS = 60; + const TIMEOUT = 30 * 60 * 1000; // 30-minute timeout + const startTime = Date.now(); + let iteration = 0; + + // Main loop: let AI determine each step + while (iteration < MAX_ITERATIONS && Date.now() - startTime < TIMEOUT) { + // Get AI's decision + const confirmationPromptStr = confirmationPrompt( + context.task_type, + context + ); + const response = await startChatStream( + { + chatId: input.chatId, + message: confirmationPromptStr, + model: input.model, + role: `assistant`, + }, + token + ); + + // Parse AI's response using `parseXmlToJson` + let decision; + try { + decision = parseXmlToJson(response); + } catch (error) { + toast.error('Failed to parse AI response'); + throw error; + } + + // If task is complete, exit loop + if (decision.completed) { + break; + } + + // Update the current step in context + context.currentStep = { + tool: decision.next_step.tool, + status: 'executing', + description: decision.next_step.description, + }; + + context.requiredFiles = [ + ...context.requiredFiles, + ...decision.next_step.files.filter( + (file) => !context.requiredFiles.includes(file) + ), + ]; + + // Find and execute the tool + const toolNode = findToolNode(decision.next_step.tool); + if (!toolNode) { + throw new Error(`Tool not found: ${decision.next_step.tool}`); + } + + // Execute the tool + const toolInput = { + ...input, + role: `assistant`, + message: decision.next_step.description, + }; + + await toolNode.behavior(toolInput, context); + + // Update step status + context.currentStep.status = 'completed'; + + iteration++; + if (context.task_type == TaskType.UNRELATED) { + break; + } + } + + // Check if task exceeded limits + if (iteration >= MAX_ITERATIONS) { + throw new Error('Task exceeded maximum iterations'); + } + if (Date.now() - startTime >= TIMEOUT) { + throw new Error('Task timed out'); + } + + // Handle final file modifications + if (Object.keys(context.modifiedFiles).length > 0) { + await Promise.all( + Object.entries(context.modifiedFiles).map( + async ([filePath, content]) => { + try { + await fetch('/api/file', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + filePath: normalizePath(`${context.projectPath}/${filePath}`), + newContent: content, + }), + }); + } catch (error) { + toast.error('Failed to save file'); + throw error; + } + } + ) + ); + + // Refresh projects to update the code display + await refreshProjects(); + } + + // Save all accumulated thoughts + if (context.accumulatedThoughts.length > 0) { + context.saveMessage({ + variables: { + input: { + chatId: input.chatId, + message: context.accumulatedThoughts.join('\n\n'), + model: input.model, + role: `assistant`, + }, + }, + }); + } + } catch (error) { + toast.error('Failed to complete task'); + throw error; + } +} diff --git a/frontend/src/hooks/multi-agent/toolNodes.ts b/frontend/src/hooks/multi-agent/toolNodes.ts new file mode 100644 index 00000000..4fe6be83 --- /dev/null +++ b/frontend/src/hooks/multi-agent/toolNodes.ts @@ -0,0 +1,130 @@ +import { ChatInputType } from '@/graphql/type'; +import { AgentContext } from './agentPrompt'; +import { + taskTool, + readFileTool, + applyChangesTool, + codeReviewTool, + commitChangesTool, + editFileTool, +} from './tools'; + +export interface ToolUsage { + useFor: string; // Purpose of the tool + input: string; // Input description + output: string; // Output description + whenToUse: string; // When to use the tool + nextStep?: string; // Typical next step +} + +// Modify the Tool interface to include usage instructions +export interface Tool { + toolName: string; + behavior: (input: ChatInputType, context: AgentContext) => Promise; + description: string; + usage: ToolUsage; +} + +export const managerTools: Tool[] = [ + { + toolName: 'taskTool', + behavior: taskTool, + description: + 'Analyzes task requirements, identifies relevant files, and generates a processing plan.', + usage: { + useFor: + 'Understanding the task requirements and identifying relevant files', + input: 'Task description and current status', + output: 'List of files that need to be checked or modified', + whenToUse: 'At the beginning of a task or when new files are required', + nextStep: 'readFileTool', + }, + }, + { + toolName: 'readFileTool', + behavior: readFileTool, + description: 'Reads the content of identified files.', + usage: { + useFor: 'Retrieving file content for analysis and modification', + input: 'List of file paths', + output: 'File content', + whenToUse: 'After identifying the files to be processed', + nextStep: 'editFileTool', + }, + }, + { + toolName: 'editFileTool', + behavior: editFileTool, + description: + 'Generates editing prompts based on the file content stored in the context.', + usage: { + useFor: 'Implementing the required code changes', + input: 'File content and modification plan', + output: 'Modified code', + whenToUse: 'After analysis is complete or when fixes are needed', + nextStep: 'codeReviewTool', + }, + }, + { + toolName: 'codeReviewTool', + behavior: codeReviewTool, + description: + 'Generates a code review report based on the modified file content in the context.', + usage: { + useFor: 'Ensuring code quality and correctness', + input: 'Modified file content', + output: 'Review comments and suggestions', + whenToUse: 'After code modifications', + nextStep: + 'If issues exist, return to editFileTool; if passed, proceed to applyChangesTool', + }, + }, + { + toolName: 'applyChangesTool', + behavior: applyChangesTool, + description: + 'Synchronizes modifications to the file content in the context.', + usage: { + useFor: 'Confirming and applying verified changes', + input: 'Reviewed and approved modifications', + output: 'Updated file content', + whenToUse: 'After code review is completed', + nextStep: 'commitChangesTool', + }, + }, + { + toolName: 'commitChangesTool', + behavior: commitChangesTool, + description: + 'Generates commit messages based on modifications stored in the context.', + usage: { + useFor: 'Generating descriptive commit messages', + input: 'All applied changes', + output: 'Commit message', + whenToUse: 'After changes have been applied', + nextStep: 'Task completed', + }, + }, +]; + +export function findToolNode(toolName: string): Tool | undefined { + return managerTools.find((tool) => tool.toolName === toolName); +} + +// Helper function: Retrieve tool usage instructions +export function getToolUsageMap(): Record { + const usageMap: Record = {}; + managerTools.forEach((tool) => { + usageMap[tool.toolName] = tool.usage; + }); + return usageMap; +} + +// Helper function: Retrieve tool descriptions +export function getToolDescriptionMap(): Record { + const descMap: Record = {}; + managerTools.forEach((tool) => { + descMap[tool.toolName] = tool.description; + }); + return descMap; +} diff --git a/frontend/src/hooks/multi-agent/tools.ts b/frontend/src/hooks/multi-agent/tools.ts new file mode 100644 index 00000000..081d5763 --- /dev/null +++ b/frontend/src/hooks/multi-agent/tools.ts @@ -0,0 +1,517 @@ +import { toast } from 'sonner'; +import { + leaderPrompt, + findbugPrompt, + refactorPrompt, + optimizePrompt, + editFilePrompt, + codeReviewPrompt, + commitChangesPrompt, + AgentContext, + TaskType, +} from './agentPrompt'; +import { startChatStream } from '@/api/ChatStreamAPI'; +import { parseXmlToJson } from '@/utils/parser'; +import { ChatInputType } from '@/graphql/type'; + +/** + * Helper function to save and display a thinking process (or any provided text) + * with a typewriter effect. + * + * Usage: + * await saveThinkingProcess("The thinking process text", input, context); + * + * This function stores the text in context.accumulatedThoughts and then gradually + * displays it using a typewriter effect. After the entire text is shown, it appends + * two newline characters. + */ +const saveThinkingProcess = async ( + thinkingText: string, + input: ChatInputType, + context: AgentContext +): Promise => { + if (!thinkingText) return; + + // Accumulate the thinking process text for historical record. + context.accumulatedThoughts.push(thinkingText); + + // Function to break the text into small chunks for a smoother typewriter animation. + const breakText = (text: string): string[] => { + return text.match(/(\S{1,3}|\s+)/g) || []; + }; + + // Typewriter effect: gradually display the text chunks with a specified delay. + const typewriterEffect = async ( + textArray: string[], + delay: number + ): Promise => { + return new Promise((resolve) => { + let index = 0; + + const updateMessage = () => { + if (index < textArray.length) { + context.setMessages((prev) => { + const lastMsg = prev[prev.length - 1]; + if (lastMsg?.role === 'assistant' && lastMsg.id === input.chatId) { + return [ + ...prev.slice(0, -1), + { + ...lastMsg, + content: lastMsg.content + textArray[index], + }, + ]; + } else { + return [ + ...prev, + { + id: input.chatId, + role: 'assistant', + content: textArray[index], + createdAt: new Date().toISOString(), + }, + ]; + } + }); + + index++; + setTimeout(updateMessage, delay); + } else { + resolve(); + } + }; + + updateMessage(); + }); + }; + + // Break the provided thinkingText into chunks and display them. + const brokenText = breakText(thinkingText); + await typewriterEffect(brokenText, 10); + + // After displaying all chunks, append two newlines to the final message. + context.setMessages((prev) => { + const lastMsg = prev[prev.length - 1]; + if (lastMsg?.role === 'assistant' && lastMsg.id === input.chatId) { + return [ + ...prev.slice(0, -1), + { + ...lastMsg, + content: lastMsg.content + '\n\n', + }, + ]; + } + return prev; + }); +}; +/** + * Get the corresponding prompt based on the task type. + */ +function getPromptByTaskType( + task_type: TaskType | null, + message: string, + fileStructure: string[] +): string { + // Validate inputs + if (!task_type) { + throw new Error('Task type is required'); + } + if (!message || typeof message !== 'string') { + throw new Error('Message is required and must be a string'); + } + if (!Array.isArray(fileStructure)) { + throw new Error('File structure must be an array'); + } + + // Handle task types + switch (task_type) { + case TaskType.DEBUG: + return findbugPrompt(message, fileStructure); + case TaskType.REFACTOR: + return refactorPrompt(message, fileStructure); + case TaskType.OPTIMIZE: + return optimizePrompt(message, fileStructure); + case TaskType.UNRELATED: + throw new Error('Cannot generate prompt for unrelated tasks'); + default: + throw new Error(`Unsupported task type: ${task_type}`); + } +} + +/** + * Task analysis tool: + * Analyzes requirements and identifies relevant files. + */ +export const taskTool = async ( + input: ChatInputType, + context: AgentContext +): Promise => { + if (!input || !context) { + throw new Error('Invalid input or context'); + } + + try { + const prompt = leaderPrompt(input.message); + + // Get task analysis response + const taskResponse = await startChatStream( + { + chatId: input.chatId, + message: prompt, + model: input.model, + role: input.role, + }, + context.token + ); + + // Parse task analysis response + const result = parseXmlToJson(taskResponse); + if (!result || typeof result !== 'object') { + throw new Error('Invalid task analysis response format'); + } + + // Validate task type + const taskType = result.task_type; + if (!taskType || !Object.values(TaskType).includes(taskType)) { + throw new Error(`Invalid task type: ${taskType || 'undefined'}`); + } + + // Update context and display analysis + context.task_type = taskType; + await saveThinkingProcess( + `Task Analysis: This is a ${taskType} task.\n${result.description || ''}`, + input, + context + ); + + // Handle unrelated tasks with early return + if (taskType === TaskType.UNRELATED) { + await saveThinkingProcess( + "This question isn't related to code or development. Please ask coding-related questions.", + input, + context + ); + return; + } + + // Get and validate file analysis prompt + const fileAnalysisPrompt = getPromptByTaskType( + taskType, + input.message, + context.fileStructure + ); + if (!fileAnalysisPrompt) { + throw new Error( + `Failed to generate analysis prompt for task type: ${taskType}` + ); + } + + // Get file analysis from AI + await saveThinkingProcess('Analyzing project files...', input, context); + const fileResponse = await startChatStream( + { + chatId: input.chatId, + message: fileAnalysisPrompt, + model: input.model, + role: input.role, + }, + context.token + ); + + // Parse and validate file analysis response + const fileResult = parseXmlToJson(fileResponse); + if (!fileResult || typeof fileResult !== 'object') { + throw new Error('Invalid file analysis response format'); + } + + // Validate and process identified files + const files = fileResult.files; + if (!Array.isArray(files)) { + throw new Error('Invalid file analysis: missing files array'); + } + + // Filter and validate file paths + const validFiles = files.filter( + (path) => typeof path === 'string' && path.length > 0 + ); + if (validFiles.length === 0) { + throw new Error('No valid files identified in the analysis'); + } + + // Show analysis results + await saveThinkingProcess( + `Found ${validFiles.length} relevant files:\n${validFiles.join('\n')}`, + input, + context + ); + + // Update context with next steps + context.requiredFiles = validFiles; + context.currentStep = { + tool: 'readFileTool', + status: 'pending', + description: `Reading ${validFiles.length} identified files`, + }; + } catch (error) { + await saveThinkingProcess( + `Error during task analysis: ${error.message}`, + input, + context + ); + throw error; + } +}; + +/** + * Read file tool: + * Reads the content of identified files. + */ +export async function readFileTool( + input: ChatInputType, + context: AgentContext +): Promise { + if (!context.requiredFiles || context.requiredFiles.length === 0) { + throw new Error( + 'No files specified to read. Make sure requiredFiles is set.' + ); + } + + // Read file content for each required file + await Promise.all( + context.requiredFiles.map(async (filePath: string) => { + try { + const response = await fetch( + `/api/file?path=${encodeURIComponent(`${context.projectPath}/${filePath}`)}`, + { + method: 'GET', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + } + ); + const data = await response.json(); + context.fileContents[filePath] = data.content; + } catch (error) { + toast.error(`Failed to read file: ${filePath}`); + throw error; + } + }) + ); + + toast.success(`${context.requiredFiles.length} files loaded successfully`); +} + +/** + * Edit file tool: + * Modifies file content based on requirements. + */ +export async function editFileTool( + input: ChatInputType, + context: AgentContext +): Promise { + if (Object.keys(context.fileContents).length === 0) { + throw new Error( + 'No file contents available. Make sure readFileTool was called first.' + ); + } + + // Generate edit prompt + const prompt = editFilePrompt(input.message, context.fileContents); + + // Get AI response + const response = await startChatStream( + { + chatId: input.chatId, + message: prompt, + model: input.model, + role: input.role, + }, + context.token + ); + + // Parse response using `parseXmlToJson` + const result = parseXmlToJson(response); + await saveThinkingProcess( + `Analyzing file changes...\nFiles to modify: ${Object.keys(result.modified_files || {}).join(', ')}`, + input, + context + ); + + // Check for files that need to be read or modified + if (result.files && Array.isArray(result.files)) { + context.requiredFiles = result.files; + } + + if (!result.modified_files || typeof result.modified_files !== 'object') { + throw new Error('Invalid response format: missing modified_files object'); + } + + // Update required files and content in context + const modifiedPaths = Object.keys(result.modified_files); + const validPaths = modifiedPaths.filter((path) => !!path); + context.requiredFiles = Array.from( + new Set([...context.requiredFiles, ...validPaths]) + ); + + // Update file content in context with typewriter effect + for (const [filePath, content] of Object.entries(result.modified_files)) { + // Set current file in code-engine + context.setFilePath(filePath); + await new Promise((resolve) => setTimeout(resolve, 500)); // Wait for file to load + + // Parse content if it's a JSON string + let realContent = content as string; + try { + if (realContent.startsWith('"') && realContent.endsWith('"')) { + realContent = JSON.parse(realContent); + } + } catch (error) { + toast.error('Failed to parse file content'); + throw error; + } + + const lines = realContent.split('\n'); + let accumulatedContent = ''; + + if (context.editorRef?.current) { + // Get current editor content as baseline + accumulatedContent = context.editorRef.current.getValue(); + + // Wait for file path change to take effect + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Show each line with animation + try { + for (let i = 0; i < lines.length; i++) { + accumulatedContent = lines.slice(0, i + 1).join('\n'); + context.editorRef.current.setValue(accumulatedContent); + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } catch (error) { + toast.error('Failed to animate file content'); + throw error; + } + + // Sync final content + context.fileContents[filePath] = content as string; + } + + // Store final unescaped content + context.modifiedFiles[filePath] = realContent; + context.fileContents[filePath] = realContent; + } +} + +/** + * Apply changes tool: + * Confirms and applies file modifications. + */ +export async function applyChangesTool( + input: ChatInputType, + context: AgentContext +): Promise { + // Validate modification content + Object.entries(context.modifiedFiles).forEach(([filePath, content]) => { + if (!content || typeof content !== 'string') { + throw new Error(`Invalid content for file: ${filePath}`); + } + }); + + // Update file content in context + Object.entries(context.modifiedFiles).forEach(([filePath, content]) => { + context.fileContents[filePath] = content; + }); + + toast.success('Changes applied successfully'); +} + +/** + * Code review tool: + * Checks whether modifications meet the requirements. + */ +export async function codeReviewTool( + input: ChatInputType, + context: AgentContext +): Promise { + // Generate review prompt + const formattedMessage = Object.entries(context.modifiedFiles) + .map( + ([filePath, content]) => + `### File: ${filePath}\n\`\`\`\n${content}\n\`\`\`` + ) + .join('\n\n'); + + const prompt = codeReviewPrompt(formattedMessage); + + // Get AI response + const response = await startChatStream( + { + chatId: input.chatId, + message: prompt, + model: input.model, + role: input.role, + }, + context.token + ); + + // Parse review results using `parseXmlToJson` + const result = parseXmlToJson(response); + await saveThinkingProcess( + `Code Review:\n${result.review_result}\n\nComments:\n${result.comments?.join('\n')}`, + input, + context + ); + + if (!result.review_result || !result.comments) { + throw new Error('Invalid response format: missing review details'); + } + + // Update review results in context + context.reviewComments = result.comments; + if (result.review_result === 'Still has issues') { + context.currentStep = { + tool: 'editFileTool', + status: 'pending', + description: 'Address review comments', + }; + } +} + +/** + * Commit changes tool: + * Generates commit messages. + */ +export async function commitChangesTool( + input: ChatInputType, + context: AgentContext +): Promise { + // Generate commit message prompt + const formattedChanges = Object.entries(context.modifiedFiles) + .map(([filePath, content]) => `Modified file: ${filePath}: ${content}`) + .join('\n'); + + const prompt = commitChangesPrompt(formattedChanges); + + // Get AI response + const response = await startChatStream( + { + chatId: input.chatId, + message: prompt, + model: input.model, + role: input.role, + }, + context.token + ); + + // Parse commit message using `parseXmlToJson` + const result = parseXmlToJson(response); + await saveThinkingProcess( + `Generated commit message:\n${result.commit_message}`, + input, + context + ); + + if (!result.commit_message) { + throw new Error('Invalid response format: missing commit message'); + } + + context.commitMessage = result.commit_message; +} diff --git a/frontend/src/hooks/useChatStream.ts b/frontend/src/hooks/useChatStream.ts index eb67ad3e..96f06cef 100644 --- a/frontend/src/hooks/useChatStream.ts +++ b/frontend/src/hooks/useChatStream.ts @@ -1,12 +1,16 @@ -import { useState, useCallback, useEffect } from 'react'; +import { useState, useCallback, useEffect, useContext } from 'react'; import { useMutation } from '@apollo/client'; -import { CREATE_CHAT } from '@/graphql/request'; +import { CREATE_CHAT, SAVE_MESSAGE } from '@/graphql/request'; import { Message } from '@/const/MessageType'; import { toast } from 'sonner'; import { logger } from '@/app/log/logger'; import { useAuthContext } from '@/providers/AuthProvider'; +import { startChatStream } from '@/api/ChatStreamAPI'; +import { ProjectContext } from '@/components/chat/code-engine/project-context'; +import { ChatInputType } from '@/graphql/type'; +import { managerAgent } from './multi-agent/managerAgent'; -interface UseChatStreamProps { +export interface UseChatStreamProps { chatId: string; input: string; setInput: (input: string) => void; @@ -24,6 +28,15 @@ export const useChatStream = ({ const [loadingSubmit, setLoadingSubmit] = useState(false); const [currentChatId, setCurrentChatId] = useState(chatId); const { token } = useAuthContext(); + const { curProject, refreshProjects, setFilePath, editorRef } = + useContext(ProjectContext); + const [curProjectPath, setCurProjectPath] = useState(''); + + useEffect(() => { + if (curProject) { + setCurProjectPath(curProject.projectPath); + } + }, [curProject]); // Use useEffect to handle new chat event and cleanup useEffect(() => { @@ -48,6 +61,8 @@ export const useChatStream = ({ setCurrentChatId(chatId); }, [chatId]); + const [saveMessage] = useMutation(SAVE_MESSAGE); + const [createChat] = useMutation(CREATE_CHAT, { onCompleted: async (data) => { const newChatId = data.createChat.id; @@ -62,95 +77,30 @@ export const useChatStream = ({ }, }); - const startChatStream = async ( - targetChatId: string, - message: string, - model: string, - stream: boolean = false // Default to non-streaming for better performance - ): Promise => { - if (!token) { - throw new Error('Not authenticated'); - } - - const response = await fetch('/api/chat', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ - chatId: targetChatId, - message, - model, - stream, - }), - }); - - if (!response.ok) { - throw new Error( - `Network response was not ok: ${response.status} ${response.statusText}` - ); - } - // TODO: Handle streaming responses properly - // if (stream) { - // // For streaming responses, aggregate the streamed content - // let fullContent = ''; - // const reader = response.body?.getReader(); - // if (!reader) { - // throw new Error('No reader available'); - // } - - // while (true) { - // const { done, value } = await reader.read(); - // if (done) break; - - // const text = new TextDecoder().decode(value); - // const lines = text.split('\n\n'); - - // for (const line of lines) { - // if (line.startsWith('data: ')) { - // const data = line.slice(5); - // if (data === '[DONE]') break; - // try { - // const { content } = JSON.parse(data); - // if (content) { - // fullContent += content; - // } - // } catch (e) { - // console.error('Error parsing SSE data:', e); - // } - // } - // } - // } - // return fullContent; - // } else { - // // For non-streaming responses, return the content directly - // const data = await response.json(); - // return data.content; - // } - - const data = await response.json(); - return data.content; - }; - const handleChatResponse = async (targetChatId: string, message: string) => { try { setInput(''); - const response = await startChatStream( - targetChatId, + const userInput: ChatInputType = { + chatId: targetChatId, message, - selectedModel - ); - - setMessages((prev) => [ - ...prev, - { - id: `${targetChatId}/${prev.length}`, - role: 'assistant', - content: response, - createdAt: new Date().toISOString(), + model: selectedModel, + role: 'user', + }; + saveMessage({ + variables: { + input: userInput as ChatInputType, }, - ]); + }); + await managerAgent( + userInput, + setMessages, + curProjectPath, + saveMessage, + token, + refreshProjects, + setFilePath, + editorRef + ); setLoadingSubmit(false); } catch (err) { diff --git a/frontend/src/utils/file-reader.ts b/frontend/src/utils/file-reader.ts index 7c1a803a..1adaec77 100644 --- a/frontend/src/utils/file-reader.ts +++ b/frontend/src/utils/file-reader.ts @@ -44,7 +44,6 @@ export class FileReader { try { const items = await fs.readdir(dir, { withFileTypes: true }); - for (const item of items) { if (item.name.includes('node_modules')) continue; const fullPath = path.join(dir, item.name); @@ -88,7 +87,7 @@ export class FileReader { logger.info(`📝 [FileReader] Updating file: ${fullPath}`); try { - const content = JSON.parse(newContent); + const content = newContent.trim(); await fs.writeFile(fullPath, content, 'utf-8'); logger.info('[FileReader] File updated successfully'); diff --git a/frontend/src/utils/parser.ts b/frontend/src/utils/parser.ts new file mode 100644 index 00000000..a6c602ee --- /dev/null +++ b/frontend/src/utils/parser.ts @@ -0,0 +1,19 @@ +/** + * Parses AI response wrapped inside tags into a JSON object. + */ +export const parseXmlToJson = (xmlString: string) => { + const match = xmlString.match(/([\s\S]*?)<\/jsonResponse>/); + if (!match || !match[1]) { + throw new Error('Invalid XML: No element found'); + } + + const jsonText = match[1].trim(); + + try { + return JSON.parse(jsonText); + } catch (error) { + throw new Error( + 'Invalid JSON: Failed to parse content inside ' + error + ); + } +};