From fa1f9d70094b2012748f4068260a9d7727b32b43 Mon Sep 17 00:00:00 2001 From: Siddhant Rai Date: Fri, 11 Apr 2025 17:24:22 +0530 Subject: [PATCH 1/7] feat: agents route replacing chatbots - Removed API Keys tab from SettingsBar and adjusted tab layout. - Improved styling for tab scrolling buttons and gradient indicators. - Introduced AgentDetailsModal for displaying agent access details. - Updated Analytics component to fetch agent data and handle analytics for selected agent. - Refactored Logs component to accept agentId as a prop for filtering logs. - Enhanced type definitions for InputProps to include textSize. - Cleaned up unused imports and optimized component structure across various files. --- application/api/answer/routes.py | 77 +-- application/api/user/routes.py | 477 ++++++++++++++---- frontend/package-lock.json | 26 +- frontend/package.json | 6 +- frontend/prettier.config.cjs | 3 +- frontend/src/App.tsx | 8 +- frontend/src/Navigation.tsx | 124 +++-- frontend/src/agents/AgentLogs.tsx | 32 ++ frontend/src/agents/NewAgent.tsx | 501 +++++++++++++++++++ frontend/src/agents/index.tsx | 230 +++++++++ frontend/src/agents/types/index.ts | 14 + frontend/src/api/endpoints.ts | 5 + frontend/src/api/services/userService.ts | 14 + frontend/src/assets/copy-linear.svg | 4 + frontend/src/assets/monitoring.svg | 3 + frontend/src/assets/red-trash.svg | 4 +- frontend/src/assets/robot.svg | 22 + frontend/src/assets/spark.svg | 3 + frontend/src/assets/white-trash.svg | 3 + frontend/src/components/ContextMenu.tsx | 34 +- frontend/src/components/Dropdown.tsx | 24 +- frontend/src/components/Input.tsx | 23 +- frontend/src/components/MultiSelectPopup.tsx | 278 ++++++++++ frontend/src/components/SettingsBar.tsx | 22 +- frontend/src/components/types/index.ts | 1 + frontend/src/modals/AgentDetailsModal.tsx | 68 +++ frontend/src/settings/Analytics.tsx | 392 +++++++-------- frontend/src/settings/Logs.tsx | 115 +---- frontend/src/settings/index.tsx | 67 ++- 29 files changed, 2001 insertions(+), 579 deletions(-) create mode 100644 frontend/src/agents/AgentLogs.tsx create mode 100644 frontend/src/agents/NewAgent.tsx create mode 100644 frontend/src/agents/index.tsx create mode 100644 frontend/src/agents/types/index.ts create mode 100644 frontend/src/assets/copy-linear.svg create mode 100644 frontend/src/assets/monitoring.svg create mode 100644 frontend/src/assets/robot.svg create mode 100644 frontend/src/assets/spark.svg create mode 100644 frontend/src/assets/white-trash.svg create mode 100644 frontend/src/components/MultiSelectPopup.tsx create mode 100644 frontend/src/modals/AgentDetailsModal.tsx diff --git a/application/api/answer/routes.py b/application/api/answer/routes.py index 7f61880d..7a9261bb 100644 --- a/application/api/answer/routes.py +++ b/application/api/answer/routes.py @@ -27,7 +27,7 @@ db = mongo["docsgpt"] conversations_collection = db["conversations"] sources_collection = db["sources"] prompts_collection = db["prompts"] -api_key_collection = db["api_keys"] +agents_collection = db["agents"] user_logs_collection = db["user_logs"] attachments_collection = db["attachments"] @@ -87,18 +87,18 @@ def run_async_chain(chain, question, chat_history): def get_data_from_api_key(api_key): - data = api_key_collection.find_one({"key": api_key}) - # # Raise custom exception if the API key is not found - if data is None: - raise Exception("Invalid API Key, please generate new key", 401) + data = agents_collection.find_one({"key": api_key}) + if not data: + raise Exception("Invalid API Key, please generate a new key", 401) - if "source" in data and isinstance(data["source"], DBRef): - source_doc = db.dereference(data["source"]) + source = data.get("source") + if isinstance(source, DBRef): + source_doc = db.dereference(source) data["source"] = str(source_doc["_id"]) - if "retriever" in source_doc: - data["retriever"] = source_doc["retriever"] + data["retriever"] = source_doc.get("retriever", data.get("retriever")) else: data["source"] = {} + return data @@ -128,7 +128,7 @@ def save_conversation( llm, decoded_token, index=None, - api_key=None + api_key=None, ): current_time = datetime.datetime.now(datetime.timezone.utc) if conversation_id is not None and index is not None: @@ -202,7 +202,7 @@ def save_conversation( ], } if api_key: - api_key_doc = api_key_collection.find_one({"key": api_key}) + api_key_doc = agents_collection.find_one({"key": api_key}) if api_key_doc: conversation_data["api_key"] = api_key_doc["key"] conversation_id = conversations_collection.insert_one( @@ -241,7 +241,9 @@ def complete_stream( if attachments: attachment_ids = [attachment["id"] for attachment in attachments] - logger.info(f"Processing request with {len(attachments)} attachments: {attachment_ids}") + logger.info( + f"Processing request with {len(attachments)} attachments: {attachment_ids}" + ) answer = agent.gen(query=question, retriever=retriever) @@ -294,7 +296,7 @@ def complete_stream( llm, decoded_token, index, - api_key=user_api_key + api_key=user_api_key, ) else: conversation_id = None @@ -366,7 +368,9 @@ class Stream(Resource): required=False, description="Index of the query to update" ), "save_conversation": fields.Boolean( - required=False, default=True, description="Whether to save the conversation" + required=False, + default=True, + description="Whether to save the conversation", ), "attachments": fields.List( fields.String, required=False, description="List of attachment IDs" @@ -400,6 +404,7 @@ class Stream(Resource): chunks = int(data.get("chunks", 2)) token_limit = data.get("token_limit", settings.DEFAULT_MAX_HISTORY) retriever_name = data.get("retriever", "classic") + agent_type = settings.AGENT_NAME if "api_key" in data: data_key = get_data_from_api_key(data["api_key"]) @@ -408,6 +413,7 @@ class Stream(Resource): source = {"active_docs": data_key.get("source")} retriever_name = data_key.get("retriever", retriever_name) user_api_key = data["api_key"] + agent_type = data_key.get("agent_type", agent_type) decoded_token = {"sub": data_key.get("user")} elif "active_docs" in data: @@ -423,8 +429,10 @@ class Stream(Resource): if not decoded_token: return make_response({"error": "Unauthorized"}, 401) - - attachments = get_attachments_content(attachment_ids, decoded_token.get("sub")) + + attachments = get_attachments_content( + attachment_ids, decoded_token.get("sub") + ) logger.info( f"/stream - request_data: {data}, source: {source}, attachments: {len(attachments)}", @@ -436,7 +444,7 @@ class Stream(Resource): chunks = 0 agent = AgentCreator.create_agent( - settings.AGENT_NAME, + agent_type, endpoint="stream", llm_name=settings.LLM_NAME, gpt_model=gpt_model, @@ -552,6 +560,7 @@ class Answer(Resource): chunks = int(data.get("chunks", 2)) token_limit = data.get("token_limit", settings.DEFAULT_MAX_HISTORY) retriever_name = data.get("retriever", "classic") + agent_type = settings.AGENT_NAME if "api_key" in data: data_key = get_data_from_api_key(data["api_key"]) @@ -560,6 +569,7 @@ class Answer(Resource): source = {"active_docs": data_key.get("source")} retriever_name = data_key.get("retriever", retriever_name) user_api_key = data["api_key"] + agent_type = data_key.get("agent_type", agent_type) decoded_token = {"sub": data_key.get("user")} elif "active_docs" in data: @@ -584,7 +594,7 @@ class Answer(Resource): ) agent = AgentCreator.create_agent( - settings.AGENT_NAME, + agent_type, endpoint="api/answer", llm_name=settings.LLM_NAME, gpt_model=gpt_model, @@ -811,33 +821,34 @@ class Search(Resource): def get_attachments_content(attachment_ids, user): """ Retrieve content from attachment documents based on their IDs. - + Args: attachment_ids (list): List of attachment document IDs user (str): User identifier to verify ownership - + Returns: list: List of dictionaries containing attachment content and metadata """ if not attachment_ids: return [] - + attachments = [] for attachment_id in attachment_ids: try: - attachment_doc = attachments_collection.find_one({ - "_id": ObjectId(attachment_id), - "user": user - }) - + attachment_doc = attachments_collection.find_one( + {"_id": ObjectId(attachment_id), "user": user} + ) + if attachment_doc: - attachments.append({ - "id": str(attachment_doc["_id"]), - "content": attachment_doc["content"], - "token_count": attachment_doc.get("token_count", 0), - "path": attachment_doc.get("path", "") - }) + attachments.append( + { + "id": str(attachment_doc["_id"]), + "content": attachment_doc["content"], + "token_count": attachment_doc.get("token_count", 0), + "path": attachment_doc.get("path", ""), + } + ) except Exception as e: logger.error(f"Error retrieving attachment {attachment_id}: {e}") - + return attachments diff --git a/application/api/user/routes.py b/application/api/user/routes.py index 8f374aa7..e3a977ef 100644 --- a/application/api/user/routes.py +++ b/application/api/user/routes.py @@ -28,7 +28,7 @@ conversations_collection = db["conversations"] sources_collection = db["sources"] prompts_collection = db["prompts"] feedback_collection = db["feedback"] -api_key_collection = db["api_keys"] +agents_collection = db["agents"] token_usage_collection = db["token_usage"] shared_conversations_collections = db["shared_conversations"] user_logs_collection = db["user_logs"] @@ -920,124 +920,391 @@ class UpdatePrompt(Resource): return make_response(jsonify({"success": True}), 200) -@user_ns.route("/api/get_api_keys") -class GetApiKeys(Resource): - @api.doc(description="Retrieve API keys for the user") +@user_ns.route("/api/get_agent") +class GetAgent(Resource): + @api.doc(params={"id": "ID of the agent"}, description="Get a single agent by ID") + def get(self): + decoded_token = request.decoded_token + if not decoded_token: + return make_response(jsonify({"success": False}), 401) + user = decoded_token.get("sub") + agent_id = request.args.get("id") + if not agent_id: + return make_response( + jsonify({"success": False, "message": "ID is required"}), 400 + ) + + try: + agent = agents_collection.find_one( + {"_id": ObjectId(agent_id), "user": user} + ) + if not agent: + return make_response(jsonify({"status": "Not found"}), 404) + data = { + "id": str(agent["_id"]), + "name": agent["name"], + "description": agent["description"], + "source": ( + str(db.dereference(agent["source"])["_id"]) + if "source" in agent and isinstance(agent["source"], DBRef) + else "" + ), + "chunks": agent["chunks"], + "retriever": agent.get("retriever", ""), + "prompt_id": agent["prompt_id"], + "tools": agent.get("tools", []), + "agent_type": agent["agent_type"], + "status": agent["status"], + "key": f"{agent['key'][:4]}...{agent['key'][-4:]}", + } + except Exception as err: + current_app.logger.error(f"Error retrieving agent: {err}") + return make_response(jsonify({"success": False}), 400) + + return make_response(jsonify(data), 200) + + +@user_ns.route("/api/get_agents") +class GetAgents(Resource): + @api.doc(description="Retrieve agents for the user") def get(self): decoded_token = request.decoded_token if not decoded_token: return make_response(jsonify({"success": False}), 401) user = decoded_token.get("sub") try: - keys = api_key_collection.find({"user": user}) - list_keys = [] - for key in keys: - if "source" in key and isinstance(key["source"], DBRef): - source = db.dereference(key["source"]) - if source is None: - continue - source_name = source["name"] - elif "retriever" in key: - source_name = key["retriever"] - else: - continue - - list_keys.append( - { - "id": str(key["_id"]), - "name": key["name"], - "key": key["key"][:4] + "..." + key["key"][-4:], - "source": source_name, - "prompt_id": key["prompt_id"], - "chunks": key["chunks"], - } - ) + agents = agents_collection.find({"user": user}) + list_agents = [ + { + "id": str(agent["_id"]), + "name": agent["name"], + "description": agent["description"], + "source": ( + str(db.dereference(agent["source"])["_id"]) + if "source" in agent and isinstance(agent["source"], DBRef) + else "" + ), + "chunks": agent["chunks"], + "retriever": agent.get("retriever", ""), + "prompt_id": agent["prompt_id"], + "tools": agent.get("tools", []), + "agent_type": agent["agent_type"], + "status": agent["status"], + "key": f"{agent['key'][:4]}...{agent['key'][-4:]}", + } + for agent in agents + if "source" in agent or "retriever" in agent + ] except Exception as err: - current_app.logger.error(f"Error retrieving API keys: {err}") + current_app.logger.error(f"Error retrieving agents: {err}") return make_response(jsonify({"success": False}), 400) - return make_response(jsonify(list_keys), 200) + return make_response(jsonify(list_agents), 200) -@user_ns.route("/api/create_api_key") -class CreateApiKey(Resource): - create_api_key_model = api.model( - "CreateApiKeyModel", +@user_ns.route("/api/create_agent") +class CreateAgent(Resource): + create_agent_model = api.model( + "CreateAgentModel", { - "name": fields.String(required=True, description="Name of the API key"), - "prompt_id": fields.String(required=True, description="Prompt ID"), + "name": fields.String(required=True, description="Name of the agent"), + "description": fields.String( + required=True, description="Description of the agent" + ), + "image": fields.String( + required=False, description="Image URL or identifier" + ), + "source": fields.String(required=True, description="Source ID"), "chunks": fields.Integer(required=True, description="Chunks count"), - "source": fields.String(description="Source ID (optional)"), - "retriever": fields.String(description="Retriever (optional)"), + "retriever": fields.String(required=True, description="Retriever ID"), + "prompt_id": fields.String(required=True, description="Prompt ID"), + "tools": fields.List( + fields.String, required=False, description="List of tool identifiers" + ), + "agent_type": fields.String(required=True, description="Type of the agent"), + "status": fields.String( + required=True, description="Status of the agent (draft or published)" + ), }, ) - @api.expect(create_api_key_model) - @api.doc(description="Create a new API key") + @api.expect(create_agent_model) + @api.doc(description="Create a new agent") def post(self): decoded_token = request.decoded_token if not decoded_token: return make_response(jsonify({"success": False}), 401) user = decoded_token.get("sub") data = request.get_json() - required_fields = ["name", "prompt_id", "chunks"] + + if data.get("status") not in ["draft", "published"]: + return make_response( + jsonify({"success": False, "message": "Invalid status"}), 400 + ) + + required_fields = [] + if data.get("status") == "published": + required_fields = [ + "name", + "description", + "source", + "chunks", + "retriever", + "prompt_id", + "agent_type", + ] + else: + required_fields = ["name"] missing_fields = check_required_fields(data, required_fields) if missing_fields: return missing_fields try: key = str(uuid.uuid4()) - new_api_key = { - "name": data["name"], - "key": key, + new_agent = { "user": user, - "prompt_id": data["prompt_id"], - "chunks": data["chunks"], + "name": data.get("name"), + "description": data.get("description", ""), + "image": data.get("image", ""), + "source": ( + DBRef("sources", ObjectId(data.get("source"))) + if ObjectId.is_valid(data.get("source")) + else "" + ), + "chunks": data.get("chunks", ""), + "retriever": data.get("retriever", ""), + "prompt_id": data.get("prompt_id", ""), + "tools": data.get("tools", []), + "agent_type": data.get("agent_type", ""), + "status": data.get("status"), + "createdAt": datetime.datetime.now(datetime.timezone.utc), + "updatedAt": datetime.datetime.now(datetime.timezone.utc), + "lastUsedAt": None, + "key": key, } - if "source" in data and ObjectId.is_valid(data["source"]): - new_api_key["source"] = DBRef("sources", ObjectId(data["source"])) - if "retriever" in data: - new_api_key["retriever"] = data["retriever"] - resp = api_key_collection.insert_one(new_api_key) + resp = agents_collection.insert_one(new_agent) new_id = str(resp.inserted_id) except Exception as err: - current_app.logger.error(f"Error creating API key: {err}") + current_app.logger.error(f"Error creating agent: {err}") return make_response(jsonify({"success": False}), 400) return make_response(jsonify({"id": new_id, "key": key}), 201) -@user_ns.route("/api/delete_api_key") -class DeleteApiKey(Resource): - delete_api_key_model = api.model( - "DeleteApiKeyModel", - {"id": fields.String(required=True, description="API Key ID to delete")}, +@user_ns.route("/api/update_agent/") +class UpdateAgent(Resource): + update_agent_model = api.model( + "UpdateAgentModel", + { + "name": fields.String(required=True, description="New name of the agent"), + "description": fields.String( + required=True, description="New description of the agent" + ), + "image": fields.String( + required=False, description="New image URL or identifier" + ), + "source": fields.String(required=True, description="Source ID"), + "chunks": fields.Integer(required=True, description="Chunks count"), + "retriever": fields.String(required=True, description="Retriever ID"), + "prompt_id": fields.String(required=True, description="Prompt ID"), + "tools": fields.List( + fields.String, required=False, description="List of tool identifiers" + ), + "agent_type": fields.String(required=True, description="Type of the agent"), + "status": fields.String( + required=True, description="Status of the agent (draft or published)" + ), + }, ) - @api.expect(delete_api_key_model) - @api.doc(description="Delete an API key by ID") - def post(self): + @api.expect(update_agent_model) + @api.doc(description="Update an existing agent") + def put(self, agent_id): decoded_token = request.decoded_token if not decoded_token: return make_response(jsonify({"success": False}), 401) user = decoded_token.get("sub") data = request.get_json() - required_fields = ["id"] - missing_fields = check_required_fields(data, required_fields) - if missing_fields: - return missing_fields + + if not ObjectId.is_valid(agent_id): + return make_response( + jsonify({"success": False, "message": "Invalid agent ID format"}), 400 + ) + oid = ObjectId(agent_id) try: - result = api_key_collection.delete_one( - {"_id": ObjectId(data["id"]), "user": user} - ) - if result.deleted_count == 0: - return {"success": False, "message": "API Key not found"}, 404 + existing_agent = agents_collection.find_one({"_id": oid, "user": user}) except Exception as err: - current_app.logger.error(f"Error deleting API key: {err}") - return {"success": False}, 400 + return make_response( + jsonify({"success": False, "message": "Database error finding agent"}), + 500, + ) - return {"success": True}, 200 + if not existing_agent: + return make_response( + jsonify( + {"success": False, "message": "Agent not found or not authorized"} + ), + 404, + ) + + update_fields = {} + allowed_fields = [ + "name", + "description", + "image", + "source", + "chunks", + "retriever", + "prompt_id", + "tools", + "agent_type", + "status", + ] + + for field in allowed_fields: + if field in data: + if field == "status": + new_status = data.get("status") + if new_status not in ["draft", "published"]: + return make_response( + jsonify( + {"success": False, "message": "Invalid status value"} + ), + 400, + ) + update_fields[field] = new_status + elif field == "source": + source_id = data.get("source") + if source_id and ObjectId.is_valid(source_id): + update_fields[field] = DBRef("sources", ObjectId(source_id)) + elif source_id: + return make_response( + jsonify( + { + "success": False, + "message": f"Invalid source ID format provided", + } + ), + 400, + ) + else: + update_fields[field] = "" + else: + update_fields[field] = data[field] + + if not update_fields: + return make_response( + jsonify({"success": False, "message": "No update data provided"}), 400 + ) + + final_status = update_fields.get("status", existing_agent.get("status")) + if final_status == "published": + required_published_fields = [ + "name", + "description", + "source", + "chunks", + "retriever", + "prompt_id", + "agent_type", + ] + missing_published_fields = [] + for req_field in required_published_fields: + final_value = update_fields.get( + req_field, existing_agent.get(req_field) + ) + if req_field == "source" and final_value: + if not isinstance(final_value, DBRef): + missing_published_fields.append(req_field) + + if missing_published_fields: + return make_response( + jsonify( + { + "success": False, + "message": f"Cannot publish agent. Missing or invalid required fields: {', '.join(missing_published_fields)}", + } + ), + 400, + ) + + update_fields["updatedAt"] = datetime.datetime.now(datetime.timezone.utc) + + try: + result = agents_collection.update_one( + {"_id": oid, "user": user}, {"$set": update_fields} + ) + + if result.matched_count == 0: + return make_response( + jsonify( + { + "success": False, + "message": "Agent not found or update failed unexpectedly", + } + ), + 404, + ) + if result.modified_count == 0 and result.matched_count == 1: + return make_response( + jsonify( + { + "success": True, + "message": "Agent found, but no changes were applied.", + } + ), + 304, + ) + + except Exception as err: + current_app.logger.error(f"Error updating agent {agent_id}: {err}") + return make_response( + jsonify({"success": False, "message": "Database error during update"}), + 500, + ) + + return make_response( + jsonify( + { + "success": True, + "id": agent_id, + "message": "Agent updated successfully", + } + ), + 200, + ) + + +@user_ns.route("/api/delete_agent") +class DeleteAgent(Resource): + @api.doc(params={"id": "ID of the agent"}, description="Delete an agent by ID") + def delete(self): + decoded_token = request.decoded_token + if not decoded_token: + return make_response(jsonify({"success": False}), 401) + user = decoded_token.get("sub") + agent_id = request.args.get("id") + if not agent_id: + return make_response( + jsonify({"success": False, "message": "ID is required"}), 400 + ) + + try: + deleted_agent = agents_collection.find_one_and_delete( + {"_id": ObjectId(agent_id), "user": user} + ) + if not deleted_agent: + return make_response( + jsonify({"success": False, "message": "Agent not found"}), 404 + ) + deleted_id = str(deleted_agent["_id"]) + + except Exception as err: + current_app.logger.error(f"Error deleting agent: {err}") + return make_response(jsonify({"success": False}), 400) + + return make_response(jsonify({"id": deleted_id}), 200) @user_ns.route("/api/share") @@ -1112,9 +1379,7 @@ class ShareConversation(Resource): if "retriever" in data: new_api_key_data["retriever"] = data["retriever"] - pre_existing_api_document = api_key_collection.find_one( - new_api_key_data - ) + pre_existing_api_document = agents_collection.find_one(new_api_key_data) if pre_existing_api_document: api_uuid = pre_existing_api_document["key"] pre_existing = shared_conversations_collections.find_one( @@ -1173,7 +1438,7 @@ class ShareConversation(Resource): if "retriever" in data: new_api_key_data["retriever"] = data["retriever"] - api_key_collection.insert_one(new_api_key_data) + agents_collection.insert_one(new_api_key_data) shared_conversations_collections.insert_one( { "uuid": explicit_binary, @@ -1331,9 +1596,9 @@ class GetMessageAnalytics(Resource): try: api_key = ( - api_key_collection.find_one( - {"_id": ObjectId(api_key_id), "user": user} - )["key"] + agents_collection.find_one({"_id": ObjectId(api_key_id), "user": user})[ + "key" + ] if api_key_id else None ) @@ -1375,7 +1640,7 @@ class GetMessageAnalytics(Resource): } if api_key: match_stage["$match"]["api_key"] = api_key - + pipeline = [ match_stage, {"$unwind": "$queries"}, @@ -1455,9 +1720,9 @@ class GetTokenAnalytics(Resource): try: api_key = ( - api_key_collection.find_one( - {"_id": ObjectId(api_key_id), "user": user} - )["key"] + agents_collection.find_one({"_id": ObjectId(api_key_id), "user": user})[ + "key" + ] if api_key_id else None ) @@ -1614,9 +1879,9 @@ class GetFeedbackAnalytics(Resource): try: api_key = ( - api_key_collection.find_one( - {"_id": ObjectId(api_key_id), "user": user} - )["key"] + agents_collection.find_one({"_id": ObjectId(api_key_id), "user": user})[ + "key" + ] if api_key_id else None ) @@ -1779,7 +2044,7 @@ class GetUserLogs(Resource): try: api_key = ( - api_key_collection.find_one({"_id": ObjectId(api_key_id)})["key"] + agents_collection.find_one({"_id": ObjectId(api_key_id)})["key"] if api_key_id else None ) @@ -2493,10 +2758,10 @@ class StoreAttachment(Resource): decoded_token = request.decoded_token if not decoded_token: return make_response(jsonify({"success": False}), 401) - + # Get single file instead of list file = request.files.get("file") - + if not file or file.filename == "": return make_response( jsonify({"status": "error", "message": "Missing file"}), @@ -2504,15 +2769,17 @@ class StoreAttachment(Resource): ) user = secure_filename(decoded_token.get("sub")) - + try: original_filename = secure_filename(file.filename) folder_name = original_filename - save_dir = os.path.join(current_dir, settings.UPLOAD_FOLDER, user, "attachments",folder_name) + save_dir = os.path.join( + current_dir, settings.UPLOAD_FOLDER, user, "attachments", folder_name + ) os.makedirs(save_dir, exist_ok=True) # Create directory structure: user/attachments/filename/ file_path = os.path.join(save_dir, original_filename) - + # Handle filename conflicts if os.path.exists(file_path): name_parts = os.path.splitext(original_filename) @@ -2520,27 +2787,25 @@ class StoreAttachment(Resource): new_filename = f"{name_parts[0]}_{timestamp}{name_parts[1]}" file_path = os.path.join(save_dir, new_filename) original_filename = new_filename - + file.save(file_path) file_info = {"folder": folder_name, "filename": original_filename} current_app.logger.info(f"Saved file: {file_path}") - + # Start async task to process single file - task = store_attachment.delay( - save_dir, - file_info, - user - ) - + task = store_attachment.delay(save_dir, file_info, user) + return make_response( - jsonify({ - "success": True, - "task_id": task.id, - "message": "File uploaded successfully. Processing started." - }), - 200 + jsonify( + { + "success": True, + "task_id": task.id, + "message": "File uploaded successfully. Processing started.", + } + ), + 200, ) - + except Exception as err: current_app.logger.error(f"Error storing attachment: {err}") return make_response(jsonify({"success": False, "error": str(err)}), 400) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d70a202f..043bbf58 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -49,8 +49,8 @@ "husky": "^8.0.0", "lint-staged": "^15.3.0", "postcss": "^8.4.49", - "prettier": "^3.4.2", - "prettier-plugin-tailwindcss": "^0.6.9", + "prettier": "^3.5.3", + "prettier-plugin-tailwindcss": "^0.6.11", "tailwindcss": "^3.4.17", "typescript": "^5.7.2", "vite": "^5.4.14", @@ -1635,7 +1635,7 @@ "version": "18.3.0", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.0.tgz", "integrity": "sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==", - "dev": true, + "devOptional": true, "dependencies": { "@types/react": "*" } @@ -7648,10 +7648,11 @@ } }, "node_modules/prettier": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.2.tgz", - "integrity": "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==", + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", + "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", "dev": true, + "license": "MIT", "bin": { "prettier": "bin/prettier.cjs" }, @@ -7675,10 +7676,11 @@ } }, "node_modules/prettier-plugin-tailwindcss": { - "version": "0.6.9", - "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.9.tgz", - "integrity": "sha512-r0i3uhaZAXYP0At5xGfJH876W3HHGHDp+LCRUJrs57PBeQ6mYHMwr25KH8NPX44F2yGTvdnH7OqCshlQx183Eg==", + "version": "0.6.11", + "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.11.tgz", + "integrity": "sha512-YxaYSIvZPAqhrrEpRtonnrXdghZg1irNg4qrjboCXrpybLWVs55cW2N3juhspVJiO0JBvYJT8SYsJpc8OQSnsA==", "dev": true, + "license": "MIT", "engines": { "node": ">=14.21.3" }, @@ -7687,7 +7689,7 @@ "@prettier/plugin-pug": "*", "@shopify/prettier-plugin-liquid": "*", "@trivago/prettier-plugin-sort-imports": "*", - "@zackad/prettier-plugin-twig-melody": "*", + "@zackad/prettier-plugin-twig": "*", "prettier": "^3.0", "prettier-plugin-astro": "*", "prettier-plugin-css-order": "*", @@ -7714,7 +7716,7 @@ "@trivago/prettier-plugin-sort-imports": { "optional": true }, - "@zackad/prettier-plugin-twig-melody": { + "@zackad/prettier-plugin-twig": { "optional": true }, "prettier-plugin-astro": { @@ -9376,7 +9378,7 @@ "version": "5.7.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", - "dev": true, + "devOptional": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/frontend/package.json b/frontend/package.json index 89a55b04..45058e98 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -28,8 +28,8 @@ "react-chartjs-2": "^5.3.0", "react-copy-to-clipboard": "^5.1.0", "react-dom": "^18.3.1", - "react-helmet": "^6.1.0", "react-dropzone": "^14.3.5", + "react-helmet": "^6.1.0", "react-i18next": "^15.4.0", "react-markdown": "^9.0.1", "react-redux": "^8.0.5", @@ -60,8 +60,8 @@ "husky": "^8.0.0", "lint-staged": "^15.3.0", "postcss": "^8.4.49", - "prettier": "^3.4.2", - "prettier-plugin-tailwindcss": "^0.6.9", + "prettier": "^3.5.3", + "prettier-plugin-tailwindcss": "^0.6.11", "tailwindcss": "^3.4.17", "typescript": "^5.7.2", "vite": "^5.4.14", diff --git a/frontend/prettier.config.cjs b/frontend/prettier.config.cjs index c92ea504..8b38ecfa 100644 --- a/frontend/prettier.config.cjs +++ b/frontend/prettier.config.cjs @@ -4,4 +4,5 @@ module.exports = { semi: true, singleQuote: true, printWidth: 80, -} + plugins: ['prettier-plugin-tailwindcss'], +}; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 33c66bd1..41b05ac0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,13 +12,14 @@ import useTokenAuth from './hooks/useTokenAuth'; import Navigation from './Navigation'; import PageNotFound from './PageNotFound'; import Setting from './settings'; +import Agents from './agents'; function AuthWrapper({ children }: { children: React.ReactNode }) { const { isAuthLoading } = useTokenAuth(); if (isAuthLoading) { return ( -
+
); @@ -31,7 +32,7 @@ function MainLayout() { const [navOpen, setNavOpen] = useState(!isMobile); return ( -
+
; } return ( -
+
} /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/Navigation.tsx b/frontend/src/Navigation.tsx index 0068c3d3..b8684df7 100644 --- a/frontend/src/Navigation.tsx +++ b/frontend/src/Navigation.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'; import { useDispatch, useSelector } from 'react-redux'; import { NavLink, useNavigate } from 'react-router-dom'; +import { Agent } from './agents/types'; import conversationService from './api/services/conversationService'; import userService from './api/services/userService'; import Add from './assets/add.svg'; @@ -12,11 +13,12 @@ import Expand from './assets/expand.svg'; import Github from './assets/github.svg'; import Hamburger from './assets/hamburger.svg'; import openNewChat from './assets/openNewChat.svg'; +import Robot from './assets/robot.svg'; +import Spark from './assets/spark.svg'; import SettingGear from './assets/settingGear.svg'; import SpinnerDark from './assets/spinner-dark.svg'; import Spinner from './assets/spinner.svg'; import Twitter from './assets/TwitterX.svg'; -import UploadIcon from './assets/upload.svg'; import Help from './components/Help'; import { handleAbort, @@ -50,36 +52,26 @@ interface NavigationProps { export default function Navigation({ navOpen, setNavOpen }: NavigationProps) { const dispatch = useDispatch(); + const navigate = useNavigate(); + + const { t } = useTranslation(); + const token = useSelector(selectToken); const queries = useSelector(selectQueries); const conversations = useSelector(selectConversations); const modalStateDeleteConv = useSelector(selectModalStateDeleteConv); - const conversationId = useSelector(selectConversationId); - const [isDeletingConversation, setIsDeletingConversation] = useState(false); const { isMobile } = useMediaQuery(); const [isDarkTheme] = useDarkTheme(); - const { t } = useTranslation(); - const isApiKeySet = useSelector(selectApiKeyStatus); - const { showTokenModal, handleTokenSubmit } = useTokenAuth(); + const [isDeletingConversation, setIsDeletingConversation] = useState(false); const [uploadModalState, setUploadModalState] = useState('INACTIVE'); + const [recentAgents, setRecentAgents] = useState([]); const navRef = useRef(null); - const navigate = useNavigate(); - - useEffect(() => { - if (!conversations?.data) { - fetchConversations(); - } - if (queries.length === 0) { - resetConversation(); - } - }, [conversations?.data, dispatch]); - async function fetchConversations() { dispatch(setConversations({ ...conversations, loading: true })); return await getConversations(token) @@ -92,6 +84,21 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) { }); } + const getAgents = async () => { + const response = await userService.getAgents(token); + if (!response.ok) throw new Error('Failed to fetch agents'); + const data = await response.json(); + setRecentAgents( + data.filter((agent: Agent) => agent.status === 'published'), + ); + }; + + useEffect(() => { + if (recentAgents.length === 0) getAgents(); + if (!conversations?.data) fetchConversations(); + if (queries.length === 0) resetConversation(); + }, [conversations?.data, dispatch]); + const handleDeleteAllConversations = () => { setIsDeletingConversation(true); conversationService @@ -170,8 +177,8 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) { return ( <> {!navOpen && ( -
-
+
+
)} -
+
DocsGPT
@@ -208,13 +215,13 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) { ref={navRef} className={`${ !navOpen && '-ml-96 md:-ml-[18rem]' - } duration-20 fixed top-0 z-20 flex h-full w-72 flex-col border-r-[1px] border-b-0 bg-lotion dark:bg-chinese-black transition-all dark:border-r-purple-taupe dark:text-white`} + } duration-20 fixed top-0 z-20 flex h-full w-72 flex-col border-b-0 border-r-[1px] bg-lotion transition-all dark:border-r-purple-taupe dark:bg-chinese-black dark:text-white`} >
{ if (isMobile) { setNavOpen(!navOpen); @@ -252,7 +259,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) { className={({ isActive }) => `${ isActive ? 'bg-transparent' : '' - } group sticky mx-4 mt-4 flex cursor-pointer gap-2.5 rounded-3xl border border-silver p-3 hover:border-rainy-gray dark:border-purple-taupe dark:text-white hover:bg-transparent` + } group sticky mx-4 mt-4 flex cursor-pointer gap-2.5 rounded-3xl border border-silver p-3 hover:border-rainy-gray hover:bg-transparent dark:border-purple-taupe dark:text-white` } > {conversations?.loading && !isDeletingConversation && ( -
+
)} - {conversations?.data && conversations.data.length > 0 ? ( + {
-
-

{t('chats')}

+
+

Agents

+
+
+ {recentAgents?.length > 0 ? ( +
+ {recentAgents.map((agent, idx) => ( +
+
+ agent-logo +
+

+ {agent.name} +

+
+ ))} +
navigate('/agents')} + > +
+ manage-agents +
+

+ Manage Agents +

+
+
+ ) : ( +
+ No agents available. +
+ )} +
+
+ } + {conversations?.data && conversations.data.length > 0 ? ( +
+
+

{t('chats')}

{conversations.data?.map((conversation) => ( @@ -316,7 +372,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) { }} to="/settings" className={({ isActive }) => - `my-auto mx-4 flex h-9 cursor-pointer gap-4 rounded-3xl hover:bg-gray-100 dark:hover:bg-[#28292E] ${ + `mx-4 my-auto flex h-9 cursor-pointer gap-4 rounded-3xl hover:bg-gray-100 dark:hover:bg-[#28292E] ${ isActive ? 'bg-gray-3000 dark:bg-transparent' : '' }` } @@ -324,15 +380,15 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) { Settings -

+

{t('settings.label')}

-
+
@@ -381,9 +437,9 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
-
+
-
DocsGPT
+
DocsGPT
+
+ +

+ Back to all agents +

+
+
+

+ Agent Logs +

+
+ + +
+ ); +} diff --git a/frontend/src/agents/NewAgent.tsx b/frontend/src/agents/NewAgent.tsx new file mode 100644 index 00000000..db733db2 --- /dev/null +++ b/frontend/src/agents/NewAgent.tsx @@ -0,0 +1,501 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { useNavigate, useParams } from 'react-router-dom'; + +import userService from '../api/services/userService'; +import ArrowLeft from '../assets/arrow-left.svg'; +import SourceIcon from '../assets/source.svg'; +import Dropdown from '../components/Dropdown'; +import MultiSelectPopup, { OptionType } from '../components/MultiSelectPopup'; +import { ActiveState, Doc } from '../models/misc'; +import { selectSourceDocs, selectToken } from '../preferences/preferenceSlice'; +import { UserToolType } from '../settings/types'; +import { Agent } from './types'; +import ConfirmationModal from '../modals/ConfirmationModal'; +import AgentDetailsModal from '../modals/AgentDetailsModal'; + +const embeddingsName = + import.meta.env.VITE_EMBEDDINGS_NAME || + 'huggingface_sentence-transformers/all-mpnet-base-v2'; + +export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { + const navigate = useNavigate(); + const { agentId } = useParams(); + const token = useSelector(selectToken); + const sourceDocs = useSelector(selectSourceDocs); + + const [effectiveMode, setEffectiveMode] = useState(mode); + const [agent, setAgent] = useState({ + id: agentId || '', + name: '', + description: '', + image: '', + source: '', + chunks: '', + retriever: '', + prompt_id: '', + tools: [], + agent_type: '', + status: '', + }); + const [prompts, setPrompts] = useState< + { name: string; id: string; type: string }[] + >([]); + const [userTools, setUserTools] = useState([]); + const [isSourcePopupOpen, setIsSourcePopupOpen] = useState(false); + const [isToolsPopupOpen, setIsToolsPopupOpen] = useState(false); + const [selectedSourceIds, setSelectedSourceIds] = useState< + Set + >(new Set()); + const [selectedToolIds, setSelectedToolIds] = useState>( + new Set(), + ); + const [deleteConfirmation, setDeleteConfirmation] = + useState('INACTIVE'); + const [agentDetails, setAgentDetails] = useState('INACTIVE'); + + const sourceAnchorButtonRef = useRef(null); + const toolAnchorButtonRef = useRef(null); + + const modeConfig = { + new: { + heading: 'New Agent', + buttonText: 'Create Agent', + showDelete: false, + showSaveDraft: true, + showLogs: false, + showAccessDetails: false, + }, + edit: { + heading: 'Edit Agent', + buttonText: 'Save Changes', + showDelete: true, + showSaveDraft: false, + showLogs: true, + showAccessDetails: true, + }, + draft: { + heading: 'New Agent (Draft)', + buttonText: 'Publish Draft', + showDelete: true, + showSaveDraft: true, + showLogs: false, + showAccessDetails: false, + }, + }; + const chunks = ['0', '2', '4', '6', '8', '10']; + const agentTypes = [ + { label: 'Classic', value: 'classic' }, + { label: 'ReAct', value: 'react' }, + ]; + + const isPublishable = () => { + return ( + agent.name && + agent.description && + (agent.source || agent.retriever) && + agent.chunks && + agent.prompt_id && + agent.agent_type + ); + }; + + const handleCancel = () => navigate('/agents'); + + const handleDelete = async (agentId: string) => { + const response = await userService.deleteAgent(agentId, token); + if (!response.ok) throw new Error('Failed to delete agent'); + navigate('/agents'); + }; + + const handleSaveDraft = async () => { + const response = + effectiveMode === 'new' + ? await userService.createAgent({ ...agent, status: 'draft' }, token) + : await userService.updateAgent( + agent.id || '', + { ...agent, status: 'draft' }, + token, + ); + if (!response.ok) throw new Error('Failed to create agent draft'); + const data = await response.json(); + if (effectiveMode === 'new') { + setEffectiveMode('draft'); + setAgent((prev) => ({ ...prev, id: data.id })); + } + }; + + const handlePublish = async () => { + const response = + effectiveMode === 'new' + ? await userService.createAgent( + { ...agent, status: 'published' }, + token, + ) + : await userService.updateAgent( + agent.id || '', + { ...agent, status: 'published' }, + token, + ); + if (!response.ok) throw new Error('Failed to publish agent'); + const data = await response.json(); + if (data.key) setAgent((prev) => ({ ...prev, key: data.key })); + if (effectiveMode === 'new') setAgentDetails('ACTIVE'); + }; + + useEffect(() => { + const getTools = async () => { + const response = await userService.getUserTools(token); + if (!response.ok) throw new Error('Failed to fetch tools'); + const data = await response.json(); + const tools: OptionType[] = data.tools.map((tool: UserToolType) => ({ + id: tool.id, + label: tool.displayName, + icon: `/toolIcons/tool_${tool.name}.svg`, + })); + setUserTools(tools); + }; + const getPrompts = async () => { + const response = await userService.getPrompts(token); + if (!response.ok) { + throw new Error('Failed to fetch prompts'); + } + const data = await response.json(); + setPrompts(data); + }; + getTools(); + getPrompts(); + }, [token]); + + useEffect(() => { + if ((mode === 'edit' || mode === 'draft') && agentId) { + const getAgent = async () => { + const response = await userService.getAgent(agentId, token); + if (!response.ok) { + navigate('/agents'); + throw new Error('Failed to fetch agent'); + } + const data = await response.json(); + if (data.source) setSelectedSourceIds(new Set([data.source])); + else if (data.retriever) + setSelectedSourceIds(new Set([data.retriever])); + if (data.tools) setSelectedToolIds(new Set(data.tools)); + if (data.status === 'draft') setEffectiveMode('draft'); + setAgent(data); + }; + getAgent(); + } + }, [agentId, mode, token]); + + useEffect(() => { + const selectedSource = Array.from(selectedSourceIds).map((id) => + sourceDocs?.find( + (source) => + source.id === id || source.retriever === id || source.name === id, + ), + ); + if (selectedSource[0]?.model === embeddingsName) { + if (selectedSource[0] && 'id' in selectedSource[0]) { + setAgent((prev) => ({ + ...prev, + source: selectedSource[0]?.id || 'default', + retriever: '', + })); + } else + setAgent((prev) => ({ + ...prev, + source: '', + retriever: selectedSource[0]?.retriever || 'classic', + })); + } + }, [selectedSourceIds]); + + useEffect(() => { + const selectedTool = Array.from(selectedToolIds).map((id) => + userTools.find((tool) => tool.id === id), + ); + setAgent((prev) => ({ + ...prev, + tools: selectedTool + .map((tool) => tool?.id) + .filter((id): id is string => typeof id === 'string'), + })); + }, [selectedToolIds]); + return ( +
+
+ +

+ Back to all agents +

+
+
+

+ {modeConfig[effectiveMode].heading} +

+
+ + {modeConfig[effectiveMode].showDelete && agent.id && ( + + )} + {modeConfig[effectiveMode].showSaveDraft && ( + + )} + {modeConfig[effectiveMode].showAccessDetails && ( + + )} + {modeConfig[effectiveMode].showAccessDetails && ( + + )} + +
+
+
+
+
+

Meta

+ setAgent({ ...agent, name: e.target.value })} + /> +