From da6317a242c375ffa2a22039f47a81044c4d7a6e Mon Sep 17 00:00:00 2001 From: Siddhant Rai <47355538+siiddhantt@users.noreply.github.com> Date: Tue, 7 Oct 2025 15:30:14 +0530 Subject: [PATCH 1/4] feat: agent templates and seeding premade agents (#1910) * feat: agent templates and seeding premade agents * fix: ensure ObjectId is used for source reference in agent configuration * fix: improve source handling in DatabaseSeeder and update tool config processing * feat: add prompt handling in DatabaseSeeder for agent configuration * Docs premade agents * link to prescraped docs * feat: add template agent retrieval and adopt agent functionality * feat: simplify agent descriptions in premade_agents.yaml added docs --------- Co-authored-by: Pavel Co-authored-by: Alex --- application/agents/react_agent.py | 145 ++++-- application/api/user/agents/routes.py | 64 +++ application/seed/__init__.py | 0 application/seed/commands.py | 26 ++ application/seed/config/agents_template.yaml | 36 ++ application/seed/config/premade_agents.yaml | 94 ++++ application/seed/seeder.py | 277 +++++++++++ application/utils.py | 4 + application/worker.py | 9 +- docs/pages/Agents/basics.mdx | 10 + frontend/src/agents/AgentCard.tsx | 235 ++++++++-- frontend/src/agents/AgentsList.tsx | 134 ++++++ frontend/src/agents/NewAgent.tsx | 57 +-- frontend/src/agents/agentPreviewSlice.ts | 17 +- frontend/src/agents/agents.config.ts | 42 ++ frontend/src/agents/index.tsx | 456 +------------------ frontend/src/api/endpoints.ts | 2 + frontend/src/api/services/userService.ts | 4 + frontend/src/assets/duplicate.svg | 4 + frontend/src/preferences/preferenceSlice.ts | 8 + frontend/src/store.ts | 3 +- 21 files changed, 1053 insertions(+), 574 deletions(-) create mode 100644 application/seed/__init__.py create mode 100644 application/seed/commands.py create mode 100644 application/seed/config/agents_template.yaml create mode 100644 application/seed/config/premade_agents.yaml create mode 100644 application/seed/seeder.py create mode 100644 frontend/src/agents/AgentsList.tsx create mode 100644 frontend/src/agents/agents.config.ts create mode 100644 frontend/src/assets/duplicate.svg diff --git a/application/agents/react_agent.py b/application/agents/react_agent.py index 60646492..d07660d2 100644 --- a/application/agents/react_agent.py +++ b/application/agents/react_agent.py @@ -20,9 +20,10 @@ with open( "r", ) as f: final_prompt_template = f.read() - + MAX_ITERATIONS_REASONING = 10 + class ReActAgent(BaseAgent): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -38,49 +39,69 @@ class ReActAgent(BaseAgent): collected_content = [] if isinstance(resp, str): collected_content.append(resp) - elif ( # OpenAI non-streaming or Anthropic non-streaming (older SDK style) + elif ( # OpenAI non-streaming or Anthropic non-streaming (older SDK style) hasattr(resp, "message") and hasattr(resp.message, "content") and resp.message.content is not None ): collected_content.append(resp.message.content) - elif ( # OpenAI non-streaming (Pydantic model), Anthropic new SDK non-streaming - hasattr(resp, "choices") and resp.choices and - hasattr(resp.choices[0], "message") and - hasattr(resp.choices[0].message, "content") and - resp.choices[0].message.content is not None + elif ( # OpenAI non-streaming (Pydantic model), Anthropic new SDK non-streaming + hasattr(resp, "choices") + and resp.choices + and hasattr(resp.choices[0], "message") + and hasattr(resp.choices[0].message, "content") + and resp.choices[0].message.content is not None ): - collected_content.append(resp.choices[0].message.content) # OpenAI - elif ( # Anthropic new SDK non-streaming content block - hasattr(resp, "content") and isinstance(resp.content, list) and resp.content and - hasattr(resp.content[0], "text") + collected_content.append(resp.choices[0].message.content) # OpenAI + elif ( # Anthropic new SDK non-streaming content block + hasattr(resp, "content") + and isinstance(resp.content, list) + and resp.content + and hasattr(resp.content[0], "text") ): - collected_content.append(resp.content[0].text) # Anthropic + collected_content.append(resp.content[0].text) # Anthropic else: # Assume resp is a stream if not a recognized object + chunk = None try: - for chunk in resp: # This will fail if resp is not iterable (e.g. a non-streaming response object) + for ( + chunk + ) in ( + resp + ): # This will fail if resp is not iterable (e.g. a non-streaming response object) content_piece = "" # OpenAI-like stream - if hasattr(chunk, 'choices') and len(chunk.choices) > 0 and \ - hasattr(chunk.choices[0], 'delta') and \ - hasattr(chunk.choices[0].delta, 'content') and \ - chunk.choices[0].delta.content is not None: + if ( + hasattr(chunk, "choices") + and len(chunk.choices) > 0 + and hasattr(chunk.choices[0], "delta") + and hasattr(chunk.choices[0].delta, "content") + and chunk.choices[0].delta.content is not None + ): content_piece = chunk.choices[0].delta.content # Anthropic-like stream (ContentBlockDelta) - elif hasattr(chunk, 'type') and chunk.type == 'content_block_delta' and \ - hasattr(chunk, 'delta') and hasattr(chunk.delta, 'text'): + elif ( + hasattr(chunk, "type") + and chunk.type == "content_block_delta" + and hasattr(chunk, "delta") + and hasattr(chunk.delta, "text") + ): content_piece = chunk.delta.text - elif isinstance(chunk, str): # Simplest case: stream of strings + elif isinstance(chunk, str): # Simplest case: stream of strings content_piece = chunk if content_piece: collected_content.append(content_piece) - except TypeError: # If resp is not iterable (e.g. a final response object that wasn't caught above) - logger.debug(f"Response type {type(resp)} could not be iterated as a stream. It might be a non-streaming object not handled by specific checks.") + except ( + TypeError + ): # If resp is not iterable (e.g. a final response object that wasn't caught above) + logger.debug( + f"Response type {type(resp)} could not be iterated as a stream. It might be a non-streaming object not handled by specific checks." + ) except Exception as e: - logger.error(f"Error processing potential stream chunk: {e}, chunk was: {getattr(chunk, '__dict__', chunk)}") - + logger.error( + f"Error processing potential stream chunk: {e}, chunk was: {getattr(chunk, '__dict__', chunk) if chunk is not None else 'N/A'}" + ) return "".join(collected_content) @@ -112,8 +133,9 @@ class ReActAgent(BaseAgent): yield {"thought": line_chunk} self.plan = "".join(current_plan_parts) if self.plan: - self.observations.append(f"Plan: {self.plan} Iteration: {iterating_reasoning}") - + self.observations.append( + f"Plan: {self.plan} Iteration: {iterating_reasoning}" + ) max_obs_len = 20000 obs_str = "\n".join(self.observations) @@ -125,34 +147,55 @@ class ReActAgent(BaseAgent): + f"\n\nObservations:\n{obs_str}" + f"\n\nIf there is enough data to complete user query '{query}', Respond with 'SATISFIED' only. Otherwise, continue. Dont Menstion 'SATISFIED' in your response if you are not ready. " ) - + messages = self._build_messages(execution_prompt_str, query, retrieved_data) resp_from_llm_gen = self._llm_gen(messages, log_context) - initial_llm_thought_content = self._extract_content_from_llm_response(resp_from_llm_gen) + initial_llm_thought_content = self._extract_content_from_llm_response( + resp_from_llm_gen + ) if initial_llm_thought_content: - self.observations.append(f"Initial thought/response: {initial_llm_thought_content}") + self.observations.append( + f"Initial thought/response: {initial_llm_thought_content}" + ) else: - logger.info("ReActAgent: Initial LLM response (before handler) had no textual content (might be only tool calls).") - resp_after_handler = self._llm_handler(resp_from_llm_gen, tools_dict, messages, log_context) - - for tool_call_info in self.tool_calls: # Iterate over self.tool_calls populated by _llm_handler + logger.info( + "ReActAgent: Initial LLM response (before handler) had no textual content (might be only tool calls)." + ) + resp_after_handler = self._llm_handler( + resp_from_llm_gen, tools_dict, messages, log_context + ) + + for ( + tool_call_info + ) in ( + self.tool_calls + ): # Iterate over self.tool_calls populated by _llm_handler observation_string = ( f"Executed Action: Tool '{tool_call_info.get('tool_name', 'N/A')}' " f"with arguments '{tool_call_info.get('arguments', '{}')}'. Result: '{str(tool_call_info.get('result', ''))[:200]}...'" ) self.observations.append(observation_string) - content_after_handler = self._extract_content_from_llm_response(resp_after_handler) + content_after_handler = self._extract_content_from_llm_response( + resp_after_handler + ) if content_after_handler: - self.observations.append(f"Response after tool execution: {content_after_handler}") + self.observations.append( + f"Response after tool execution: {content_after_handler}" + ) else: - logger.info("ReActAgent: LLM response after handler had no textual content.") + logger.info( + "ReActAgent: LLM response after handler had no textual content." + ) if log_context: log_context.stacks.append( - {"component": "agent_tool_calls", "data": {"tool_calls": self.tool_calls.copy()}} + { + "component": "agent_tool_calls", + "data": {"tool_calls": self.tool_calls.copy()}, + } ) yield {"sources": retrieved_data} @@ -165,13 +208,17 @@ class ReActAgent(BaseAgent): display_tool_calls.append(cleaned_tc) if display_tool_calls: yield {"tool_calls": display_tool_calls} - + if "SATISFIED" in content_after_handler: - logger.info("ReActAgent: LLM satisfied with the plan and data. Stopping reasoning.") + logger.info( + "ReActAgent: LLM satisfied with the plan and data. Stopping reasoning." + ) break # 3. Create Final Answer based on all observations - final_answer_stream = self._create_final_answer(query, self.observations, log_context) + final_answer_stream = self._create_final_answer( + query, self.observations, log_context + ) for answer_chunk in final_answer_stream: yield {"answer": answer_chunk} logger.info("ReActAgent: Finished generating final answer.") @@ -184,12 +231,16 @@ class ReActAgent(BaseAgent): summaries = docs_data if docs_data else "No documents retrieved." plan_prompt_filled = plan_prompt_filled.replace("{summaries}", summaries) plan_prompt_filled = plan_prompt_filled.replace("{prompt}", self.prompt or "") - plan_prompt_filled = plan_prompt_filled.replace("{observations}", "\n".join(self.observations)) + plan_prompt_filled = plan_prompt_filled.replace( + "{observations}", "\n".join(self.observations) + ) messages = [{"role": "user", "content": plan_prompt_filled}] plan_stream_from_llm = self.llm.gen_stream( - model=self.gpt_model, messages=messages, tools=getattr(self, 'tools', None) # Use self.tools + model=self.gpt_model, + messages=messages, + tools=getattr(self, "tools", None), # Use self.tools ) if log_context: data = build_stack_data(self.llm) @@ -206,8 +257,12 @@ class ReActAgent(BaseAgent): observation_string = "\n".join(observations) max_obs_len = 10000 if len(observation_string) > max_obs_len: - observation_string = observation_string[:max_obs_len] + "\n...[observations truncated]" - logger.warning("ReActAgent: Truncated observations for final answer prompt due to length.") + observation_string = ( + observation_string[:max_obs_len] + "\n...[observations truncated]" + ) + logger.warning( + "ReActAgent: Truncated observations for final answer prompt due to length." + ) final_answer_prompt_filled = final_prompt_template.format( query=query, observations=observation_string @@ -226,4 +281,4 @@ class ReActAgent(BaseAgent): for chunk in final_answer_stream_from_llm: content_piece = self._extract_content_from_llm_response(chunk) if content_piece: - yield content_piece \ No newline at end of file + yield content_piece diff --git a/application/api/user/agents/routes.py b/application/api/user/agents/routes.py index da76f906..6755a647 100644 --- a/application/api/user/agents/routes.py +++ b/application/api/user/agents/routes.py @@ -822,6 +822,70 @@ class PinnedAgents(Resource): return make_response(jsonify(list_pinned_agents), 200) +@agents_ns.route("/template_agents") +class GetTemplateAgents(Resource): + @api.doc(description="Get template/premade agents") + def get(self): + try: + template_agents = agents_collection.find({"user": "system"}) + template_agents = [ + { + "id": str(agent["_id"]), + "name": agent["name"], + "description": agent["description"], + "image": agent.get("image", ""), + } + for agent in template_agents + ] + return make_response(jsonify(template_agents), 200) + except Exception as e: + current_app.logger.error(f"Template agents fetch error: {e}", exc_info=True) + return make_response(jsonify({"success": False}), 400) + + +@agents_ns.route("/adopt_agent") +class AdoptAgent(Resource): + @api.doc(params={"id": "Agent ID"}, description="Adopt an agent by ID") + def post(self): + if not (decoded_token := request.decoded_token): + return make_response(jsonify({"success": False}), 401) + + if not (agent_id := request.args.get("id")): + return make_response( + jsonify({"success": False, "message": "ID required"}), 400 + ) + + try: + agent = agents_collection.find_one( + {"_id": ObjectId(agent_id), "user": "system"} + ) + if not agent: + return make_response(jsonify({"status": "Not found"}), 404) + + new_agent = agent.copy() + new_agent.pop("_id", None) + new_agent["user"] = decoded_token["sub"] + new_agent["status"] = "published" + new_agent["lastUsedAt"] = datetime.datetime.now(datetime.timezone.utc) + new_agent["key"] = str(uuid.uuid4()) + insert_result = agents_collection.insert_one(new_agent) + + response_agent = new_agent.copy() + response_agent.pop("_id", None) + response_agent["id"] = str(insert_result.inserted_id) + response_agent["tool_details"] = resolve_tool_details( + response_agent.get("tools", []) + ) + if isinstance(response_agent.get("source"), DBRef): + response_agent["source"] = str(response_agent["source"].id) + return make_response( + jsonify({"success": True, "agent": response_agent}), 200 + ) + except Exception as e: + current_app.logger.error(f"Agent adopt error: {e}", exc_info=True) + return make_response(jsonify({"success": False}), 400) + + @agents_ns.route("/pin_agent") class PinAgent(Resource): @api.doc(params={"id": "ID of the agent"}, description="Pin or unpin an agent") diff --git a/application/seed/__init__.py b/application/seed/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/application/seed/commands.py b/application/seed/commands.py new file mode 100644 index 00000000..61a1295e --- /dev/null +++ b/application/seed/commands.py @@ -0,0 +1,26 @@ +import click + +from application.core.mongo_db import MongoDB +from application.core.settings import settings +from application.seed.seeder import DatabaseSeeder + + +@click.group() +def seed(): + """Database seeding commands""" + pass + + +@seed.command() +@click.option("--force", is_flag=True, help="Force reseeding even if data exists") +def init(force): + """Initialize database with seed data""" + mongo = MongoDB.get_client() + db = mongo[settings.MONGO_DB_NAME] + + seeder = DatabaseSeeder(db) + seeder.seed_initial_data(force=force) + + +if __name__ == "__main__": + seed() diff --git a/application/seed/config/agents_template.yaml b/application/seed/config/agents_template.yaml new file mode 100644 index 00000000..5548cd0a --- /dev/null +++ b/application/seed/config/agents_template.yaml @@ -0,0 +1,36 @@ +# Configuration for Premade Agents +# This file contains template agents that will be seeded into the database + +agents: + # Basic Agent Template + - name: "Agent Name" # Required: Unique name for the agent + description: "What this agent does" # Required: Brief description of the agent's purpose + image: "URL_TO_IMAGE" # Optional: URL to agent's avatar/image + agent_type: "classic" # Required: Type of agent (e.g., classic, react, etc.) + prompt_id: "default" # Optional: Reference to prompt template + prompt: # Optional: Define new prompt + name: "New Prompt" + content: "You are new agent with cool new prompt." + chunks: "0" # Optional: Chunking strategy for documents + retriever: "" # Optional: Retriever type for document search + + # Source Configuration (where the agent gets its knowledge) + source: # Optional: Select a source to link with agent + name: "Source Display Name" # Human-readable name for the source + url: "https://example.com/data-source" # URL or path to knowledge source + loader: "url" # Type of loader (url, pdf, txt, etc.) + + # Tools Configuration (what capabilities the agent has) + tools: # Optional: Remove if agent doesn't need tools + - name: "tool_name" # Must match a supported tool name + display_name: "Tool Display Name" # Optional: Human-readable name for the tool + config: + # Tool-specific configuration + # Example for DuckDuckGo: + # token: "${DDG_API_KEY}" # ${} denotes environment variable + + # Add more tools as needed + # - name: "another_tool" + # config: + # param1: "value1" + # param2: "${ENV_VAR}" \ No newline at end of file diff --git a/application/seed/config/premade_agents.yaml b/application/seed/config/premade_agents.yaml new file mode 100644 index 00000000..cd895bec --- /dev/null +++ b/application/seed/config/premade_agents.yaml @@ -0,0 +1,94 @@ +# Configuration for Premade Agents + +agents: + - name: "Assistant" + description: "Your general-purpose AI assistant. Ready to help with a wide range of tasks." + image: "https://d3dg1063dc54p9.cloudfront.net/imgs/agents/agent-logo.svg" + agent_type: "classic" + prompt_id: "default" + chunks: "0" + retriever: "" + + # Tools Configuration + tools: + - name: "tool_name" + display_name: "read_webpage" + config: + + - name: "Researcher" + description: "A specialized research agent that performs deep dives into subjects." + image: "https://d3dg1063dc54p9.cloudfront.net/imgs/agents/agent-researcher.svg" + agent_type: "react" + prompt: + name: "Researcher-Agent" + content: | + You are a specialized AI research assistant, DocsGPT. Your primary function is to conduct in-depth research on a given subject or question. You are methodical, thorough, and analytical. You should perform multiple iterations of thinking to gather and synthesize information before providing a final, comprehensive answer. + + You have access to the 'Read Webpage' tool. Use this tool to explore sources, gather data, and deepen your understanding. Be proactive in using the tool to fill in knowledge gaps and validate information. + + Users can Upload documents for your context as attachments or sources via UI using the Conversation input box. + If appropriate, your answers can include code examples, formatted as follows: + ```(language) + (code) + ``` + Users are also able to see charts and diagrams if you use them with valid mermaid syntax in your responses. Try to respond with mermaid charts if visualization helps with users queries. You effectively utilize chat history, ensuring relevant and tailored responses. Try to use additional provided context if it's available, otherwise use your knowledge and tool capabilities. + ---------------- + Possible additional context from uploaded sources: + {summaries} + + chunks: "0" + retriever: "" + + # Tools Configuration + tools: + - name: "tool_name" + display_name: "read_webpage" + config: + + - name: "Search Widget" + description: "A powerful search widget agent. Ask it anything about DocsGPT" + image: "https://d3dg1063dc54p9.cloudfront.net/imgs/agents/agent-search.svg" + agent_type: "classic" + prompt: + name: "Search-Agent" + content: | + You are a website search assistant, DocsGPT. Your sole purpose is to help users find information within the provided context of the DocsGPT documentation. Act as a specialized search engine. + + Your answers must be based *only* on the provided context. Do not use any external knowledge. If the answer is not in the context, inform the user that you could not find the information within the documentation. + + Keep your responses concise and directly related to the user's query, pointing them to the most relevant information. + ---------------- + Possible additional context from uploaded sources: + {summaries} + + chunks: "8" + retriever: "" + + source: + name: "DocsGPT-Docs" + url: "https://d3dg1063dc54p9.cloudfront.net/agent-source/docsgpt-documentation.md" # URL to DocsGPT documentation + loader: "url" + + - name: "Support Widget" + description: "A friendly support widget agent to help you with any questions." + image: "https://d3dg1063dc54p9.cloudfront.net/imgs/agents/agent-support.svg" + agent_type: "classic" + prompt: + name: "Support-Agent" + content: | + You are a helpful AI support widget agent, DocsGPT. Your goal is to assist users by answering their questions about our website, product and its features. Provide friendly, clear, and direct support. + + Your knowledge is strictly limited to the provided context from the DocsGPT documentation. You must not answer questions outside of this scope. If a user asks something you cannot answer from the context, politely state that you can only help with questions about this website. + + Effectively utilize chat history to understand the user's issue fully. Guide users to the information they need in a helpful and conversational manner. + ---------------- + Possible additional context from uploaded sources: + {summaries} + + chunks: "8" + retriever: "" + + source: + name: "DocsGPT-Docs" + url: "https://d3dg1063dc54p9.cloudfront.net/agent-source/docsgpt-documentation.md" # URL to DocsGPT documentation + loader: "url" \ No newline at end of file diff --git a/application/seed/seeder.py b/application/seed/seeder.py new file mode 100644 index 00000000..70cf8766 --- /dev/null +++ b/application/seed/seeder.py @@ -0,0 +1,277 @@ +import logging +import os +from datetime import datetime, timezone +from typing import Dict, List, Optional, Union + +import yaml +from bson import ObjectId +from bson.dbref import DBRef + +from dotenv import load_dotenv +from pymongo import MongoClient + +from application.agents.tools.tool_manager import ToolManager +from application.api.user.tasks import ingest_remote + +load_dotenv() +tool_config = {} +tool_manager = ToolManager(config=tool_config) + + +class DatabaseSeeder: + def __init__(self, db): + self.db = db + self.tools_collection = self.db["user_tools"] + self.sources_collection = self.db["sources"] + self.agents_collection = self.db["agents"] + self.prompts_collection = self.db["prompts"] + self.system_user_id = "system" + self.logger = logging.getLogger(__name__) + + def seed_initial_data(self, config_path: str = None, force=False): + """Main entry point for seeding all initial data""" + if not force and self._is_already_seeded(): + self.logger.info("Database already seeded. Use force=True to reseed.") + return + config_path = config_path or os.path.join( + os.path.dirname(__file__), "config", "premade_agents.yaml" + ) + + try: + with open(config_path, "r") as f: + config = yaml.safe_load(f) + self._seed_from_config(config) + except Exception as e: + self.logger.error(f"Failed to load seeding config: {str(e)}") + raise + + def _seed_from_config(self, config: Dict): + """Seed all data from configuration""" + self.logger.info("🌱 Starting seeding...") + + if not config.get("agents"): + self.logger.warning("No agents found in config") + return + used_tool_ids = set() + + for agent_config in config["agents"]: + try: + self.logger.info(f"Processing agent: {agent_config['name']}") + + # 1. Handle Source + + source_result = self._handle_source(agent_config) + if source_result is False: + self.logger.error( + f"Skipping agent {agent_config['name']} due to source ingestion failure" + ) + continue + source_id = source_result + # 2. Handle Tools + + tool_ids = self._handle_tools(agent_config) + if len(tool_ids) == 0: + self.logger.warning( + f"No valid tools for agent {agent_config['name']}" + ) + used_tool_ids.update(tool_ids) + + # 3. Handle Prompt + + prompt_id = self._handle_prompt(agent_config) + + # 4. Create Agent + + agent_data = { + "user": self.system_user_id, + "name": agent_config["name"], + "description": agent_config["description"], + "image": agent_config.get("image", ""), + "source": ( + DBRef("sources", ObjectId(source_id)) if source_id else "" + ), + "tools": [str(tid) for tid in tool_ids], + "agent_type": agent_config["agent_type"], + "prompt_id": prompt_id or agent_config.get("prompt_id", "default"), + "chunks": agent_config.get("chunks", "0"), + "retriever": agent_config.get("retriever", ""), + "status": "template", + "createdAt": datetime.now(timezone.utc), + "updatedAt": datetime.now(timezone.utc), + } + + existing = self.agents_collection.find_one( + {"user": self.system_user_id, "name": agent_config["name"]} + ) + if existing: + self.logger.info(f"Updating existing agent: {agent_config['name']}") + self.agents_collection.update_one( + {"_id": existing["_id"]}, {"$set": agent_data} + ) + agent_id = existing["_id"] + else: + self.logger.info(f"Creating new agent: {agent_config['name']}") + result = self.agents_collection.insert_one(agent_data) + agent_id = result.inserted_id + self.logger.info( + f"Successfully processed agent: {agent_config['name']} (ID: {agent_id})" + ) + except Exception as e: + self.logger.error( + f"Error processing agent {agent_config['name']}: {str(e)}" + ) + continue + self.logger.info("✅ Database seeding completed") + + def _handle_source(self, agent_config: Dict) -> Union[ObjectId, None, bool]: + """Handle source ingestion and return source ID""" + if not agent_config.get("source"): + self.logger.info( + "No source provided for agent - will create agent without source" + ) + return None + source_config = agent_config["source"] + self.logger.info(f"Ingesting source: {source_config['url']}") + + try: + existing = self.sources_collection.find_one( + {"user": self.system_user_id, "remote_data": source_config["url"]} + ) + if existing: + self.logger.info(f"Source already exists: {existing['_id']}") + return existing["_id"] + # Ingest new source using worker + + task = ingest_remote.delay( + source_data=source_config["url"], + job_name=source_config["name"], + user=self.system_user_id, + loader=source_config.get("loader", "url"), + ) + + result = task.get(timeout=300) + + if not task.successful(): + raise Exception(f"Source ingestion failed: {result}") + source_id = None + if isinstance(result, dict) and "id" in result: + source_id = result["id"] + else: + raise Exception(f"Source ingestion result missing 'id': {result}") + self.logger.info(f"Source ingested successfully: {source_id}") + return source_id + except Exception as e: + self.logger.error(f"Failed to ingest source: {str(e)}") + return False + + def _handle_tools(self, agent_config: Dict) -> List[ObjectId]: + """Handle tool creation and return list of tool IDs""" + tool_ids = [] + if not agent_config.get("tools"): + return tool_ids + for tool_config in agent_config["tools"]: + try: + tool_name = tool_config["name"] + processed_config = self._process_config(tool_config.get("config", {})) + self.logger.info(f"Processing tool: {tool_name}") + + existing = self.tools_collection.find_one( + { + "user": self.system_user_id, + "name": tool_name, + "config": processed_config, + } + ) + if existing: + self.logger.info(f"Tool already exists: {existing['_id']}") + tool_ids.append(existing["_id"]) + continue + tool_data = { + "user": self.system_user_id, + "name": tool_name, + "displayName": tool_config.get("display_name", tool_name), + "description": tool_config.get("description", ""), + "actions": tool_manager.tools[tool_name].get_actions_metadata(), + "config": processed_config, + "status": True, + } + + result = self.tools_collection.insert_one(tool_data) + tool_ids.append(result.inserted_id) + self.logger.info(f"Created new tool: {result.inserted_id}") + except Exception as e: + self.logger.error(f"Failed to process tool {tool_name}: {str(e)}") + continue + return tool_ids + + def _handle_prompt(self, agent_config: Dict) -> Optional[str]: + """Handle prompt creation and return prompt ID""" + if not agent_config.get("prompt"): + return None + + prompt_config = agent_config["prompt"] + prompt_name = prompt_config.get("name", f"{agent_config['name']} Prompt") + prompt_content = prompt_config.get("content", "") + + if not prompt_content: + self.logger.warning( + f"No prompt content provided for agent {agent_config['name']}" + ) + return None + + self.logger.info(f"Processing prompt: {prompt_name}") + + try: + existing = self.prompts_collection.find_one( + { + "user": self.system_user_id, + "name": prompt_name, + "content": prompt_content, + } + ) + if existing: + self.logger.info(f"Prompt already exists: {existing['_id']}") + return str(existing["_id"]) + + prompt_data = { + "name": prompt_name, + "content": prompt_content, + "user": self.system_user_id, + } + + result = self.prompts_collection.insert_one(prompt_data) + prompt_id = str(result.inserted_id) + self.logger.info(f"Created new prompt: {prompt_id}") + return prompt_id + + except Exception as e: + self.logger.error(f"Failed to process prompt {prompt_name}: {str(e)}") + return None + + def _process_config(self, config: Dict) -> Dict: + """Process config values to replace environment variables""" + processed = {} + for key, value in config.items(): + if ( + isinstance(value, str) + and value.startswith("${") + and value.endswith("}") + ): + env_var = value[2:-1] + processed[key] = os.getenv(env_var, "") + else: + processed[key] = value + return processed + + def _is_already_seeded(self) -> bool: + """Check if premade agents already exist""" + return self.agents_collection.count_documents({"user": self.system_user_id}) > 0 + + @classmethod + def initialize_from_env(cls, worker=None): + """Factory method to create seeder from environment""" + mongo_uri = os.getenv("MONGO_URI", "mongodb://localhost:27017") + db_name = os.getenv("MONGO_DB_NAME", "docsgpt") + client = MongoClient(mongo_uri) + db = client[db_name] + return cls(db) diff --git a/application/utils.py b/application/utils.py index d4f0a362..5ef28376 100644 --- a/application/utils.py +++ b/application/utils.py @@ -168,6 +168,10 @@ def validate_function_name(function_name): def generate_image_url(image_path): + if isinstance(image_path, str) and ( + image_path.startswith("http://") or image_path.startswith("https://") + ): + return image_path strategy = getattr(settings, "URL_STRATEGY", "backend") if strategy == "s3": bucket_name = getattr(settings, "S3_BUCKET_NAME", "docsgpt-test-bucket") diff --git a/application/worker.py b/application/worker.py index 81909fc3..f17e1537 100755 --- a/application/worker.py +++ b/application/worker.py @@ -39,6 +39,7 @@ sources_collection = db["sources"] # Constants + MIN_TOKENS = 150 MAX_TOKENS = 1250 RECURSION_DEPTH = 2 @@ -740,7 +741,13 @@ def remote_worker( if os.path.exists(full_path): shutil.rmtree(full_path) logging.info("remote_worker task completed successfully") - return {"urls": source_data, "name_job": name_job, "user": user, "limited": False} + return { + "id": str(id), + "urls": source_data, + "name_job": name_job, + "user": user, + "limited": False, + } def sync( diff --git a/docs/pages/Agents/basics.mdx b/docs/pages/Agents/basics.mdx index cc67c2df..d701d998 100644 --- a/docs/pages/Agents/basics.mdx +++ b/docs/pages/Agents/basics.mdx @@ -107,3 +107,13 @@ Once an agent is created, you can: * Modify any of its configuration settings (name, description, source, prompt, tools, type). * **Generate a Public Link:** From the edit screen, you can create a shareable public link that allows others to import and use your agent. * **Get a Webhook URL:** You can also obtain a Webhook URL for the agent. This allows external applications or services to trigger the agent and receive responses programmatically, enabling powerful integrations and automations. + +## Seeding Premade Agents from YAML + +You can bootstrap a fresh DocsGPT deployment with a curated set of agents by seeding them directly into MongoDB. + +1. **Customize the configuration** – edit `application/seed/config/premade_agents.yaml` (or copy from `application/seed/config/agents_template.yaml`) to describe the agents you want to provision. Each entry lets you define prompts, tools, and optional data sources. +2. **Ensure dependencies are running** – MongoDB must be reachable using the credentials in `.env`, and a Celery worker should be available if any agent sources need to be ingested via `ingest_remote`. +3. **Execute the seeder** – run `python -m application.seed.commands init`. Add `--force` when you need to reseed an existing environment. + +The seeder keeps templates under the `system` user so they appear in the UI for anyone to clone or customize. Environment variable placeholders such as `${MY_TOKEN}` inside tool configs are resolved during the seeding process. diff --git a/frontend/src/agents/AgentCard.tsx b/frontend/src/agents/AgentCard.tsx index 90302c71..536aad4d 100644 --- a/frontend/src/agents/AgentCard.tsx +++ b/frontend/src/agents/AgentCard.tsx @@ -1,14 +1,22 @@ -import { useRef, useState } from 'react'; +import { SyntheticEvent, useRef, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useNavigate } from 'react-router-dom'; import userService from '../api/services/userService'; -import AgentImage from '../components/AgentImage'; +import Duplicate from '../assets/duplicate.svg'; +import Edit from '../assets/edit.svg'; +import Link from '../assets/link-gray.svg'; +import Monitoring from '../assets/monitoring.svg'; +import Pin from '../assets/pin.svg'; +import Trash from '../assets/red-trash.svg'; import ThreeDots from '../assets/three-dots.svg'; +import UnPin from '../assets/unpin.svg'; +import AgentImage from '../components/AgentImage'; import ContextMenu, { MenuOption } from '../components/ContextMenu'; import ConfirmationModal from '../modals/ConfirmationModal'; import { ActiveState } from '../models/misc'; import { + selectAgents, selectToken, setAgents, setSelectedAgent, @@ -18,46 +26,205 @@ import { Agent } from './types'; type AgentCardProps = { agent: Agent; agents: Agent[]; - menuOptions?: MenuOption[]; - onDelete?: (agentId: string) => void; + updateAgents?: (agents: Agent[]) => void; + section: string; }; export default function AgentCard({ agent, agents, - menuOptions, - onDelete, + updateAgents, + section, }: AgentCardProps) { const navigate = useNavigate(); const dispatch = useDispatch(); const token = useSelector(selectToken); + const userAgents = useSelector(selectAgents); - const [isMenuOpen, setIsMenuOpen] = useState(false); + const [isMenuOpen, setIsMenuOpen] = useState(false); const [deleteConfirmation, setDeleteConfirmation] = useState('INACTIVE'); const menuRef = useRef(null); - const handleCardClick = () => { - if (agent.status === 'published') { - dispatch(setSelectedAgent(agent)); - navigate('/'); + const menuOptionsConfig: Record = { + template: [ + { + icon: Duplicate, + label: 'Duplicate', + onClick: (e: SyntheticEvent) => { + e.stopPropagation(); + handleDuplicate(); + }, + variant: 'primary', + iconWidth: 18, + iconHeight: 18, + }, + ], + user: [ + { + icon: Monitoring, + label: 'Logs', + onClick: (e: SyntheticEvent) => { + e.stopPropagation(); + navigate(`/agents/logs/${agent.id}`); + }, + variant: 'primary', + iconWidth: 14, + iconHeight: 14, + }, + { + icon: Edit, + label: 'Edit', + onClick: (e: SyntheticEvent) => { + e.stopPropagation(); + navigate(`/agents/edit/${agent.id}`); + }, + variant: 'primary', + iconWidth: 14, + iconHeight: 14, + }, + ...(agent.status === 'published' + ? [ + { + icon: agent.pinned ? UnPin : Pin, + label: agent.pinned ? 'Unpin' : 'Pin agent', + onClick: (e: SyntheticEvent) => { + e.stopPropagation(); + togglePin(); + }, + variant: 'primary' as const, + iconWidth: 18, + iconHeight: 18, + }, + ] + : []), + { + icon: Trash, + label: 'Delete', + onClick: (e: SyntheticEvent) => { + e.stopPropagation(); + setDeleteConfirmation('ACTIVE'); + }, + variant: 'danger', + iconWidth: 13, + iconHeight: 13, + }, + ], + shared: [ + { + icon: Link, + label: 'Open', + onClick: (e: SyntheticEvent) => { + e.stopPropagation(); + navigate(`/agents/shared/${agent.shared_token}`); + }, + variant: 'primary', + iconWidth: 12, + iconHeight: 12, + }, + { + icon: agent.pinned ? UnPin : Pin, + label: agent.pinned ? 'Unpin' : 'Pin agent', + onClick: (e: SyntheticEvent) => { + e.stopPropagation(); + togglePin(); + }, + variant: 'primary', + iconWidth: 18, + iconHeight: 18, + }, + { + icon: Trash, + label: 'Remove', + onClick: (e: SyntheticEvent) => { + e.stopPropagation(); + handleHideSharedAgent(); + }, + variant: 'danger', + iconWidth: 13, + iconHeight: 13, + }, + ], + }; + const menuOptions = menuOptionsConfig[section] || []; + + const handleClick = () => { + if (section === 'user') { + if (agent.status === 'published') { + dispatch(setSelectedAgent(agent)); + navigate(`/`); + } + } + if (section === 'shared') { + navigate(`/agents/shared/${agent.shared_token}`); } }; - const defaultDelete = async (agentId: string) => { - const response = await userService.deleteAgent(agentId, token); - if (!response.ok) throw new Error('Failed to delete agent'); - const data = await response.json(); - dispatch(setAgents(agents.filter((prevAgent) => prevAgent.id !== data.id))); + const togglePin = async () => { + try { + const response = await userService.togglePinAgent(agent.id ?? '', token); + if (!response.ok) throw new Error('Failed to pin agent'); + const updatedAgents = agents.map((prevAgent) => { + if (prevAgent.id === agent.id) + return { ...prevAgent, pinned: !prevAgent.pinned }; + return prevAgent; + }); + updateAgents?.(updatedAgents); + } catch (error) { + console.error('Error:', error); + } }; + const handleHideSharedAgent = async () => { + try { + const response = await userService.removeSharedAgent( + agent.id ?? '', + token, + ); + if (!response.ok) throw new Error('Failed to hide shared agent'); + const updatedAgents = agents.filter( + (prevAgent) => prevAgent.id !== agent.id, + ); + updateAgents?.(updatedAgents); + } catch (error) { + console.error('Error:', error); + } + }; + + const handleDelete = async () => { + try { + const response = await userService.deleteAgent(agent.id ?? '', token); + if (!response.ok) throw new Error('Failed to delete agent'); + const updatedAgents = agents.filter( + (prevAgent) => prevAgent.id !== agent.id, + ); + updateAgents?.(updatedAgents); + } catch (error) { + console.error('Error:', error); + } + }; + + const handleDuplicate = async () => { + try { + const response = await userService.adoptAgent(agent.id ?? '', token); + if (!response.ok) throw new Error('Failed to duplicate agent'); + const data = await response.json(); + if (userAgents) { + const updatedAgents = [...userAgents, data.agent]; + dispatch(setAgents(updatedAgents)); + } else dispatch(setAgents([data.agent])); + } catch (error) { + console.error('Error:', error); + } + }; return (
{ + e.stopPropagation(); + handleClick(); + }} >
- options - {menuOptions && ( - - )} + {'use-agent'} +
-
{agent.status === 'draft' && ( -

- (Draft) -

+

{`(Draft)`}

)}
@@ -105,14 +267,13 @@ export default function AgentCard({

- { - onDelete ? onDelete(agent.id || '') : defaultDelete(agent.id || ''); + handleDelete(); setDeleteConfirmation('INACTIVE'); }} cancelLabel="Cancel" diff --git a/frontend/src/agents/AgentsList.tsx b/frontend/src/agents/AgentsList.tsx new file mode 100644 index 00000000..04957443 --- /dev/null +++ b/frontend/src/agents/AgentsList.tsx @@ -0,0 +1,134 @@ +import { useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useNavigate } from 'react-router-dom'; + +import Spinner from '../components/Spinner'; +import { + setConversation, + updateConversationId, +} from '../conversation/conversationSlice'; +import { + selectSelectedAgent, + selectToken, + setSelectedAgent, +} from '../preferences/preferenceSlice'; +import AgentCard from './AgentCard'; +import { agentSectionsConfig } from './agents.config'; +import { Agent } from './types'; + +export default function AgentsList() { + const dispatch = useDispatch(); + const token = useSelector(selectToken); + const selectedAgent = useSelector(selectSelectedAgent); + + useEffect(() => { + dispatch(setConversation([])); + dispatch( + updateConversationId({ + query: { conversationId: null }, + }), + ); + if (selectedAgent) dispatch(setSelectedAgent(null)); + }, [token]); + return ( +
+

+ Agents +

+

+ Discover and create custom versions of DocsGPT that combine + instructions, extra knowledge, and any combination of skills +

+ {agentSectionsConfig.map((sectionConfig) => ( + + ))} +
+ ); +} + +function AgentSection({ + config, +}: { + config: (typeof agentSectionsConfig)[number]; +}) { + const navigate = useNavigate(); + const dispatch = useDispatch(); + const token = useSelector(selectToken); + const agents = useSelector(config.selectData); + + const [loading, setLoading] = useState(true); + + const updateAgents = (updatedAgents: Agent[]) => { + dispatch(config.updateAction(updatedAgents)); + }; + + useEffect(() => { + const getAgents = async () => { + setLoading(true); + try { + const response = await config.fetchAgents(token); + if (!response.ok) + throw new Error(`Failed to fetch ${config.id} agents`); + const data = await response.json(); + dispatch(config.updateAction(data)); + } catch (error) { + console.error(`Error fetching ${config.id} agents:`, error); + dispatch(config.updateAction([])); + } finally { + setLoading(false); + } + }; + getAgents(); + }, [token, config, dispatch]); + return ( +
+
+
+

+ {config.title} +

+

{config.description}

+
+ {config.showNewAgentButton && ( + + )} +
+
+ {loading ? ( +
+ +
+ ) : agents && agents.length > 0 ? ( +
+ {agents.map((agent) => ( + + ))} +
+ ) : ( +
+

{config.emptyStateDescription}

+ {config.showNewAgentButton && ( + + )} +
+ )} +
+
+ ); +} diff --git a/frontend/src/agents/NewAgent.tsx b/frontend/src/agents/NewAgent.tsx index 95b31606..21a1f3f5 100644 --- a/frontend/src/agents/NewAgent.tsx +++ b/frontend/src/agents/NewAgent.tsx @@ -23,7 +23,7 @@ import PromptsModal from '../preferences/PromptsModal'; import Prompts from '../settings/Prompts'; import { UserToolType } from '../settings/types'; import AgentPreview from './AgentPreview'; -import { Agent } from './types'; +import { Agent, ToolSummary } from './types'; const embeddingsName = import.meta.env.VITE_EMBEDDINGS_NAME || @@ -64,9 +64,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { const [selectedSourceIds, setSelectedSourceIds] = useState< Set >(new Set()); - const [selectedToolIds, setSelectedToolIds] = useState>( - new Set(), - ); + const [selectedTools, setSelectedTools] = useState([]); const [deleteConfirmation, setDeleteConfirmation] = useState('INACTIVE'); const [agentDetails, setAgentDetails] = useState('INACTIVE'); @@ -337,7 +335,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { const data = await response.json(); const tools: OptionType[] = data.tools.map((tool: UserToolType) => ({ id: tool.id, - label: tool.displayName, + label: tool.customName ? tool.customName : tool.displayName, icon: `/toolIcons/tool_${tool.name}.svg`, })); setUserTools(tools); @@ -410,7 +408,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { setSelectedSourceIds(new Set([data.retriever])); } - if (data.tools) setSelectedToolIds(new Set(data.tools)); + if (data.tool_details) setSelectedTools(data.tool_details); if (data.status === 'draft') setEffectiveMode('draft'); if (data.json_schema) { const jsonText = JSON.stringify(data.json_schema, null, 2); @@ -480,16 +478,13 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { }, [selectedSourceIds]); useEffect(() => { - const selectedTool = Array.from(selectedToolIds).map((id) => - userTools.find((tool) => tool.id === id), - ); setAgent((prev) => ({ ...prev, - tools: selectedTool + tools: Array.from(selectedTools) .map((tool) => tool?.id) .filter((id): id is string => typeof id === 'string'), })); - }, [selectedToolIds]); + }, [selectedTools]); useEffect(() => { if (isPublishable()) dispatch(setSelectedAgent(agent)); @@ -645,15 +640,15 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { > {selectedSourceIds.size > 0 ? Array.from(selectedSourceIds) - .map( - (id) => - sourceDocs?.find( - (source) => - source.id === id || - source.name === id || - source.retriever === id, - )?.name, - ) + .map((id) => { + const matchedDoc = sourceDocs?.find( + (source) => + source.id === id || + source.name === id || + source.retriever === id, + ); + return matchedDoc?.name || `External KB`; + }) .filter(Boolean) .join(', ') : 'Select sources'} @@ -768,16 +763,14 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { ref={toolAnchorButtonRef} onClick={() => setIsToolsPopupOpen(!isToolsPopupOpen)} className={`border-silver dark:bg-raisin-black w-full truncate rounded-3xl border bg-white px-5 py-3 text-left text-sm dark:border-[#7E7E7E] ${ - selectedToolIds.size > 0 + selectedTools.length > 0 ? 'text-jet dark:text-bright-gray' : 'dark:text-silver text-gray-400' }`} > - {selectedToolIds.size > 0 - ? Array.from(selectedToolIds) - .map( - (id) => userTools.find((tool) => tool.id === id)?.label, - ) + {selectedTools.length > 0 + ? selectedTools + .map((tool) => tool.display_name || tool.name) .filter(Boolean) .join(', ') : 'Select tools'} @@ -787,9 +780,17 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { onClose={() => setIsToolsPopupOpen(false)} anchorRef={toolAnchorButtonRef} options={userTools} - selectedIds={selectedToolIds} + selectedIds={new Set(selectedTools.map((tool) => tool.id))} onSelectionChange={(newSelectedIds: Set) => - setSelectedToolIds(newSelectedIds) + setSelectedTools( + userTools + .filter((tool) => newSelectedIds.has(tool.id)) + .map((tool) => ({ + id: String(tool.id), + name: tool.label, + display_name: tool.label, + })), + ) } title="Select Tools" searchPlaceholder="Search tools..." diff --git a/frontend/src/agents/agentPreviewSlice.ts b/frontend/src/agents/agentPreviewSlice.ts index bf449401..b08d9d2e 100644 --- a/frontend/src/agents/agentPreviewSlice.ts +++ b/frontend/src/agents/agentPreviewSlice.ts @@ -1,19 +1,20 @@ import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'; + +import { + handleFetchAnswer, + handleFetchAnswerSteaming, +} from '../conversation/conversationHandlers'; import { Answer, ConversationState, Query, Status, } from '../conversation/conversationModels'; -import { - handleFetchAnswer, - handleFetchAnswerSteaming, -} from '../conversation/conversationHandlers'; -import { - selectCompletedAttachments, - clearAttachments, -} from '../upload/uploadSlice'; import store from '../store'; +import { + clearAttachments, + selectCompletedAttachments, +} from '../upload/uploadSlice'; const initialState: ConversationState = { queries: [], diff --git a/frontend/src/agents/agents.config.ts b/frontend/src/agents/agents.config.ts new file mode 100644 index 00000000..b6be5015 --- /dev/null +++ b/frontend/src/agents/agents.config.ts @@ -0,0 +1,42 @@ +import userService from '../api/services/userService'; +import { + selectAgents, + selectTemplateAgents, + selectSharedAgents, + setAgents, + setTemplateAgents, + setSharedAgents, +} from '../preferences/preferenceSlice'; + +export const agentSectionsConfig = [ + { + id: 'template', + title: 'By DocsGPT', + description: 'Agents provided by DocsGPT', + showNewAgentButton: false, + emptyStateDescription: 'No template agents found.', + fetchAgents: (token: string | null) => userService.getTemplateAgents(token), + selectData: selectTemplateAgents, + updateAction: setTemplateAgents, + }, + { + id: 'user', + title: 'By me', + description: 'Agents created or published by you', + showNewAgentButton: true, + emptyStateDescription: 'You don’t have any created agents yet.', + fetchAgents: (token: string | null) => userService.getAgents(token), + selectData: selectAgents, + updateAction: setAgents, + }, + { + id: 'shared', + title: 'Shared with me', + description: 'Agents imported by using a public link', + showNewAgentButton: false, + emptyStateDescription: 'No shared agents found.', + fetchAgents: (token: string | null) => userService.getSharedAgents(token), + selectData: selectSharedAgents, + updateAction: setSharedAgents, + }, +]; diff --git a/frontend/src/agents/index.tsx b/frontend/src/agents/index.tsx index 8851e173..a046b921 100644 --- a/frontend/src/agents/index.tsx +++ b/frontend/src/agents/index.tsx @@ -1,37 +1,9 @@ -import { SyntheticEvent, useEffect, useRef, useState } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import { Route, Routes, useNavigate } from 'react-router-dom'; +import { Route, Routes } from 'react-router-dom'; -import userService from '../api/services/userService'; -import Edit from '../assets/edit.svg'; -import Link from '../assets/link-gray.svg'; -import Monitoring from '../assets/monitoring.svg'; -import Pin from '../assets/pin.svg'; -import Trash from '../assets/red-trash.svg'; -import AgentImage from '../components/AgentImage'; -import ThreeDots from '../assets/three-dots.svg'; -import UnPin from '../assets/unpin.svg'; -import ContextMenu, { MenuOption } from '../components/ContextMenu'; -import Spinner from '../components/Spinner'; -import { - setConversation, - updateConversationId, -} from '../conversation/conversationSlice'; -import ConfirmationModal from '../modals/ConfirmationModal'; -import { ActiveState } from '../models/misc'; -import { - selectAgents, - selectSelectedAgent, - selectSharedAgents, - selectToken, - setAgents, - setSelectedAgent, - setSharedAgents, -} from '../preferences/preferenceSlice'; import AgentLogs from './AgentLogs'; +import AgentsList from './AgentsList'; import NewAgent from './NewAgent'; import SharedAgent from './SharedAgent'; -import { Agent } from './types'; export default function Agents() { return ( @@ -44,427 +16,3 @@ export default function Agents() { ); } - -const sectionConfig = { - user: { - title: 'By me', - description: 'Agents created or published by you', - showNewAgentButton: true, - emptyStateDescription: 'You don’t have any created agents yet', - }, - shared: { - title: 'Shared with me', - description: 'Agents imported by using a public link', - showNewAgentButton: false, - emptyStateDescription: 'No shared agents found', - }, -}; - -function AgentsList() { - const dispatch = useDispatch(); - const token = useSelector(selectToken); - const agents = useSelector(selectAgents); - const sharedAgents = useSelector(selectSharedAgents); - const selectedAgent = useSelector(selectSelectedAgent); - - const [loadingUserAgents, setLoadingUserAgents] = useState(true); - const [loadingSharedAgents, setLoadingSharedAgents] = useState(true); - - const getAgents = async () => { - try { - setLoadingUserAgents(true); - const response = await userService.getAgents(token); - if (!response.ok) throw new Error('Failed to fetch agents'); - const data = await response.json(); - dispatch(setAgents(data)); - setLoadingUserAgents(false); - } catch (error) { - console.error('Error:', error); - setLoadingUserAgents(false); - } - }; - - const getSharedAgents = async () => { - try { - setLoadingSharedAgents(true); - const response = await userService.getSharedAgents(token); - if (!response.ok) throw new Error('Failed to fetch shared agents'); - const data = await response.json(); - dispatch(setSharedAgents(data)); - setLoadingSharedAgents(false); - } catch (error) { - console.error('Error:', error); - setLoadingSharedAgents(false); - } - }; - - useEffect(() => { - getAgents(); - getSharedAgents(); - dispatch(setConversation([])); - dispatch( - updateConversationId({ - query: { conversationId: null }, - }), - ); - if (selectedAgent) dispatch(setSelectedAgent(null)); - }, [token]); - return ( -
-

- Agents -

-

- Discover and create custom versions of DocsGPT that combine - instructions, extra knowledge, and any combination of skills -

- {/* Premade agents section */} - {/*
-

- Premade by DocsGPT -

-
- {Array.from({ length: 5 }, (_, index) => ( -
- -
-
- -
-
-

- {} -

-

- {} -

-
-
-
-
- ))} -
-
*/} - { - dispatch(setAgents(updatedAgents)); - }} - loading={loadingUserAgents} - section="user" - /> - { - dispatch(setSharedAgents(updatedAgents)); - }} - loading={loadingSharedAgents} - section="shared" - /> -
- ); -} - -function AgentSection({ - agents, - updateAgents, - loading, - section, -}: { - agents: Agent[]; - updateAgents?: (agents: Agent[]) => void; - loading: boolean; - section: keyof typeof sectionConfig; -}) { - const navigate = useNavigate(); - return ( -
-
-
-

- {sectionConfig[section].title} -

-

- {sectionConfig[section].description} -

-
- {sectionConfig[section].showNewAgentButton && ( - - )} -
-
- {loading ? ( -
- -
- ) : agents && agents.length > 0 ? ( -
- {agents.map((agent, idx) => ( - - ))} -
- ) : ( -
-

{sectionConfig[section].emptyStateDescription}

- {sectionConfig[section].showNewAgentButton && ( - - )} -
- )} -
-
- ); -} - -function AgentCard({ - agent, - agents, - updateAgents, - section, -}: { - agent: Agent; - agents: Agent[]; - updateAgents?: (agents: Agent[]) => void; - section: keyof typeof sectionConfig; -}) { - const navigate = useNavigate(); - const dispatch = useDispatch(); - const token = useSelector(selectToken); - - const [isMenuOpen, setIsMenuOpen] = useState(false); - const [deleteConfirmation, setDeleteConfirmation] = - useState('INACTIVE'); - - const menuRef = useRef(null); - - const togglePin = async () => { - try { - const response = await userService.togglePinAgent(agent.id ?? '', token); - if (!response.ok) throw new Error('Failed to pin agent'); - const updatedAgents = agents.map((prevAgent) => { - if (prevAgent.id === agent.id) - return { ...prevAgent, pinned: !prevAgent.pinned }; - return prevAgent; - }); - updateAgents?.(updatedAgents); - } catch (error) { - console.error('Error:', error); - } - }; - - const handleHideSharedAgent = async () => { - try { - const response = await userService.removeSharedAgent( - agent.id ?? '', - token, - ); - if (!response.ok) throw new Error('Failed to hide shared agent'); - const updatedAgents = agents.filter( - (prevAgent) => prevAgent.id !== agent.id, - ); - updateAgents?.(updatedAgents); - } catch (error) { - console.error('Error:', error); - } - }; - - const menuOptionsConfig: Record = { - user: [ - { - icon: Monitoring, - label: 'Logs', - onClick: (e: SyntheticEvent) => { - e.stopPropagation(); - navigate(`/agents/logs/${agent.id}`); - }, - variant: 'primary', - iconWidth: 14, - iconHeight: 14, - }, - { - icon: Edit, - label: 'Edit', - onClick: (e: SyntheticEvent) => { - e.stopPropagation(); - navigate(`/agents/edit/${agent.id}`); - }, - variant: 'primary', - iconWidth: 14, - iconHeight: 14, - }, - ...(agent.status === 'published' - ? [ - { - icon: agent.pinned ? UnPin : Pin, - label: agent.pinned ? 'Unpin' : 'Pin agent', - onClick: (e: SyntheticEvent) => { - e.stopPropagation(); - togglePin(); - }, - variant: 'primary' as const, - iconWidth: 18, - iconHeight: 18, - }, - ] - : []), - { - icon: Trash, - label: 'Delete', - onClick: (e: SyntheticEvent) => { - e.stopPropagation(); - setDeleteConfirmation('ACTIVE'); - }, - variant: 'danger', - iconWidth: 13, - iconHeight: 13, - }, - ], - shared: [ - { - icon: Link, - label: 'Open', - onClick: (e: SyntheticEvent) => { - e.stopPropagation(); - navigate(`/agents/shared/${agent.shared_token}`); - }, - variant: 'primary', - iconWidth: 12, - iconHeight: 12, - }, - { - icon: agent.pinned ? UnPin : Pin, - label: agent.pinned ? 'Unpin' : 'Pin agent', - onClick: (e: SyntheticEvent) => { - e.stopPropagation(); - togglePin(); - }, - variant: 'primary', - iconWidth: 18, - iconHeight: 18, - }, - { - icon: Trash, - label: 'Remove', - onClick: (e: SyntheticEvent) => { - e.stopPropagation(); - handleHideSharedAgent(); - }, - variant: 'danger', - iconWidth: 13, - iconHeight: 13, - }, - ], - }; - - const menuOptions = menuOptionsConfig[section] || []; - - const handleClick = () => { - if (section === 'user') { - if (agent.status === 'published') { - dispatch(setSelectedAgent(agent)); - navigate(`/`); - } - } - if (section === 'shared') { - navigate(`/agents/shared/${agent.shared_token}`); - } - }; - - const handleDelete = async (agentId: string) => { - const response = await userService.deleteAgent(agentId, token); - if (!response.ok) throw new Error('Failed to delete agent'); - const data = await response.json(); - dispatch(setAgents(agents.filter((prevAgent) => prevAgent.id !== data.id))); - }; - return ( -
{ - e.stopPropagation(); - handleClick(); - }} - > -
{ - e.stopPropagation(); - setIsMenuOpen(true); - }} - className="absolute top-4 right-4 z-10 cursor-pointer" - > - {'use-agent'} - -
-
-
- - {agent.status === 'draft' && ( -

{`(Draft)`}

- )} -
-
-

- {agent.name} -

-

- {agent.description} -

-
-
- { - handleDelete(agent.id || ''); - setDeleteConfirmation('INACTIVE'); - }} - cancelLabel="Cancel" - variant="danger" - /> -
- ); -} diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index d2fb1518..569e2095 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -19,6 +19,8 @@ const endpoints = { SHARED_AGENTS: '/api/shared_agents', SHARE_AGENT: `/api/share_agent`, REMOVE_SHARED_AGENT: (id: string) => `/api/remove_shared_agent?id=${id}`, + TEMPLATE_AGENTS: '/api/template_agents', + ADOPT_AGENT: (id: string) => `/api/adopt_agent?id=${id}`, AGENT_WEBHOOK: (id: string) => `/api/agent_webhook?id=${id}`, PROMPTS: '/api/get_prompts', CREATE_PROMPT: '/api/create_prompt', diff --git a/frontend/src/api/services/userService.ts b/frontend/src/api/services/userService.ts index 4e31317d..513dd613 100644 --- a/frontend/src/api/services/userService.ts +++ b/frontend/src/api/services/userService.ts @@ -44,6 +44,10 @@ const userService = { apiClient.put(endpoints.USER.SHARE_AGENT, data, token), removeSharedAgent: (id: string, token: string | null): Promise => apiClient.delete(endpoints.USER.REMOVE_SHARED_AGENT(id), token), + getTemplateAgents: (token: string | null): Promise => + apiClient.get(endpoints.USER.TEMPLATE_AGENTS, token), + adoptAgent: (id: string, token: string | null): Promise => + apiClient.post(endpoints.USER.ADOPT_AGENT(id), {}, token), getAgentWebhook: (id: string, token: string | null): Promise => apiClient.get(endpoints.USER.AGENT_WEBHOOK(id), token), getPrompts: (token: string | null): Promise => diff --git a/frontend/src/assets/duplicate.svg b/frontend/src/assets/duplicate.svg new file mode 100644 index 00000000..06f7a8d3 --- /dev/null +++ b/frontend/src/assets/duplicate.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/preferences/preferenceSlice.ts b/frontend/src/preferences/preferenceSlice.ts index 6da8be95..e3300a4e 100644 --- a/frontend/src/preferences/preferenceSlice.ts +++ b/frontend/src/preferences/preferenceSlice.ts @@ -24,6 +24,7 @@ export interface Preference { token: string | null; modalState: ActiveState; paginatedDocuments: Doc[] | null; + templateAgents: Agent[] | null; agents: Agent[] | null; sharedAgents: Agent[] | null; selectedAgent: Agent | null; @@ -52,6 +53,7 @@ const initialState: Preference = { token: localStorage.getItem('authToken') || null, modalState: 'INACTIVE', paginatedDocuments: null, + templateAgents: null, agents: null, sharedAgents: null, selectedAgent: null, @@ -91,6 +93,9 @@ export const prefSlice = createSlice({ setModalStateDeleteConv: (state, action: PayloadAction) => { state.modalState = action.payload; }, + setTemplateAgents: (state, action) => { + state.templateAgents = action.payload; + }, setAgents: (state, action) => { state.agents = action.payload; }, @@ -114,6 +119,7 @@ export const { setTokenLimit, setModalStateDeleteConv, setPaginatedDocuments, + setTemplateAgents, setAgents, setSharedAgents, setSelectedAgent, @@ -191,6 +197,8 @@ export const selectTokenLimit = (state: RootState) => state.preference.token_limit; export const selectPaginatedDocuments = (state: RootState) => state.preference.paginatedDocuments; +export const selectTemplateAgents = (state: RootState) => + state.preference.templateAgents; export const selectAgents = (state: RootState) => state.preference.agents; export const selectSharedAgents = (state: RootState) => state.preference.sharedAgents; diff --git a/frontend/src/store.ts b/frontend/src/store.ts index 76a34e71..d3570ff7 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -1,5 +1,6 @@ import { configureStore } from '@reduxjs/toolkit'; +import agentPreviewReducer from './agents/agentPreviewSlice'; import { conversationSlice } from './conversation/conversationSlice'; import { sharedConversationSlice } from './conversation/sharedConversationSlice'; import { @@ -8,7 +9,6 @@ import { prefSlice, } from './preferences/preferenceSlice'; import uploadReducer from './upload/uploadSlice'; -import agentPreviewReducer from './agents/agentPreviewSlice'; const key = localStorage.getItem('DocsGPTApiKey'); const prompt = localStorage.getItem('DocsGPTPrompt'); @@ -43,6 +43,7 @@ const preloadedState: { preference: Preference } = { ], modalState: 'INACTIVE', paginatedDocuments: null, + templateAgents: null, agents: null, sharedAgents: null, selectedAgent: null, From 03452ffd9f5ecef57eb85d664803b988158e1937 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 7 Oct 2025 14:53:14 +0100 Subject: [PATCH 2/4] feat: add GitHub access token support and fix file content fetching logic (#2032) --- application/core/settings.py | 3 + application/parser/remote/github_loader.py | 148 +++++++++++++++++---- tests/parser/remote/test_github_loader.py | 23 ++-- 3 files changed, 139 insertions(+), 35 deletions(-) diff --git a/application/core/settings.py b/application/core/settings.py index 4475c443..2dc159ba 100644 --- a/application/core/settings.py +++ b/application/core/settings.py @@ -51,6 +51,9 @@ class Settings(BaseSettings): "http://127.0.0.1:7091/api/connectors/callback" ##add redirect url as it is to your provider's console(gcp) ) + # GitHub source + GITHUB_ACCESS_TOKEN: Optional[str] = None # PAT token with read repo access + # LLM Cache CACHE_REDIS_URL: str = "redis://localhost:6379/2" diff --git a/application/parser/remote/github_loader.py b/application/parser/remote/github_loader.py index 8f805056..327e59a2 100644 --- a/application/parser/remote/github_loader.py +++ b/application/parser/remote/github_loader.py @@ -1,44 +1,135 @@ import base64 import requests -from typing import List +import time +from typing import List, Optional from application.parser.remote.base import BaseRemote -from langchain_core.documents import Document +from application.parser.schema.base import Document import mimetypes +from application.core.settings import settings class GitHubLoader(BaseRemote): def __init__(self): - self.access_token = None + self.access_token = settings.GITHUB_ACCESS_TOKEN self.headers = { - "Authorization": f"token {self.access_token}" - } if self.access_token else {} + "Authorization": f"token {self.access_token}", + "Accept": "application/vnd.github.v3+json" + } if self.access_token else { + "Accept": "application/vnd.github.v3+json" + } return - def fetch_file_content(self, repo_url: str, file_path: str) -> str: + def is_text_file(self, file_path: str) -> bool: + """Determine if a file is a text file based on extension.""" + # Common text file extensions + text_extensions = { + '.txt', '.md', '.markdown', '.rst', '.json', '.xml', '.yaml', '.yml', + '.py', '.js', '.ts', '.jsx', '.tsx', '.java', '.c', '.cpp', '.h', '.hpp', + '.cs', '.go', '.rs', '.rb', '.php', '.swift', '.kt', '.scala', + '.html', '.css', '.scss', '.sass', '.less', + '.sh', '.bash', '.zsh', '.fish', + '.sql', '.r', '.m', '.mat', + '.ini', '.cfg', '.conf', '.config', '.env', + '.gitignore', '.dockerignore', '.editorconfig', + '.log', '.csv', '.tsv' + } + + # Get file extension + file_lower = file_path.lower() + for ext in text_extensions: + if file_lower.endswith(ext): + return True + + # Also check MIME type + mime_type, _ = mimetypes.guess_type(file_path) + if mime_type and (mime_type.startswith("text") or mime_type in ["application/json", "application/xml"]): + return True + + return False + + def fetch_file_content(self, repo_url: str, file_path: str) -> Optional[str]: + """Fetch file content. Returns None if file should be skipped (binary files or empty files).""" url = f"https://api.github.com/repos/{repo_url}/contents/{file_path}" - response = requests.get(url, headers=self.headers) + response = self._make_request(url) - if response.status_code == 200: - content = response.json() - mime_type, _ = mimetypes.guess_type(file_path) # Guess the MIME type based on the file extension + content = response.json() - if content.get("encoding") == "base64": - if mime_type and mime_type.startswith("text"): # Handle only text files - try: - decoded_content = base64.b64decode(content["content"]).decode("utf-8") - return f"Filename: {file_path}\n\n{decoded_content}" - except Exception as e: - raise e - else: - return f"Filename: {file_path} is a binary file and was skipped." + if content.get("encoding") == "base64": + if self.is_text_file(file_path): # Handle only text files + try: + decoded_content = base64.b64decode(content["content"]).decode("utf-8").strip() + # Skip empty files + if not decoded_content: + return None + return decoded_content + except Exception: + # If decoding fails, it's probably a binary file + return None else: - return f"Filename: {file_path}\n\n{content['content']}" + # Skip binary files by returning None + return None else: - response.raise_for_status() + file_content = content['content'].strip() + # Skip empty files + if not file_content: + return None + return file_content + + def _make_request(self, url: str, max_retries: int = 3) -> requests.Response: + """Make a request with retry logic for rate limiting""" + for attempt in range(max_retries): + response = requests.get(url, headers=self.headers) + + if response.status_code == 200: + return response + elif response.status_code == 403: + # Check if it's a rate limit issue + try: + error_data = response.json() + error_msg = error_data.get("message", "") + + # Check rate limit headers + remaining = response.headers.get("X-RateLimit-Remaining", "unknown") + reset_time = response.headers.get("X-RateLimit-Reset", "unknown") + + print(f"GitHub API 403 Error: {error_msg}") + print(f"Rate limit remaining: {remaining}, Reset time: {reset_time}") + + if "rate limit" in error_msg.lower(): + if attempt < max_retries - 1: + wait_time = 2 ** attempt # Exponential backoff + print(f"Rate limit hit, waiting {wait_time} seconds before retry...") + time.sleep(wait_time) + continue + + # Provide helpful error message + if remaining == "0": + raise Exception(f"GitHub API rate limit exceeded. Please set GITHUB_ACCESS_TOKEN environment variable. Reset time: {reset_time}") + else: + raise Exception(f"GitHub API error: {error_msg}. This may require authentication - set GITHUB_ACCESS_TOKEN environment variable.") + except Exception as e: + if isinstance(e, Exception) and "GitHub API" in str(e): + raise + # If we can't parse the response, raise the original error + response.raise_for_status() + else: + response.raise_for_status() + + return response def fetch_repo_files(self, repo_url: str, path: str = "") -> List[str]: url = f"https://api.github.com/repos/{repo_url}/contents/{path}" - response = requests.get(url, headers={**self.headers, "Accept": "application/vnd.github.v3.raw"}) + response = self._make_request(url) + contents = response.json() + + # Handle error responses from GitHub API + if isinstance(contents, dict) and "message" in contents: + raise Exception(f"GitHub API error: {contents.get('message')}") + + # Ensure contents is a list + if not isinstance(contents, list): + raise TypeError(f"Expected list from GitHub API, got {type(contents).__name__}: {contents}") + files = [] for item in contents: if item["type"] == "file": @@ -53,6 +144,15 @@ class GitHubLoader(BaseRemote): documents = [] for file_path in files: content = self.fetch_file_content(repo_name, file_path) - documents.append(Document(page_content=content, metadata={"title": file_path, - "source": f"https://github.com/{repo_name}/blob/main/{file_path}"})) + # Skip binary files (content is None) + if content is None: + continue + documents.append(Document( + text=content, + doc_id=file_path, + extra_info={ + "title": file_path, + "source": f"https://github.com/{repo_name}/blob/main/{file_path}" + } + )) return documents diff --git a/tests/parser/remote/test_github_loader.py b/tests/parser/remote/test_github_loader.py index 6bb3ed2e..f52003c1 100644 --- a/tests/parser/remote/test_github_loader.py +++ b/tests/parser/remote/test_github_loader.py @@ -27,7 +27,7 @@ class TestGitHubLoaderFetchFileContent: result = loader.fetch_file_content("owner/repo", "README.md") - assert result == f"Filename: README.md\n\n{content_str}" + assert result == content_str mock_get.assert_called_once_with( "https://api.github.com/repos/owner/repo/contents/README.md", headers=loader.headers, @@ -40,7 +40,7 @@ class TestGitHubLoaderFetchFileContent: result = loader.fetch_file_content("owner/repo", "image.png") - assert result == "Filename: image.png is a binary file and was skipped." + assert result is None @patch("application.parser.remote.github_loader.requests.get") def test_non_base64_plain_content(self, mock_get): @@ -49,7 +49,7 @@ class TestGitHubLoaderFetchFileContent: result = loader.fetch_file_content("owner/repo", "file.txt") - assert result == "Filename: file.txt\n\nPlain text" + assert result == "Plain text" @patch("application.parser.remote.github_loader.requests.get") def test_http_error_raises(self, mock_get): @@ -102,13 +102,13 @@ class TestGitHubLoaderLoadData: docs = loader.load_data("https://github.com/owner/repo") assert len(docs) == 2 - assert docs[0].page_content == "content for README.md" - assert docs[0].metadata == { + assert docs[0].text == "content for README.md" + assert docs[0].extra_info == { "title": "README.md", "source": "https://github.com/owner/repo/blob/main/README.md", } - assert docs[1].page_content == "content for src/main.py" - assert docs[1].metadata == { + assert docs[1].text == "content for src/main.py" + assert docs[1].extra_info == { "title": "src/main.py", "source": "https://github.com/owner/repo/blob/main/src/main.py", } @@ -142,12 +142,13 @@ class TestGitHubLoaderRobustness: GitHubLoader().fetch_file_content("owner/repo", "README.md") @patch("application.parser.remote.github_loader.requests.get") - def test_fetch_file_content_unexpected_shape_missing_content_raises(self, mock_get): + def test_fetch_file_content_unexpected_shape_missing_content_returns_none(self, mock_get): # encoding indicates base64 text, but 'content' key is missing + # With the new code, the exception is caught and returns None (treated as binary/skipped) resp = make_response({"encoding": "base64"}) mock_get.return_value = resp - with pytest.raises(KeyError): - GitHubLoader().fetch_file_content("owner/repo", "README.md") + result = GitHubLoader().fetch_file_content("owner/repo", "file.txt") + assert result is None @patch("application.parser.remote.github_loader.base64.b64decode") @patch("application.parser.remote.github_loader.requests.get") @@ -156,4 +157,4 @@ class TestGitHubLoaderRobustness: mock_b64decode.side_effect = AssertionError("b64decode should not be called for binary files") mock_get.return_value = make_response({"encoding": "base64", "content": "AAA"}) result = GitHubLoader().fetch_file_content("owner/repo", "bigfile.bin") - assert result == "Filename: bigfile.bin is a binary file and was skipped." + assert result is None From 0ec86c2c71b625d2950f6a759986682ac4a0dee0 Mon Sep 17 00:00:00 2001 From: Anshuman Payasi <121681953+anshumaaaan@users.noreply.github.com> Date: Tue, 7 Oct 2025 19:41:03 +0530 Subject: [PATCH 3/4] fix: adjust share modal size and spacing (#2027) * fix/share-modal-spacing * fix(share-modal): spacing adjusted for mobile view --- frontend/src/modals/ShareConversationModal.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/modals/ShareConversationModal.tsx b/frontend/src/modals/ShareConversationModal.tsx index 624d64f5..e0b06392 100644 --- a/frontend/src/modals/ShareConversationModal.tsx +++ b/frontend/src/modals/ShareConversationModal.tsx @@ -104,11 +104,11 @@ export const ShareConversationModal = ({ return ( -
+

{t('modals.shareConv.label')}

-

+

{t('modals.shareConv.note')}

From 160ad2dc7904a21669a1ffedbcbffc0b6ba084d9 Mon Sep 17 00:00:00 2001 From: Nihar <99893073+nihaaaar22@users.noreply.github.com> Date: Wed, 8 Oct 2025 01:31:39 +0530 Subject: [PATCH 4/4] correct path for storing embeddings model (#2035) --- docs/pages/Deploying/Development-Environment.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/pages/Deploying/Development-Environment.mdx b/docs/pages/Deploying/Development-Environment.mdx index 2852be19..47be7f39 100644 --- a/docs/pages/Deploying/Development-Environment.mdx +++ b/docs/pages/Deploying/Development-Environment.mdx @@ -67,7 +67,7 @@ To run the DocsGPT backend locally, you'll need to set up a Python environment a 3. **Download Embedding Model:** - The backend requires an embedding model. Download the `mpnet-base-v2` model and place it in the `model/` directory within the project root. You can use the following script: + The backend requires an embedding model. Download the `mpnet-base-v2` model and place it in the `models/` directory within the project root. You can use the following script: ```bash wget https://d3dg1063dc54p9.cloudfront.net/models/embeddings/mpnet-base-v2.zip @@ -75,7 +75,7 @@ To run the DocsGPT backend locally, you'll need to set up a Python environment a rm mpnet-base-v2.zip ``` - Alternatively, you can manually download the zip file from [here](https://d3dg1063dc54p9.cloudfront.net/models/embeddings/mpnet-base-v2.zip), unzip it, and place the extracted folder in `model/`. + Alternatively, you can manually download the zip file from [here](https://d3dg1063dc54p9.cloudfront.net/models/embeddings/mpnet-base-v2.zip), unzip it, and place the extracted folder in `models/`. 4. **Install Backend Dependencies:**