Files
gpt-pilot/core/agents/frontend.py
2025-06-14 10:33:01 +02:00

545 lines
24 KiB
Python

import asyncio
import json
import os
import sys
from urllib.parse import urljoin
import httpx
from core.agents.base import BaseAgent
from core.agents.convo import AgentConvo
from core.agents.git import GitMixin
from core.agents.mixins import FileDiffMixin
from core.agents.response import AgentResponse
from core.cli.helpers import capture_exception
from core.config import FRONTEND_AGENT_NAME, IMPLEMENT_CHANGES_AGENT_NAME, SWAGGER_EMBEDDINGS_API
from core.config.actions import (
FE_CHANGE_REQ,
FE_CONTINUE,
FE_DONE_WITH_UI,
FE_ITERATION,
FE_ITERATION_DONE,
FE_START,
)
from core.llm.convo import Convo
from core.llm.parser import DescriptiveCodeBlockParser, OptionalCodeBlockParser
from core.log import get_logger
from core.telemetry import telemetry
from core.ui.base import ProjectStage
log = get_logger(__name__)
def has_correct_num_of_backticks(response: str) -> bool:
"""
Checks if the response has the correct number of backticks.
"""
return response.count("```") % 2 == 0 and response.count("```") > 0
class Frontend(FileDiffMixin, GitMixin, BaseAgent):
agent_type = "frontend"
display_name = "Frontend"
async def run(self) -> AgentResponse:
if not self.current_state.epics[-1]["messages"]:
finished = await self.start_frontend()
elif self.next_state.epics[-1].get("file_paths_to_remove_mock"):
finished = await self.remove_mock()
if finished is None:
return AgentResponse.exit(self)
elif not self.next_state.epics[-1].get("fe_iteration_done"):
finished = await self.continue_frontend()
else:
await self.set_app_details()
finished = await self.iterate_frontend()
if finished is None:
return AgentResponse.exit(self)
return await self.end_frontend_iteration(finished)
async def start_frontend(self):
"""
Starts the frontend of the app.
"""
self.next_state.action = FE_START
await self.send_message("Building the frontend... This may take a couple of minutes")
llm = self.get_llm(FRONTEND_AGENT_NAME)
convo = AgentConvo(self).template(
"build_frontend",
summary=self.state_manager.template["template"].get_summary()
if self.state_manager.template is not None
else self.current_state.specification.template_summary,
description=self.state_manager.template["description"]
if self.state_manager.template is not None
else self.next_state.epics[-1]["description"],
user_feedback=None,
first_time_build=True,
)
response = await llm(convo, parser=DescriptiveCodeBlockParser())
response_blocks = response.blocks
convo.assistant(response.original_response)
# Await the template task if it's not done yet
if self.state_manager.async_tasks:
if not self.state_manager.async_tasks[-1].done():
await self.state_manager.async_tasks[-1]
self.state_manager.async_tasks = []
await self.process_response(response_blocks)
self.next_state.epics[-1]["messages"] = convo.messages
self.next_state.epics[-1]["fe_iteration_done"] = (
"done" in response.original_response[-20:].lower().strip() or len(convo.messages) > 11
)
self.next_state.flag_epics_as_modified()
return False
async def continue_frontend(self):
"""
Continues building the frontend of the app after the initial user input.
"""
self.next_state.action = FE_CONTINUE
await self.ui.send_project_stage({"stage": ProjectStage.CONTINUE_FRONTEND})
await self.send_message("Continuing to build UI... This may take a couple of minutes")
llm = self.get_llm(FRONTEND_AGENT_NAME)
convo = AgentConvo(self)
convo.messages = self.current_state.epics[-1]["messages"]
convo.user(
"Ok, now think carefully about your previous response. If the response ends by mentioning something about continuing with the implementation, continue but don't implement any files that have already been implemented. If your last response doesn't end by mentioning continuing, respond only with `DONE` and with nothing else."
)
response = await llm(convo, parser=DescriptiveCodeBlockParser())
response_blocks = response.blocks
convo.assistant(response.original_response)
use_relace = self.current_state.epics[-1].get("use_relace", False)
await self.process_response(response_blocks, relace=use_relace)
if self.next_state.epics[-1].get("manual_iteration", False):
self.next_state.epics[-1]["fe_iteration_done"] = (
has_correct_num_of_backticks(response.original_response)
or self.current_state.epics[-1].get("retry_count", 0) >= 2
)
self.next_state.epics[-1]["retry_count"] = self.current_state.epics[-1].get("retry_count", 0) + 1
else:
self.next_state.epics[-1]["fe_iteration_done"] = (
"done" in response.original_response[-20:].lower().strip() or len(convo.messages) > 15
)
self.next_state.epics[-1]["messages"] = convo.messages
self.next_state.flag_epics_as_modified()
return False
async def iterate_frontend(self):
"""
Iterates over the frontend.
:return: True if the frontend is fully built, False otherwise.
"""
self.next_state.epics[-1]["auto_debug_attempts"] = 0
self.next_state.epics[-1]["retry_count"] = 0
user_input = await self.try_auto_debug()
frontend_only = self.current_state.branch.project.project_type == "swagger"
self.next_state.action = FE_ITERATION
# update the pages in the knowledge base
await self.state_manager.update_implemented_pages_and_apis()
await self.ui.send_project_stage({"stage": ProjectStage.ITERATE_FRONTEND})
if user_input:
await self.send_message("Errors detected, fixing...")
else:
answer = await self.ask_question(
"Do you want to change anything or report a bug?" if frontend_only else FE_CHANGE_REQ,
buttons={"yes": "I'm done building the UI"} if not frontend_only else None,
default="yes",
extra_info="restart_app/collect_logs",
placeholder='For example, "I don\'t see anything when I open http://localhost:5173/" or "Nothing happens when I click on the NEW PROJECT button"',
)
if answer.button == "yes":
answer = await self.ask_question(
FE_DONE_WITH_UI,
buttons={
"yes": "Yes, let's build the backend",
"no": "No, continue working on the UI",
},
buttons_only=True,
default="yes",
)
return answer.button == "yes"
if answer.text:
user_input = answer.text
await self.send_message("Implementing the changes you suggested...")
llm = self.get_llm(FRONTEND_AGENT_NAME)
relevant_api_documentation = None
if frontend_only:
convo = AgentConvo(self).template(
"is_relevant_for_docs_search",
user_feedback=user_input,
)
response = await llm(convo)
if str(response).lower() == "yes":
error = None
for attempt in range(3):
try:
url = urljoin(SWAGGER_EMBEDDINGS_API, "rag/search")
async with httpx.AsyncClient(transport=httpx.AsyncHTTPTransport()) as client:
resp = await client.post(
url,
json={"text": user_input, "project_id": str(self.state_manager.project.id)},
headers={"Authorization": f"Bearer {self.state_manager.get_access_token()}"},
)
if resp.status_code in [200]:
relevant_api_documentation = "\n".join(item["content"] for item in resp.json())
break
elif resp.status_code in [401, 403]:
access_token = await self.ui.send_token_expired()
self.state_manager.update_access_token(access_token)
else:
try:
error = resp.json()["error"]
except Exception as e:
error = e
log.warning(f"Failed to fetch from RAG service: {error}")
await self.send_message(
f"Couldn't find any relevant API documentation. Retrying... \nError: {error}"
)
except Exception as e:
error = e
capture_exception(e)
log.warning(f"Failed to fetch from RAG service: {e}", exc_info=True)
if error:
await self.send_message(f"Please try reloading the project. \nError: {error}")
return None
llm = self.get_llm(FRONTEND_AGENT_NAME, stream_output=True)
# try relace first
convo = AgentConvo(self).template(
"iterate_frontend",
description=self.current_state.epics[-1]["description"],
user_feedback=user_input,
relevant_api_documentation=relevant_api_documentation,
first_time_build=False,
)
# replace system prompt because of relace
convo.messages[0]["content"] = AgentConvo(self).render("system_relace")
response = await llm(convo, parser=DescriptiveCodeBlockParser())
relace_finished = await self.process_response(response.blocks, relace=True)
if not relace_finished:
log.debug("Relace didn't finish, reverting to build_frontend")
convo = AgentConvo(self).template(
"build_frontend",
description=self.current_state.epics[-1]["description"],
user_feedback=user_input,
relevant_api_documentation=relevant_api_documentation,
first_time_build=False,
)
response = await llm(convo, parser=DescriptiveCodeBlockParser())
await self.process_response(response.blocks)
convo.assistant(response.original_response)
self.next_state.epics[-1]["messages"] = convo.messages
self.next_state.epics[-1]["use_relace"] = relace_finished
self.next_state.epics[-1]["fe_iteration_done"] = has_correct_num_of_backticks(response.original_response)
self.next_state.epics[-1]["manual_iteration"] = True
self.next_state.flag_epics_as_modified()
return False
async def end_frontend_iteration(self, finished: bool) -> AgentResponse:
"""
Ends the frontend iteration.
:param finished: Whether the frontend is fully built.
:return: AgentResponse.done(self)
"""
if finished:
# TODO Add question if user app is fully finished
self.next_state.action = FE_ITERATION_DONE
self.next_state.complete_epic()
await telemetry.trace_code_event(
"frontend-finished",
{
"description": self.current_state.epics[-1]["description"],
"messages": self.current_state.epics[-1]["messages"],
},
)
if self.state_manager.git_available and self.state_manager.git_used:
await self.git_commit(commit_message="Frontend finished")
inputs = []
for file in self.current_state.files:
if not file.content:
continue
input_required = self.state_manager.get_input_required(file.content.content, file.path)
if input_required:
inputs += [{"file": file.path, "line": line} for line in input_required]
if inputs:
return AgentResponse.input_required(self, inputs)
return AgentResponse.done(self)
async def process_response(self, response_blocks: list, removed_mock: bool = False, relace: bool = False) -> bool:
"""
Processes the response blocks from the LLM.
:param response_blocks: The response blocks from the LLM.
:return: AgentResponse.done(self)
"""
for block in response_blocks:
description = block.description.strip()
content = block.content.strip()
# Split description into lines and check the last line for file path
description_lines = description.split("\n")
last_line = description_lines[-1].strip()
if "file:" in last_line:
# Extract file path from the last line - get everything after "file:"
file_path = last_line[last_line.index("file:") + 5 :].strip()
file_path = file_path.strip("\"'`")
# Skip empty file paths
if file_path.strip() == "":
continue
new_content = content
old_content = self.current_state.get_file_content_by_path(file_path)
if relace:
llm = self.get_llm(IMPLEMENT_CHANGES_AGENT_NAME)
convo = Convo().user(
{
"initialCode": old_content,
"editSnippet": new_content,
}
)
new_content = await llm(convo, temperature=0, parser=OptionalCodeBlockParser())
if not new_content or new_content == ("", 0, 0):
return False
n_new_lines, n_del_lines = self.get_line_changes(old_content, new_content)
await self.ui.send_file_status(file_path, "done", source=self.ui_source)
await self.ui.generate_diff(
file_path, old_content, new_content, n_new_lines, n_del_lines, source=self.ui_source
)
if not removed_mock and self.current_state.branch.project.project_type == "swagger":
if "client/src/api" in file_path:
if not self.next_state.epics[-1].get("file_paths_to_remove_mock"):
self.next_state.epics[-1]["file_paths_to_remove_mock"] = []
self.next_state.epics[-1]["file_paths_to_remove_mock"].append(file_path)
await self.state_manager.save_file(file_path, new_content)
elif "command:" in last_line:
# Split multiple commands and execute them sequentially
commands = content.strip().split("\n")
for command in commands:
command = command.strip()
if command:
# Add "cd client" prefix if not already present
if not command.startswith("cd "):
command = f"cd client && {command}"
if "run start" in command or "run dev" in command:
continue
# if command is cd client && some_command client/ -> won't work, we need to remove client/ after &&
prefix, cmd_part = command.split("&&", 1)
cmd_part = cmd_part.strip().replace("client/", "")
command = f"{prefix} && {cmd_part}"
# check if cmd_part contains npm run something, if that something is not in scripts, then skip it
if "npm run" in cmd_part:
npm_script = cmd_part.split("npm run")[1].strip()
absolute_path = os.path.join(
self.state_manager.get_full_project_root(),
os.path.join(
"client" if "client" in prefix else "server" if "server" in prefix else "",
"package.json",
),
)
with open(absolute_path, "r") as file:
package_json = json.load(file)
if npm_script not in package_json.get("scripts", {}):
log.warning(
f"Skipping command: {command} as npm script {npm_script} not found, command is {command}"
)
continue
await self.send_message(f"Running command: `{command}`...")
await self.process_manager.run_command(command)
else:
log.info(f"Unknown block description: {description}")
return True
async def remove_mock(self):
"""
Remove mock API from the backend and replace it with api endpoints defined in the external documentation
"""
new_file_paths = self.current_state.epics[-1]["file_paths_to_remove_mock"]
llm = self.get_llm(FRONTEND_AGENT_NAME)
for file_path in new_file_paths:
old_content = self.current_state.get_file_content_by_path(file_path)
convo = AgentConvo(self).template("create_rag_query", file_content=old_content)
topics = await llm(convo)
if topics != "None":
error = None
for attempt in range(3):
try:
url = urljoin(SWAGGER_EMBEDDINGS_API, "rag/search")
async with httpx.AsyncClient(transport=httpx.AsyncHTTPTransport()) as client:
resp = await client.post(
url,
json={"text": topics, "project_id": str(self.state_manager.project.id)},
headers={"Authorization": f"Bearer {self.state_manager.get_access_token()}"},
)
if resp.status_code == 200:
resp_json = resp.json()
relevant_api_documentation = "\n".join(item["content"] for item in resp_json)
referencing_files = await self.state_manager.get_referencing_files(
self.current_state, file_path
)
convo = AgentConvo(self).template(
"remove_mock",
relevant_api_documentation=relevant_api_documentation,
file_content=old_content,
file_path=file_path,
referencing_files=referencing_files,
lines=len(old_content.splitlines()),
)
response = await llm(convo, parser=DescriptiveCodeBlockParser())
response_blocks = response.blocks
convo.assistant(response.original_response)
await self.process_response(response_blocks, removed_mock=True)
self.next_state.epics[-1]["file_paths_to_remove_mock"].remove(file_path)
break
elif resp.status_code in [401, 403]:
access_token = await self.ui.send_token_expired()
self.state_manager.update_access_token(access_token)
else:
try:
error = resp.json()["error"]
except Exception as e:
error = e
log.warning(f"Failed to fetch from RAG service: {error}")
await self.send_message(
f"I couldn't find any relevant API documentation. Retrying... \nError: {error}"
)
except Exception as e:
capture_exception(e)
log.warning(f"Failed to fetch from RAG service: {e}", exc_info=True)
if error:
await self.send_message(f"Please try reloading the project. \nError: {error}")
return None
return False
async def set_app_details(self):
"""
Sets the app details.
"""
command = "npm run start"
app_link = "http://localhost:5173"
self.next_state.run_command = command
# todo store app link and send whenever we are sending run_command
# self.next_state.app_link = app_link
await self.ui.send_run_command(command)
await self.ui.send_app_link(app_link)
async def kill_app(self):
is_win = sys.platform.lower().startswith("win")
# TODO make ports configurable
# kill frontend - both swagger and node
if is_win:
await self.process_manager.run_command(
"""for /f "tokens=5" %a in ('netstat -ano ^| findstr :5173 ^| findstr LISTENING') do taskkill /F /PID %a""",
show_output=False,
)
else:
await self.process_manager.run_command("lsof -ti:5173 | xargs -r kill", show_output=False)
# if node project, kill backend as well
if self.state_manager.project.project_type == "node":
if is_win:
await self.process_manager.run_command(
"""for /f "tokens=5" %a in ('netstat -ano ^| findstr :3000 ^| findstr LISTENING') do taskkill /F /PID %a""",
show_output=False,
)
else:
await self.process_manager.run_command("lsof -ti:3000 | xargs -r kill", show_output=False)
async def try_auto_debug(self) -> str:
count = 3
if self.next_state.epics[-1].get("auto_debug_attempts", 0) >= 3:
return ""
try:
self.next_state.epics[-1]["auto_debug_attempts"] = (
self.current_state.epics[-1].get("auto_debug_attempts", 0) + 1
)
# kill app
await self.kill_app()
npm_proc = await self.process_manager.start_process("npm run start &", show_output=False)
while True:
if count == 3:
await asyncio.sleep(5)
else:
await asyncio.sleep(2)
diff_stdout, diff_stderr = await npm_proc.read_output()
if (diff_stdout == "" and diff_stderr == "") or count <= 0:
break
count -= 1
await self.process_manager.run_command("curl http://localhost:5173", show_output=False)
await asyncio.sleep(1)
diff_stdout, diff_stderr = await npm_proc.read_output()
# kill app again
await self.kill_app()
if diff_stdout or diff_stderr:
log.debug(f"Auto-debugging output:\n{diff_stdout}\n{diff_stderr}")
return f"I got an error. Here are the logs:\n{diff_stdout}\n{diff_stderr}"
except Exception as e:
capture_exception(e)
log.error(f"Error during auto-debugging: {e}", exc_info=True)
return ""