From 2246866a09b8a151bda1704b450ec5c6011b06d2 Mon Sep 17 00:00:00 2001 From: Manish Madan Date: Thu, 8 Jan 2026 22:16:40 +0530 Subject: [PATCH] Feat: Agents grouped under folders (#2245) * chore(dependabot): add react-widget npm dependency updates * refactor(prompts): init on load, mv to pref slice * (refactor): searchable dropdowns are separate * (fix/ui) prompts adjust * feat(changelog): dancing stars * (fix)conversation: re-blink bubble past stream * (fix)endless GET sources, esling err * (feat:Agents) folders metadata * (feat:agents) create new folder * (feat:agent-management) ui * feat:(agent folders) nesting/sub-folders * feat:(agent folders)- closer the figma, inline folder inputs * fix(delete behaviour) refetch agents on delete * (fix:search) folder context missing * fix(newAgent) preserve folder context * feat(agent folders) id preserved im query, navigate * feat(agents) mobile responsive * feat(search/agents) lookup for nested agents as well * (fix/modals) close on outside click --------- Co-authored-by: GH Action - Upstream Sync --- application/api/user/agents/__init__.py | 3 +- application/api/user/agents/folders.py | 261 ++++++++++++ application/api/user/agents/routes.py | 53 +++ application/api/user/base.py | 1 + application/api/user/routes.py | 5 +- frontend/package-lock.json | 23 +- frontend/src/agents/AgentCard.tsx | 37 +- frontend/src/agents/AgentsList.tsx | 403 ++++++++++++++++-- frontend/src/agents/FolderCard.tsx | 128 ++++++ frontend/src/agents/NewAgent.tsx | 59 ++- frontend/src/agents/hooks/useAgentsFetch.ts | 19 +- frontend/src/agents/types/index.ts | 9 + frontend/src/api/endpoints.ts | 3 + frontend/src/api/services/userService.ts | 21 + frontend/src/locale/de.json | 18 +- frontend/src/locale/en.json | 19 +- frontend/src/locale/es.json | 18 +- frontend/src/locale/jp.json | 18 +- frontend/src/locale/ru.json | 18 +- frontend/src/locale/zh-TW.json | 18 +- frontend/src/locale/zh.json | 18 +- frontend/src/modals/FolderManagementModal.tsx | 87 ++++ frontend/src/modals/MoveToFolderModal.tsx | 374 ++++++++++++++++ frontend/src/modals/WrapperModal.tsx | 11 +- frontend/src/preferences/preferenceSlice.ts | 10 +- frontend/src/store.ts | 1 + 26 files changed, 1574 insertions(+), 61 deletions(-) create mode 100644 application/api/user/agents/folders.py create mode 100644 frontend/src/agents/FolderCard.tsx create mode 100644 frontend/src/modals/FolderManagementModal.tsx create mode 100644 frontend/src/modals/MoveToFolderModal.tsx diff --git a/application/api/user/agents/__init__.py b/application/api/user/agents/__init__.py index f397f0eb..30199c85 100644 --- a/application/api/user/agents/__init__.py +++ b/application/api/user/agents/__init__.py @@ -3,5 +3,6 @@ from .routes import agents_ns from .sharing import agents_sharing_ns from .webhooks import agents_webhooks_ns +from .folders import agents_folders_ns -__all__ = ["agents_ns", "agents_sharing_ns", "agents_webhooks_ns"] +__all__ = ["agents_ns", "agents_sharing_ns", "agents_webhooks_ns", "agents_folders_ns"] diff --git a/application/api/user/agents/folders.py b/application/api/user/agents/folders.py new file mode 100644 index 00000000..3dd14aa5 --- /dev/null +++ b/application/api/user/agents/folders.py @@ -0,0 +1,261 @@ +""" +Agent folders management routes. +Provides virtual folder organization for agents (Google Drive-like structure). +""" + +import datetime +from bson.objectid import ObjectId +from flask import jsonify, make_response, request +from flask_restx import Namespace, Resource, fields + +from application.api import api +from application.api.user.base import ( + agent_folders_collection, + agents_collection, +) + +agents_folders_ns = Namespace( + "agents_folders", description="Agent folder management", path="/api/agents/folders" +) + + +@agents_folders_ns.route("/") +class AgentFolders(Resource): + @api.doc(description="Get all folders 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: + folders = list(agent_folders_collection.find({"user": user})) + result = [ + { + "id": str(f["_id"]), + "name": f["name"], + "parent_id": f.get("parent_id"), + "created_at": f.get("created_at", "").isoformat() if f.get("created_at") else None, + "updated_at": f.get("updated_at", "").isoformat() if f.get("updated_at") else None, + } + for f in folders + ] + return make_response(jsonify({"folders": result}), 200) + except Exception as e: + return make_response(jsonify({"success": False, "message": str(e)}), 400) + + @api.doc(description="Create a new folder") + @api.expect( + api.model( + "CreateFolder", + { + "name": fields.String(required=True, description="Folder name"), + "parent_id": fields.String(required=False, description="Parent folder ID"), + }, + ) + ) + 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() + if not data or not data.get("name"): + return make_response(jsonify({"success": False, "message": "Folder name is required"}), 400) + + parent_id = data.get("parent_id") + if parent_id: + parent = agent_folders_collection.find_one({"_id": ObjectId(parent_id), "user": user}) + if not parent: + return make_response(jsonify({"success": False, "message": "Parent folder not found"}), 404) + + try: + now = datetime.datetime.now(datetime.timezone.utc) + folder = { + "user": user, + "name": data["name"], + "parent_id": parent_id, + "created_at": now, + "updated_at": now, + } + result = agent_folders_collection.insert_one(folder) + return make_response( + jsonify({"id": str(result.inserted_id), "name": data["name"], "parent_id": parent_id}), + 201, + ) + except Exception as e: + return make_response(jsonify({"success": False, "message": str(e)}), 400) + + +@agents_folders_ns.route("/") +class AgentFolder(Resource): + @api.doc(description="Get a specific folder with its agents") + def get(self, folder_id): + decoded_token = request.decoded_token + if not decoded_token: + return make_response(jsonify({"success": False}), 401) + user = decoded_token.get("sub") + try: + folder = agent_folders_collection.find_one({"_id": ObjectId(folder_id), "user": user}) + if not folder: + return make_response(jsonify({"success": False, "message": "Folder not found"}), 404) + + agents = list(agents_collection.find({"user": user, "folder_id": folder_id})) + agents_list = [ + {"id": str(a["_id"]), "name": a["name"], "description": a.get("description", "")} + for a in agents + ] + subfolders = list(agent_folders_collection.find({"user": user, "parent_id": folder_id})) + subfolders_list = [{"id": str(sf["_id"]), "name": sf["name"]} for sf in subfolders] + + return make_response( + jsonify({ + "id": str(folder["_id"]), + "name": folder["name"], + "parent_id": folder.get("parent_id"), + "agents": agents_list, + "subfolders": subfolders_list, + }), + 200, + ) + except Exception as e: + return make_response(jsonify({"success": False, "message": str(e)}), 400) + + @api.doc(description="Update a folder") + def put(self, folder_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() + if not data: + return make_response(jsonify({"success": False, "message": "No data provided"}), 400) + + try: + update_fields = {"updated_at": datetime.datetime.now(datetime.timezone.utc)} + if "name" in data: + update_fields["name"] = data["name"] + if "parent_id" in data: + if data["parent_id"] == folder_id: + return make_response(jsonify({"success": False, "message": "Cannot set folder as its own parent"}), 400) + update_fields["parent_id"] = data["parent_id"] + + result = agent_folders_collection.update_one( + {"_id": ObjectId(folder_id), "user": user}, {"$set": update_fields} + ) + if result.matched_count == 0: + return make_response(jsonify({"success": False, "message": "Folder not found"}), 404) + return make_response(jsonify({"success": True}), 200) + except Exception as e: + return make_response(jsonify({"success": False, "message": str(e)}), 400) + + @api.doc(description="Delete a folder") + def delete(self, folder_id): + decoded_token = request.decoded_token + if not decoded_token: + return make_response(jsonify({"success": False}), 401) + user = decoded_token.get("sub") + try: + agents_collection.update_many( + {"user": user, "folder_id": folder_id}, {"$unset": {"folder_id": ""}} + ) + agent_folders_collection.update_many( + {"user": user, "parent_id": folder_id}, {"$unset": {"parent_id": ""}} + ) + result = agent_folders_collection.delete_one({"_id": ObjectId(folder_id), "user": user}) + if result.deleted_count == 0: + return make_response(jsonify({"success": False, "message": "Folder not found"}), 404) + return make_response(jsonify({"success": True}), 200) + except Exception as e: + return make_response(jsonify({"success": False, "message": str(e)}), 400) + + +@agents_folders_ns.route("/move_agent") +class MoveAgentToFolder(Resource): + @api.doc(description="Move an agent to a folder or remove from folder") + @api.expect( + api.model( + "MoveAgent", + { + "agent_id": fields.String(required=True, description="Agent ID to move"), + "folder_id": fields.String(required=False, description="Target folder ID (null to remove from folder)"), + }, + ) + ) + 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() + if not data or not data.get("agent_id"): + return make_response(jsonify({"success": False, "message": "Agent ID is required"}), 400) + + agent_id = data["agent_id"] + folder_id = data.get("folder_id") + + try: + agent = agents_collection.find_one({"_id": ObjectId(agent_id), "user": user}) + if not agent: + return make_response(jsonify({"success": False, "message": "Agent not found"}), 404) + + if folder_id: + folder = agent_folders_collection.find_one({"_id": ObjectId(folder_id), "user": user}) + if not folder: + return make_response(jsonify({"success": False, "message": "Folder not found"}), 404) + agents_collection.update_one( + {"_id": ObjectId(agent_id)}, {"$set": {"folder_id": folder_id}} + ) + else: + agents_collection.update_one( + {"_id": ObjectId(agent_id)}, {"$unset": {"folder_id": ""}} + ) + + return make_response(jsonify({"success": True}), 200) + except Exception as e: + return make_response(jsonify({"success": False, "message": str(e)}), 400) + + +@agents_folders_ns.route("/bulk_move") +class BulkMoveAgents(Resource): + @api.doc(description="Move multiple agents to a folder") + @api.expect( + api.model( + "BulkMoveAgents", + { + "agent_ids": fields.List(fields.String, required=True, description="List of agent IDs"), + "folder_id": fields.String(required=False, description="Target folder ID"), + }, + ) + ) + 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() + if not data or not data.get("agent_ids"): + return make_response(jsonify({"success": False, "message": "Agent IDs are required"}), 400) + + agent_ids = data["agent_ids"] + folder_id = data.get("folder_id") + + try: + if folder_id: + folder = agent_folders_collection.find_one({"_id": ObjectId(folder_id), "user": user}) + if not folder: + return make_response(jsonify({"success": False, "message": "Folder not found"}), 404) + + object_ids = [ObjectId(aid) for aid in agent_ids] + if folder_id: + agents_collection.update_many( + {"_id": {"$in": object_ids}, "user": user}, + {"$set": {"folder_id": folder_id}}, + ) + else: + agents_collection.update_many( + {"_id": {"$in": object_ids}, "user": user}, + {"$unset": {"folder_id": ""}}, + ) + return make_response(jsonify({"success": True}), 200) + except Exception as e: + return make_response(jsonify({"success": False, "message": str(e)}), 400) diff --git a/application/api/user/agents/routes.py b/application/api/user/agents/routes.py index a71f8fc8..52d73c37 100644 --- a/application/api/user/agents/routes.py +++ b/application/api/user/agents/routes.py @@ -11,6 +11,7 @@ from flask_restx import fields, Namespace, Resource from application.api import api from application.api.user.base import ( + agent_folders_collection, agents_collection, db, ensure_user_doc, @@ -97,6 +98,7 @@ class GetAgent(Resource): "shared_token": agent.get("shared_token", ""), "models": agent.get("models", []), "default_model_id": agent.get("default_model_id", ""), + "folder_id": agent.get("folder_id"), } return make_response(jsonify(data), 200) except Exception as e: @@ -176,6 +178,7 @@ class GetAgents(Resource): "shared_token": agent.get("shared_token", ""), "models": agent.get("models", []), "default_model_id": agent.get("default_model_id", ""), + "folder_id": agent.get("folder_id"), } for agent in agents if "source" in agent or "retriever" in agent @@ -242,6 +245,9 @@ class CreateAgent(Resource): "default_model_id": fields.String( required=False, description="Default model ID for this agent" ), + "folder_id": fields.String( + required=False, description="Folder ID to organize the agent" + ), }, ) @@ -360,6 +366,22 @@ class CreateAgent(Resource): return make_response( jsonify({"success": False, "message": "Image upload failed"}), 400 ) + + folder_id = data.get("folder_id") + if folder_id: + if not ObjectId.is_valid(folder_id): + return make_response( + jsonify({"success": False, "message": "Invalid folder ID format"}), + 400, + ) + folder = agent_folders_collection.find_one( + {"_id": ObjectId(folder_id), "user": user} + ) + if not folder: + return make_response( + jsonify({"success": False, "message": "Folder not found"}), 404 + ) + try: key = str(uuid.uuid4()) if data.get("status") == "published" else "" @@ -419,6 +441,7 @@ class CreateAgent(Resource): "key": key, "models": data.get("models", []), "default_model_id": data.get("default_model_id", ""), + "folder_id": data.get("folder_id"), } if new_agent["chunks"] == "": new_agent["chunks"] = "2" @@ -492,6 +515,9 @@ class UpdateAgent(Resource): "default_model_id": fields.String( required=False, description="Default model ID for this agent" ), + "folder_id": fields.String( + required=False, description="Folder ID to organize the agent" + ), }, ) @@ -585,6 +611,7 @@ class UpdateAgent(Resource): "request_limit", "models", "default_model_id", + "folder_id", ] for field in allowed_fields: @@ -769,6 +796,32 @@ class UpdateAgent(Resource): ), 400, ) + elif field == "folder_id": + folder_id = data.get("folder_id") + if folder_id: + if not ObjectId.is_valid(folder_id): + return make_response( + jsonify( + { + "success": False, + "message": "Invalid folder ID format", + } + ), + 400, + ) + folder = agent_folders_collection.find_one( + {"_id": ObjectId(folder_id), "user": user} + ) + if not folder: + return make_response( + jsonify( + {"success": False, "message": "Folder not found"} + ), + 404, + ) + update_fields[field] = folder_id + else: + update_fields[field] = None else: value = data[field] if field in ["name", "description", "prompt_id", "agent_type"]: diff --git a/application/api/user/base.py b/application/api/user/base.py index ac99b527..350f6a4d 100644 --- a/application/api/user/base.py +++ b/application/api/user/base.py @@ -31,6 +31,7 @@ sources_collection = db["sources"] prompts_collection = db["prompts"] feedback_collection = db["feedback"] agents_collection = db["agents"] +agent_folders_collection = db["agent_folders"] token_usage_collection = db["token_usage"] shared_conversations_collections = db["shared_conversations"] users_collection = db["users"] diff --git a/application/api/user/routes.py b/application/api/user/routes.py index 82e395c5..d669de60 100644 --- a/application/api/user/routes.py +++ b/application/api/user/routes.py @@ -5,7 +5,7 @@ Main user API routes - registers all namespace modules. from flask import Blueprint from application.api import api -from .agents import agents_ns, agents_sharing_ns, agents_webhooks_ns +from .agents import agents_ns, agents_sharing_ns, agents_webhooks_ns, agents_folders_ns from .analytics import analytics_ns from .attachments import attachments_ns @@ -31,10 +31,11 @@ api.add_namespace(conversations_ns) # Models api.add_namespace(models_ns) -# Agents (main, sharing, webhooks) +# Agents (main, sharing, webhooks, folders) api.add_namespace(agents_ns) api.add_namespace(agents_sharing_ns) api.add_namespace(agents_webhooks_ns) +api.add_namespace(agents_folders_ns) # Prompts api.add_namespace(prompts_ns) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 469a7511..4839b515 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -126,6 +126,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1791,6 +1792,7 @@ "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/core": "^7.21.3", "@svgr/babel-preset": "8.1.0", @@ -2575,6 +2577,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -3238,6 +3241,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3576,6 +3580,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -3756,6 +3761,7 @@ "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", "license": "MIT", + "peer": true, "dependencies": { "@kurkle/color": "^0.3.0" }, @@ -3768,6 +3774,7 @@ "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz", "integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@chevrotain/cst-dts-gen": "11.0.3", "@chevrotain/gast": "11.0.3", @@ -3978,6 +3985,7 @@ "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10" } @@ -4387,6 +4395,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -5001,6 +5010,7 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5077,6 +5087,7 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -6465,6 +6476,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.27.6" }, @@ -9409,6 +9421,7 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -9593,6 +9606,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -9612,6 +9626,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -9711,6 +9726,7 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -9798,7 +9814,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -10936,6 +10953,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -11156,6 +11174,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11415,6 +11434,7 @@ "integrity": "sha512-C/Naxf8H0pBx1PA4BdpT+c/5wdqI9ILMdwjSMILw7tVIh3JsxzZqdeTLmmdaoh5MYUEOyBnM9K3o0DzoZ/fe+w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -11523,6 +11543,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, diff --git a/frontend/src/agents/AgentCard.tsx b/frontend/src/agents/AgentCard.tsx index 536aad4d..53d12a1c 100644 --- a/frontend/src/agents/AgentCard.tsx +++ b/frontend/src/agents/AgentCard.tsx @@ -1,10 +1,12 @@ import { SyntheticEvent, useRef, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; import userService from '../api/services/userService'; import Duplicate from '../assets/duplicate.svg'; import Edit from '../assets/edit.svg'; +import FolderIcon from '../assets/folder.svg'; import Link from '../assets/link-gray.svg'; import Monitoring from '../assets/monitoring.svg'; import Pin from '../assets/pin.svg'; @@ -14,6 +16,7 @@ import UnPin from '../assets/unpin.svg'; import AgentImage from '../components/AgentImage'; import ContextMenu, { MenuOption } from '../components/ContextMenu'; import ConfirmationModal from '../modals/ConfirmationModal'; +import MoveToFolderModal from '../modals/MoveToFolderModal'; import { ActiveState } from '../models/misc'; import { selectAgents, @@ -36,6 +39,7 @@ export default function AgentCard({ updateAgents, section, }: AgentCardProps) { + const { t } = useTranslation(); const navigate = useNavigate(); const dispatch = useDispatch(); const token = useSelector(selectToken); @@ -44,6 +48,7 @@ export default function AgentCard({ const [isMenuOpen, setIsMenuOpen] = useState(false); const [deleteConfirmation, setDeleteConfirmation] = useState('INACTIVE'); + const [moveModalState, setMoveModalState] = useState('INACTIVE'); const menuRef = useRef(null); @@ -99,6 +104,18 @@ export default function AgentCard({ }, ] : []), + { + icon: FolderIcon, + label: t('agents.folders.moveToFolder'), + onClick: (e: SyntheticEvent) => { + e.stopPropagation(); + setMoveModalState('ACTIVE'); + setIsMenuOpen(false); + }, + variant: 'primary', + iconWidth: 16, + iconHeight: 15, + }, { icon: Trash, label: 'Delete', @@ -218,9 +235,19 @@ export default function AgentCard({ console.error('Error:', error); } }; + + const handleMoveSuccess = (folderId: string | null) => { + const updatedAgents = agents.map((prevAgent) => { + if (prevAgent.id === agent.id) { + return { ...prevAgent, folder_id: folderId ?? undefined }; + } + return prevAgent; + }); + updateAgents?.(updatedAgents); + }; return (
{ e.stopPropagation(); handleClick(); @@ -279,6 +306,14 @@ export default function AgentCard({ cancelLabel="Cancel" variant="danger" /> +
); } diff --git a/frontend/src/agents/AgentsList.tsx b/frontend/src/agents/AgentsList.tsx index 0c3c1d0e..d9b19fca 100644 --- a/frontend/src/agents/AgentsList.tsx +++ b/frontend/src/agents/AgentsList.tsx @@ -1,8 +1,9 @@ -import { useEffect } from 'react'; +import { useCallback, useEffect, useMemo, useState, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { useDispatch, useSelector } from 'react-redux'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import userService from '../api/services/userService'; import Search from '../assets/search.svg'; import Spinner from '../components/Spinner'; import { @@ -10,14 +11,18 @@ import { updateConversationId, } from '../conversation/conversationSlice'; import { + selectAgentFolders, selectSelectedAgent, + selectToken, + setAgentFolders, setSelectedAgent, } from '../preferences/preferenceSlice'; import AgentCard from './AgentCard'; import { AgentSectionId, agentSectionsConfig } from './agents.config'; +import FolderCard from './FolderCard'; import { AgentFilterTab, useAgentSearch } from './hooks/useAgentSearch'; import { useAgentsFetch } from './hooks/useAgentsFetch'; -import { Agent } from './types'; +import { Agent, AgentFolder } from './types'; const FILTER_TABS: { id: AgentFilterTab; labelKey: string }[] = [ { id: 'all', labelKey: 'agents.filters.all' }, @@ -29,9 +34,28 @@ const FILTER_TABS: { id: AgentFilterTab; labelKey: string }[] = [ export default function AgentsList() { const { t } = useTranslation(); const dispatch = useDispatch(); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const token = useSelector(selectToken); const selectedAgent = useSelector(selectSelectedAgent); + const folders = useSelector(selectAgentFolders); + const [folderPath, setFolderPath] = useState(() => { + const folderIdFromUrl = searchParams.get('folder'); + return folderIdFromUrl ? [folderIdFromUrl] : []; + }); - const { isLoading } = useAgentsFetch(); + // Sync folder path with URL + useEffect(() => { + const currentFolderInUrl = searchParams.get('folder'); + const currentFolderId = folderPath.length > 0 ? folderPath[folderPath.length - 1] : null; + + if (currentFolderId !== currentFolderInUrl) { + const newUrl = currentFolderId ? `/agents?folder=${currentFolderId}` : '/agents'; + navigate(newUrl, { replace: true }); + } + }, [folderPath, searchParams, navigate]); + + const { isLoading, refetchFolders, refetchUserAgents } = useAgentsFetch(); const { searchQuery, @@ -55,6 +79,57 @@ export default function AgentsList() { if (selectedAgent) dispatch(setSelectedAgent(null)); }, []); + const handleCreateFolder = useCallback( + async (name: string, parentId?: string) => { + const response = await userService.createAgentFolder( + { name, parent_id: parentId }, + token, + ); + if (response.ok) { + await refetchFolders(); + return true; + } + return false; + }, + [token, refetchFolders], + ); + + const handleDeleteFolder = useCallback( + async (folderId: string) => { + const response = await userService.deleteAgentFolder(folderId, token); + if (response.ok) { + await Promise.all([refetchFolders(), refetchUserAgents()]); + return true; + } + return false; + }, + [token, refetchFolders, refetchUserAgents], + ); + + const handleRenameFolder = useCallback( + async (folderId: string, newName: string) => { + const response = await userService.updateAgentFolder( + folderId, + { name: newName }, + token, + ); + if (response.ok) { + dispatch( + setAgentFolders( + (folders || []).map((f) => + f.id === folderId ? { ...f, name: newName } : f, + ), + ), + ); + } + }, + [token, folders, dispatch], + ); + + const handleSubmitNewFolder = async (name: string, parentId?: string) => { + await handleCreateFolder(name, parentId); + }; + const visibleSections = agentSectionsConfig.filter((config) => { if (activeFilter !== 'all') { return config.id === activeFilter; @@ -97,7 +172,7 @@ export default function AgentsList() { value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} placeholder={t('agents.searchPlaceholder')} - className="h-[44px] w-full rounded-full border border-[#E5E5E5] bg-white py-2 pr-5 pl-11 text-sm shadow-[0_1px_4px_rgba(0,0,0,0.06)] transition-shadow outline-none placeholder:text-[#9CA3AF] focus:shadow-[0_2px_8px_rgba(0,0,0,0.1)] dark:border-[#3A3A3A] dark:bg-[#2C2C2C] dark:text-white dark:shadow-none dark:placeholder:text-[#6B7280]" + className="h-11 w-full rounded-full border border-[#E5E5E5] bg-white py-2 pr-5 pl-11 text-sm shadow-[0_1px_4px_rgba(0,0,0,0.06)] transition-shadow outline-none placeholder:text-[#9CA3AF] focus:shadow-[0_2px_8px_rgba(0,0,0,0.1)] dark:border-[#3A3A3A] dark:bg-[#2C2C2C] dark:text-white dark:shadow-none dark:placeholder:text-[#6B7280]" /> @@ -109,7 +184,7 @@ export default function AgentsList() { className={`rounded-full px-4 py-2 text-sm transition-colors ${ activeFilter === tab.id ? 'bg-[#E0E0E0] text-[#18181B] dark:bg-[#4A4A4A] dark:text-white' - : 'bg-transparent text-[#71717A] hover:bg-[#F5F5F5] dark:text-[#949494] dark:hover:bg-[#383838]/50' + : 'dark:text-gray bg-transparent text-[#71717A] hover:bg-[#F5F5F5] dark:hover:bg-[#383838]/50' }`} > {t(tab.labelKey)} @@ -129,6 +204,14 @@ export default function AgentsList() { searchQuery={searchQuery} isFilteredView={activeFilter !== 'all'} isLoading={isLoading[sectionConfig.id as AgentSectionId]} + folders={sectionConfig.id === 'user' ? folders : null} + folderPath={sectionConfig.id === 'user' ? folderPath : []} + onFolderPathChange={ + sectionConfig.id === 'user' ? setFolderPath : undefined + } + onCreateFolder={handleSubmitNewFolder} + onDeleteFolder={handleDeleteFolder} + onRenameFolder={handleRenameFolder} /> ))} @@ -149,6 +232,12 @@ interface AgentSectionProps { searchQuery: string; isFilteredView: boolean; isLoading: boolean; + folders: AgentFolder[] | null; + folderPath: string[]; + onFolderPathChange?: (path: string[]) => void; + onCreateFolder: (name: string, parentId?: string) => void; + onDeleteFolder: (id: string) => Promise; + onRenameFolder: (id: string, name: string) => void; } function AgentSection({ @@ -158,15 +247,114 @@ function AgentSection({ searchQuery, isFilteredView, isLoading, + folders, + folderPath, + onFolderPathChange, + onCreateFolder, + onDeleteFolder, + onRenameFolder, }: AgentSectionProps) { const { t } = useTranslation(); const navigate = useNavigate(); const dispatch = useDispatch(); const allAgents = useSelector(config.selectData); + const [isCreatingFolder, setIsCreatingFolder] = useState(false); + const [newFolderName, setNewFolderName] = useState(''); + const newFolderInputRef = useRef(null); + + const currentFolderId = + folderPath.length > 0 ? folderPath[folderPath.length - 1] : null; + + const setFolderPath = useCallback( + (updater: string[] | ((prev: string[]) => string[])) => { + if (!onFolderPathChange) return; + if (typeof updater === 'function') { + onFolderPathChange(updater(folderPath)); + } else { + onFolderPathChange(updater); + } + }, + [onFolderPathChange, folderPath], + ); const updateAgents = (updatedAgents: Agent[]) => { dispatch(config.updateAction(updatedAgents)); }; + + const currentFolderDescendantIds = useMemo(() => { + if (config.id !== 'user' || !folders || currentFolderId === null) return null; + + const getDescendants = (folderId: string): string[] => { + const children = folders.filter((f) => f.parent_id === folderId); + return children.flatMap((child) => [child.id, ...getDescendants(child.id)]); + }; + + return new Set([currentFolderId, ...getDescendants(currentFolderId)]); + }, [folders, currentFolderId, config.id]); + + const folderHasMatchingAgents = useCallback( + (folderId: string): boolean => { + const directMatches = filteredAgents.some((a) => a.folder_id === folderId); + if (directMatches) return true; + const childFolders = (folders || []).filter( + (f) => f.parent_id === folderId, + ); + return childFolders.some((f) => folderHasMatchingAgents(f.id)); + }, + [filteredAgents, folders], + ); + + // Get folders at the current level (root or inside current folder) + const currentLevelFolders = useMemo(() => { + if (config.id !== 'user' || !folders) return []; + const foldersAtLevel = folders.filter( + (f) => (f.parent_id || null) === currentFolderId, + ); + if (searchQuery) { + return foldersAtLevel.filter((f) => folderHasMatchingAgents(f.id)); + } + return foldersAtLevel; + }, [folders, currentFolderId, config.id, searchQuery, folderHasMatchingAgents]); + + const unfolderedAgents = useMemo(() => { + if (config.id !== 'user' || !folders) return filteredAgents; + + if (searchQuery) { + // When searching at root: return ALL filtered agents + if (currentFolderId === null) { + return filteredAgents; + } + // When searching inside a folder: return agents in current folder OR any descendant + return filteredAgents.filter( + (a) => currentFolderDescendantIds?.has(a.folder_id ?? '') ?? false, + ); + } + + // No search: show agents that belong to the current folder level only + return filteredAgents.filter( + (a) => (a.folder_id || null) === currentFolderId, + ); + }, [filteredAgents, folders, config.id, currentFolderId, searchQuery, currentFolderDescendantIds]); + + const getAgentsForFolder = (folderId: string) => { + return filteredAgents.filter((a) => a.folder_id === folderId); + }; + + const handleNavigateIntoFolder = (folderId: string) => { + setFolderPath((prev) => [...prev, folderId]); + }; + + const handleNavigateToPath = (index: number) => { + if (index < 0) { + setFolderPath([]); + } else { + setFolderPath((prev) => prev.slice(0, index + 1)); + } + }; + + const handleSubmitNewFolder = (name: string) => { + onCreateFolder(name, currentFolderId || undefined); + }; const hasNoAgentsAtAll = !isLoading && totalAgents === 0; const isSearchingWithNoResults = @@ -197,56 +385,187 @@ function AgentSection({ ); } + // Build breadcrumb items from folder path + const breadcrumbItems = useMemo(() => { + if (!folders || folderPath.length === 0) return []; + return folderPath.map((folderId) => { + const folder = folders.find((f) => f.id === folderId); + return { id: folderId, name: folder?.name || '' }; + }); + }, [folders, folderPath]); + + const ChevronIcon = () => ( + + + + ); + return (
-
+
-

- {t(`agents.sections.${config.id}.title`)} +

+ {config.id === 'user' && folderPath.length > 0 ? ( + <> + + {breadcrumbItems.map((item, index) => ( + + + {index === breadcrumbItems.length - 1 ? ( + {item.name} + ) : ( + + )} + + ))} + + ) : ( + t(`agents.sections.${config.id}.title`) + )}

{t(`agents.sections.${config.id}.description`)}

- {config.showNewAgentButton && ( - - )} +
+ {config.id === 'user' && + (isCreatingFolder ? ( + setNewFolderName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && newFolderName.trim()) { + handleSubmitNewFolder(newFolderName.trim()); + setNewFolderName(''); + setIsCreatingFolder(false); + } else if (e.key === 'Escape') { + setNewFolderName(''); + setIsCreatingFolder(false); + } + }} + onBlur={() => { + if (!newFolderName.trim()) { + setIsCreatingFolder(false); + } + }} + placeholder={t('agents.folders.newFolder')} + className="w-28 rounded-full border border-[#E5E5E5] bg-white px-4 py-2 text-sm text-[#18181B] outline-none placeholder:text-[#9CA3AF] sm:w-auto dark:border-[#3A3A3A] dark:bg-[#2C2C2C] dark:text-white dark:placeholder:text-[#6B7280]" + autoFocus + /> + ) : ( + + ))} + {config.showNewAgentButton && ( + + )} +
-
+ +
{isLoading ? (
- ) : filteredAgents.length > 0 ? ( -
- {filteredAgents.map((agent) => ( - - ))} -
- ) : hasNoAgentsAtAll ? ( -
-

{t(`agents.sections.${config.id}.emptyState`)}

- {config.showNewAgentButton && ( - + ) : ( + <> + {/* Show subfolders at current level */} + {config.id === 'user' && currentLevelFolders.length > 0 && ( +
+ {currentLevelFolders.map((folder) => ( + + ))} +
)} -
- ) : null} + + {/* Show agents at current level */} + {unfolderedAgents.length > 0 ? ( +
+ {unfolderedAgents.map((agent) => ( + + ))} +
+ ) : hasNoAgentsAtAll && currentLevelFolders.length === 0 ? ( +
+

+ {currentFolderId + ? t('agents.folders.empty') + : t(`agents.sections.${config.id}.emptyState`)} +

+ {config.showNewAgentButton && !currentFolderId && ( + + )} +
+ ) : null} + + )}
); diff --git a/frontend/src/agents/FolderCard.tsx b/frontend/src/agents/FolderCard.tsx new file mode 100644 index 00000000..a1c8d5af --- /dev/null +++ b/frontend/src/agents/FolderCard.tsx @@ -0,0 +1,128 @@ +import { SyntheticEvent, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import Edit from '../assets/edit.svg'; +import Trash from '../assets/red-trash.svg'; +import ThreeDots from '../assets/three-dots.svg'; +import ContextMenu, { MenuOption } from '../components/ContextMenu'; +import ConfirmationModal from '../modals/ConfirmationModal'; +import FolderNameModal from '../modals/FolderManagementModal'; +import { ActiveState } from '../models/misc'; +import { AgentFolder } from './types'; + +type FolderCardProps = { + folder: AgentFolder; + agentCount: number; + onDelete: (folderId: string) => Promise; + onRename: (folderId: string, newName: string) => void; + isExpanded: boolean; + onToggleExpand: (folderId: string) => void; +}; + +export default function FolderCard({ + folder, + agentCount, + onDelete, + onRename, + isExpanded, + onToggleExpand, +}: FolderCardProps) { + const { t } = useTranslation(); + const [isMenuOpen, setIsMenuOpen] = useState(false); + const [deleteConfirmation, setDeleteConfirmation] = + useState('INACTIVE'); + const [renameModalState, setRenameModalState] = + useState('INACTIVE'); + const menuRef = useRef(null); + + const menuOptions: MenuOption[] = [ + { + icon: Edit, + label: t('agents.folders.rename'), + onClick: (e: SyntheticEvent) => { + e.stopPropagation(); + setRenameModalState('ACTIVE'); + setIsMenuOpen(false); + }, + variant: 'primary', + iconWidth: 14, + iconHeight: 14, + }, + { + icon: Trash, + label: t('agents.folders.delete'), + onClick: (e: SyntheticEvent) => { + e.stopPropagation(); + setDeleteConfirmation('ACTIVE'); + setIsMenuOpen(false); + }, + variant: 'danger', + iconWidth: 13, + iconHeight: 13, + }, + ]; + + const handleRename = (newName: string) => { + onRename(folder.id, newName); + }; + + return ( + <> +
onToggleExpand(folder.id)} + > +
+ + {folder.name} + + + ({agentCount}) + +
+
{ + e.stopPropagation(); + setIsMenuOpen(true); + }} + className="ml-2 shrink-0 cursor-pointer" + > + menu + +
+
+ { + onDelete(folder.id); + setDeleteConfirmation('INACTIVE'); + }} + cancelLabel={t('cancel')} + variant="danger" + /> + + + ); +} + diff --git a/frontend/src/agents/NewAgent.tsx b/frontend/src/agents/NewAgent.tsx index d22476af..08202486 100644 --- a/frontend/src/agents/NewAgent.tsx +++ b/frontend/src/agents/NewAgent.tsx @@ -2,7 +2,7 @@ import isEqual from 'lodash/isEqual'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useDispatch, useSelector } from 'react-redux'; -import { useNavigate, useParams } from 'react-router-dom'; +import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import modelService from '../api/services/modelService'; import userService from '../api/services/userService'; @@ -16,10 +16,12 @@ import AgentDetailsModal from '../modals/AgentDetailsModal'; import ConfirmationModal from '../modals/ConfirmationModal'; import { ActiveState, Doc, Prompt } from '../models/misc'; import { + selectAgentFolders, selectSelectedAgent, selectSourceDocs, selectToken, selectPrompts, + setAgentFolders, setSelectedAgent, setPrompts, } from '../preferences/preferenceSlice'; @@ -37,10 +39,16 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { const dispatch = useDispatch(); const { agentId } = useParams(); + const [searchParams] = useSearchParams(); + const folderIdFromUrl = searchParams.get('folder_id'); + const token = useSelector(selectToken); const sourceDocs = useSelector(selectSourceDocs); const selectedAgent = useSelector(selectSelectedAgent); const prompts = useSelector(selectPrompts); + const agentFolders = useSelector(selectAgentFolders); + + const [validatedFolderId, setValidatedFolderId] = useState(null); const [effectiveMode, setEffectiveMode] = useState(mode); const [agent, setAgent] = useState({ @@ -149,15 +157,22 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { } }, []); + const navigateBackToAgents = useCallback(() => { + const targetPath = validatedFolderId + ? `/agents?folder=${validatedFolderId}` + : '/agents'; + navigate(targetPath); + }, [navigate, validatedFolderId]); + const handleCancel = () => { if (selectedAgent) dispatch(setSelectedAgent(null)); - navigate('/agents'); + navigateBackToAgents(); }; const handleDelete = async (agentId: string) => { const response = await userService.deleteAgent(agentId, token); if (!response.ok) throw new Error('Failed to delete agent'); - navigate('/agents'); + navigateBackToAgents(); }; const handleSaveDraft = async () => { @@ -238,6 +253,10 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { formData.append('default_model_id', agent.default_model_id); } + if (effectiveMode === 'new' && validatedFolderId) { + formData.append('folder_id', validatedFolderId); + } + try { setDraftLoading(true); const response = @@ -341,6 +360,10 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { formData.append('default_model_id', agent.default_model_id); } + if (effectiveMode === 'new' && validatedFolderId) { + formData.append('folder_id', validatedFolderId); + } + try { setPublishLoading(true); const response = @@ -412,6 +435,36 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { getModels(); }, [token]); + // Validate folder_id from URL against user's folders + useEffect(() => { + const validateAndSetFolder = async () => { + if (!folderIdFromUrl) { + setValidatedFolderId(null); + return; + } + + let folders = agentFolders; + if (!folders) { + try { + const response = await userService.getAgentFolders(token); + if (response.ok) { + const data = await response.json(); + folders = data.folders || []; + dispatch(setAgentFolders(folders)); + } + } catch { + setValidatedFolderId(null); + return; + } + } + + const folderExists = folders?.some((f) => f.id === folderIdFromUrl); + setValidatedFolderId(folderExists ? folderIdFromUrl : null); + }; + + validateAndSetFolder(); + }, [folderIdFromUrl, agentFolders, token, dispatch]); + // Auto-select default source if none selected useEffect(() => { if (sourceDocs && sourceDocs.length > 0 && selectedSourceIds.size === 0) { diff --git a/frontend/src/agents/hooks/useAgentsFetch.ts b/frontend/src/agents/hooks/useAgentsFetch.ts index 8f5367d6..eee4719d 100644 --- a/frontend/src/agents/hooks/useAgentsFetch.ts +++ b/frontend/src/agents/hooks/useAgentsFetch.ts @@ -4,6 +4,7 @@ import { useDispatch, useSelector } from 'react-redux'; import userService from '../../api/services/userService'; import { selectToken, + setAgentFolders, setAgents, setSharedAgents, setTemplateAgents, @@ -13,6 +14,8 @@ import { AgentSectionId } from '../agents.config'; interface UseAgentsFetchResult { isLoading: Record; isAllLoaded: boolean; + refetchFolders: () => Promise; + refetchUserAgents: () => Promise; } export function useAgentsFetch(): UseAgentsFetchResult { @@ -64,14 +67,26 @@ export function useAgentsFetch(): UseAgentsFetchResult { } }, [token, dispatch]); + const fetchFolders = useCallback(async () => { + try { + const response = await userService.getAgentFolders(token); + if (!response.ok) throw new Error('Failed to fetch folders'); + const data = await response.json(); + dispatch(setAgentFolders(data.folders || [])); + } catch (error) { + dispatch(setAgentFolders([])); + } + }, [token, dispatch]); + useEffect(() => { setIsLoading({ template: true, user: true, shared: true }); Promise.all([ fetchTemplateAgents(), fetchUserAgents(), fetchSharedAgents(), + fetchFolders(), ]); - }, [fetchTemplateAgents, fetchUserAgents, fetchSharedAgents]); + }, [fetchTemplateAgents, fetchUserAgents, fetchSharedAgents, fetchFolders]); const isAllLoaded = !isLoading.template && !isLoading.user && !isLoading.shared; @@ -79,5 +94,7 @@ export function useAgentsFetch(): UseAgentsFetchResult { return { isLoading, isAllLoaded, + refetchFolders: fetchFolders, + refetchUserAgents: fetchUserAgents, }; } diff --git a/frontend/src/agents/types/index.ts b/frontend/src/agents/types/index.ts index 386f1ebe..e6488a1e 100644 --- a/frontend/src/agents/types/index.ts +++ b/frontend/src/agents/types/index.ts @@ -34,4 +34,13 @@ export type Agent = { request_limit?: number; models?: string[]; default_model_id?: string; + folder_id?: string; +}; + +export type AgentFolder = { + id: string; + name: string; + parent_id?: string | null; + created_at?: string; + updated_at?: string; }; diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index a127cf32..2828f297 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -65,6 +65,9 @@ const endpoints = { MCP_SAVE_SERVER: '/api/mcp_server/save', MCP_OAUTH_STATUS: (task_id: string) => `/api/mcp_server/oauth_status/${task_id}`, + AGENT_FOLDERS: '/api/agents/folders/', + AGENT_FOLDER: (id: string) => `/api/agents/folders/${id}`, + MOVE_AGENT_TO_FOLDER: '/api/agents/folders/move_agent', }, CONVERSATION: { ANSWER: '/api/answer', diff --git a/frontend/src/api/services/userService.ts b/frontend/src/api/services/userService.ts index f707f646..c864f110 100644 --- a/frontend/src/api/services/userService.ts +++ b/frontend/src/api/services/userService.ts @@ -139,6 +139,27 @@ const userService = { token, ); }, + getAgentFolders: (token: string | null): Promise => + apiClient.get(endpoints.USER.AGENT_FOLDERS, token), + createAgentFolder: ( + data: { name: string; parent_id?: string }, + token: string | null, + ): Promise => + apiClient.post(endpoints.USER.AGENT_FOLDERS, data, token), + getAgentFolder: (id: string, token: string | null): Promise => + apiClient.get(endpoints.USER.AGENT_FOLDER(id), token), + updateAgentFolder: ( + id: string, + data: { name?: string; parent_id?: string }, + token: string | null, + ): Promise => apiClient.put(endpoints.USER.AGENT_FOLDER(id), data, token), + deleteAgentFolder: (id: string, token: string | null): Promise => + apiClient.delete(endpoints.USER.AGENT_FOLDER(id), token), + moveAgentToFolder: ( + data: { agent_id: string; folder_id?: string | null }, + token: string | null, + ): Promise => + apiClient.post(endpoints.USER.MOVE_AGENT_TO_FOLDER, data, token), }; export default userService; diff --git a/frontend/src/locale/de.json b/frontend/src/locale/de.json index 89890ae7..4ff7a2a6 100644 --- a/frontend/src/locale/de.json +++ b/frontend/src/locale/de.json @@ -634,7 +634,23 @@ "preview": { "testMessage": "Teste deinen Agenten hier. Veröffentlichte Agenten können in Konversationen verwendet werden." }, - "deleteConfirmation": "Bist du sicher, dass du diesen Agenten löschen möchtest?" + "deleteConfirmation": "Bist du sicher, dass du diesen Agenten löschen möchtest?", + "folders": { + "newFolder": "Neuer Ordner", + "createFolder": "Ordner erstellen", + "folderName": "Ordnername", + "rename": "Umbenennen", + "delete": "Löschen", + "deleteConfirm": "Bist du sicher, dass du diesen Ordner löschen möchtest? Agenten im Ordner werden verschoben.", + "empty": "Dieser Ordner ist leer", + "moveToFolder": "In Ordner verschieben", + "moveTo": "Verschieben", + "move": "Verschieben", + "noFolder": "Kein Ordner (Stammverzeichnis)", + "backToRoot": "Zurück", + "noSubfolders": "Keine Unterordner", + "noFolders": "Noch keine Ordner" + } }, "components": { "fileUpload": { diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index f1c3afd5..9f40d2ab 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -634,7 +634,24 @@ "preview": { "testMessage": "Test your agent here. Published agents can be used in conversations." }, - "deleteConfirmation": "Are you sure you want to delete this agent?" + "deleteConfirmation": "Are you sure you want to delete this agent?", + "folders": { + "newFolder": "New Folder", + "createFolder": "Create Folder", + "folderName": "Folder name", + "rename": "Rename", + "delete": "Delete", + "deleteConfirm": "Are you sure you want to delete this folder? Agents inside will be moved out of the folder.", + "empty": "This folder is empty", + "moveToFolder": "Move to folder", + "moveTo": "Move", + "move": "Move", + "noFolder": "No folder (root)", + "backToRoot": "Back", + "currentFolder": "This folder", + "noSubfolders": "No subfolders", + "noFolders": "No folders yet" + } }, "components": { "fileUpload": { diff --git a/frontend/src/locale/es.json b/frontend/src/locale/es.json index 5e20dff9..e8006404 100644 --- a/frontend/src/locale/es.json +++ b/frontend/src/locale/es.json @@ -634,7 +634,23 @@ "preview": { "testMessage": "Prueba tu agente aquí. Los agentes publicados se pueden usar en conversaciones." }, - "deleteConfirmation": "¿Estás seguro de que quieres eliminar este agente?" + "deleteConfirmation": "¿Estás seguro de que quieres eliminar este agente?", + "folders": { + "newFolder": "Nueva carpeta", + "createFolder": "Crear carpeta", + "folderName": "Nombre de la carpeta", + "rename": "Renombrar", + "delete": "Eliminar", + "deleteConfirm": "¿Estás seguro de que quieres eliminar esta carpeta? Los agentes serán movidos fuera de la carpeta.", + "empty": "Esta carpeta está vacía", + "moveToFolder": "Mover a carpeta", + "moveTo": "Mover", + "move": "Mover", + "noFolder": "Sin carpeta (raíz)", + "backToRoot": "Volver", + "noSubfolders": "Sin subcarpetas", + "noFolders": "No hay carpetas todavía" + } }, "components": { "fileUpload": { diff --git a/frontend/src/locale/jp.json b/frontend/src/locale/jp.json index 233969eb..75fac241 100644 --- a/frontend/src/locale/jp.json +++ b/frontend/src/locale/jp.json @@ -634,7 +634,23 @@ "preview": { "testMessage": "ここでエージェントをテストできます。公開されたエージェントは会話で使用できます。" }, - "deleteConfirmation": "このエージェントを削除してもよろしいですか?" + "deleteConfirmation": "このエージェントを削除してもよろしいですか?", + "folders": { + "newFolder": "新しいフォルダ", + "createFolder": "フォルダを作成", + "folderName": "フォルダ名", + "rename": "名前を変更", + "delete": "削除", + "deleteConfirm": "このフォルダを削除してもよろしいですか?フォルダ内のエージェントは移動されます。", + "empty": "このフォルダは空です", + "moveToFolder": "フォルダに移動", + "moveTo": "移動", + "move": "移動", + "noFolder": "フォルダなし (ルート)", + "backToRoot": "戻る", + "noSubfolders": "サブフォルダなし", + "noFolders": "フォルダがありません" + } }, "components": { "fileUpload": { diff --git a/frontend/src/locale/ru.json b/frontend/src/locale/ru.json index 76b7b316..ada52aa7 100644 --- a/frontend/src/locale/ru.json +++ b/frontend/src/locale/ru.json @@ -634,7 +634,23 @@ "preview": { "testMessage": "Протестируйте своего агента здесь. Опубликованные агенты можно использовать в разговорах." }, - "deleteConfirmation": "Вы уверены, что хотите удалить этого агента?" + "deleteConfirmation": "Вы уверены, что хотите удалить этого агента?", + "folders": { + "newFolder": "Новая папка", + "createFolder": "Создать папку", + "folderName": "Имя папки", + "rename": "Переименовать", + "delete": "Удалить", + "deleteConfirm": "Вы уверены, что хотите удалить эту папку? Агенты в папке будут перемещены.", + "empty": "Эта папка пуста", + "moveToFolder": "Переместить в папку", + "moveTo": "Переместить", + "move": "Переместить", + "noFolder": "Без папки (корень)", + "backToRoot": "Назад", + "noSubfolders": "Нет подпапок", + "noFolders": "Пока нет папок" + } }, "components": { "fileUpload": { diff --git a/frontend/src/locale/zh-TW.json b/frontend/src/locale/zh-TW.json index 2b1a9e0b..247dd5f0 100644 --- a/frontend/src/locale/zh-TW.json +++ b/frontend/src/locale/zh-TW.json @@ -634,7 +634,23 @@ "preview": { "testMessage": "在此測試您的代理。已發佈的代理可以在對話中使用。" }, - "deleteConfirmation": "您確定要刪除此代理嗎?" + "deleteConfirmation": "您確定要刪除此代理嗎?", + "folders": { + "newFolder": "新建資料夾", + "createFolder": "建立資料夾", + "folderName": "資料夾名稱", + "rename": "重新命名", + "delete": "刪除", + "deleteConfirm": "確定要刪除此資料夾嗎?資料夾中的代理將被移出。", + "empty": "此資料夾為空", + "moveToFolder": "移動到資料夾", + "moveTo": "移動", + "move": "移動", + "noFolder": "無資料夾 (根目錄)", + "backToRoot": "返回", + "noSubfolders": "沒有子資料夾", + "noFolders": "暫無資料夾" + } }, "components": { "fileUpload": { diff --git a/frontend/src/locale/zh.json b/frontend/src/locale/zh.json index 33635026..305a898e 100644 --- a/frontend/src/locale/zh.json +++ b/frontend/src/locale/zh.json @@ -634,7 +634,23 @@ "preview": { "testMessage": "在此测试您的代理。已发布的代理可以在对话中使用。" }, - "deleteConfirmation": "您确定要删除此代理吗?" + "deleteConfirmation": "您确定要删除此代理吗?", + "folders": { + "newFolder": "新建文件夹", + "createFolder": "创建文件夹", + "folderName": "文件夹名称", + "rename": "重命名", + "delete": "删除", + "deleteConfirm": "确定要删除此文件夹吗?文件夹中的代理将被移出。", + "empty": "此文件夹为空", + "moveToFolder": "移动到文件夹", + "moveTo": "移动", + "move": "移动", + "noFolder": "无文件夹 (根目录)", + "backToRoot": "返回", + "noSubfolders": "没有子文件夹", + "noFolders": "暂无文件夹" + } }, "components": { "fileUpload": { diff --git a/frontend/src/modals/FolderManagementModal.tsx b/frontend/src/modals/FolderManagementModal.tsx new file mode 100644 index 00000000..f6d0e605 --- /dev/null +++ b/frontend/src/modals/FolderManagementModal.tsx @@ -0,0 +1,87 @@ +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { ActiveState } from '../models/misc'; +import WrapperModal from './WrapperModal'; + +type FolderNameModalProps = { + modalState: ActiveState; + setModalState: (state: ActiveState) => void; + mode: 'create' | 'rename'; + initialName?: string; + onSubmit: (name: string) => void; +}; + +export default function FolderNameModal({ + modalState, + setModalState, + mode, + initialName = '', + onSubmit, +}: FolderNameModalProps) { + const { t } = useTranslation(); + const [name, setName] = useState(initialName); + + useEffect(() => { + if (modalState === 'ACTIVE') { + setName(initialName); + } + }, [modalState, initialName]); + + const handleSubmit = () => { + if (name.trim()) { + onSubmit(name.trim()); + setModalState('INACTIVE'); + setName(''); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleSubmit(); + } + }; + + if (modalState !== 'ACTIVE') return null; + + return ( + setModalState('INACTIVE')}> +
+

+ {mode === 'create' + ? t('agents.folders.newFolder') + : t('agents.folders.rename')} +

+ setName(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={t('agents.folders.folderName')} + autoFocus + className="w-full rounded-lg border border-[#E5E5E5] bg-white px-3 py-2 text-sm outline-none dark:border-[#3A3A3A] dark:bg-[#2C2C2C] dark:text-white" + /> +
+ + +
+
+
+ ); +} diff --git a/frontend/src/modals/MoveToFolderModal.tsx b/frontend/src/modals/MoveToFolderModal.tsx new file mode 100644 index 00000000..1bd357d4 --- /dev/null +++ b/frontend/src/modals/MoveToFolderModal.tsx @@ -0,0 +1,374 @@ +import { useEffect, useState, useMemo, useRef } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useTranslation } from 'react-i18next'; + +import userService from '../api/services/userService'; +import FolderIcon from '../assets/folder.svg'; +import ChevronRight from '../assets/chevron-right.svg'; +import { ActiveState } from '../models/misc'; +import { selectToken, setAgentFolders } from '../preferences/preferenceSlice'; +import { AgentFolder } from '../agents/types'; +import WrapperModal from './WrapperModal'; + +type MoveToFolderModalProps = { + modalState: ActiveState; + setModalState: (state: ActiveState) => void; + agentName: string; + agentId: string; + currentFolderId?: string; + onMoveSuccess: (folderId: string | null) => void; +}; + +export default function MoveToFolderModal({ + modalState, + setModalState, + agentName, + agentId, + currentFolderId, + onMoveSuccess, +}: MoveToFolderModalProps) { + const { t } = useTranslation(); + const dispatch = useDispatch(); + const token = useSelector(selectToken); + const [folders, setFolders] = useState([]); + const [selectedFolderId, setSelectedFolderId] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [isCreatingFolder, setIsCreatingFolder] = useState(false); + const [newFolderName, setNewFolderName] = useState(''); + const newFolderInputRef = useRef(null); + // Track navigation path for nested folders + const [folderPath, setFolderPath] = useState([]); + + const currentNavigationFolderId = + folderPath.length > 0 ? folderPath[folderPath.length - 1] : null; + + useEffect(() => { + if (modalState === 'ACTIVE') { + fetchFolders(); + setSelectedFolderId(currentFolderId || null); + setFolderPath([]); + } + }, [modalState]); + + const fetchFolders = async () => { + setIsLoading(true); + try { + const response = await userService.getAgentFolders(token); + if (response.ok) { + const data = await response.json(); + setFolders(data.folders || []); + } + } catch (error) { + console.error('Failed to fetch folders:', error); + } finally { + setIsLoading(false); + } + }; + + // Get folders at the current navigation level + const currentLevelFolders = useMemo(() => { + return folders.filter( + (f) => (f.parent_id || null) === currentNavigationFolderId, + ); + }, [folders, currentNavigationFolderId]); + + // Build breadcrumb items + const breadcrumbItems = useMemo(() => { + return folderPath.map((folderId) => { + const folder = folders.find((f) => f.id === folderId); + return { id: folderId, name: folder?.name || '' }; + }); + }, [folders, folderPath]); + + const handleNavigateIntoFolder = (folderId: string) => { + setFolderPath((prev) => [...prev, folderId]); + }; + + const handleNavigateToPath = (index: number) => { + if (index < 0) { + setFolderPath([]); + } else { + setFolderPath((prev) => prev.slice(0, index + 1)); + } + }; + + const handleCreateFolder = async (name: string) => { + try { + const response = await userService.createAgentFolder( + { name, parent_id: currentNavigationFolderId || undefined }, + token, + ); + if (response.ok) { + const data = await response.json(); + const newFolder = { + id: data.id, + name: data.name, + parent_id: currentNavigationFolderId, + }; + setFolders((prev) => { + const updatedFolders = [...prev, newFolder]; + dispatch(setAgentFolders(updatedFolders)); + return updatedFolders; + }); + setSelectedFolderId(data.id); + } + } catch (error) { + console.error('Failed to create folder:', error); + } + }; + + const handleMove = async () => { + try { + const response = await userService.moveAgentToFolder( + { agent_id: agentId, folder_id: selectedFolderId }, + token, + ); + if (response.ok) { + onMoveSuccess(selectedFolderId); + setModalState('INACTIVE'); + } + } catch (error) { + console.error('Failed to move agent:', error); + } + }; + + if (modalState !== 'ACTIVE') return null; + + return ( + setModalState('INACTIVE')} className="!p-0"> +
+
+

+ {t('agents.folders.move')} "{agentName}" to +

+
+
+ + {breadcrumbItems.map((item, index) => ( + + + + + {index === breadcrumbItems.length - 1 ? ( + {item.name} + ) : ( + + )} + + ))} +
+
+ {isLoading ? ( +
+ + {t('loading')}... + +
+ ) : ( +
+ {/* Option to move to root (no folder) - only show at root level */} + {currentFolderId && folderPath.length === 0 && ( + + )} + + {currentLevelFolders.map((folder) => ( + + ))} + {currentLevelFolders.length === 0 && folderPath.length > 0 && ( +
+ {t('agents.folders.noSubfolders')} +
+ )} + {currentLevelFolders.length === 0 && + folderPath.length === 0 && + !currentFolderId && ( +
+ {t('agents.folders.noFolders')} +
+ )} +
+ )} +
+ +
+ {isCreatingFolder ? ( + setNewFolderName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && newFolderName.trim()) { + handleCreateFolder(newFolderName.trim()); + setNewFolderName(''); + setIsCreatingFolder(false); + } else if (e.key === 'Escape') { + setNewFolderName(''); + setIsCreatingFolder(false); + } + }} + onBlur={() => { + if (!newFolderName.trim()) { + setIsCreatingFolder(false); + } + }} + placeholder={t('agents.folders.newFolder')} + className="rounded-full border border-[#7D54D1] bg-transparent px-6 py-2 text-sm font-medium text-[#7D54D1] outline-none placeholder:text-[#7D54D1]/60 dark:text-[#B794F4] dark:placeholder:text-[#B794F4]/60" + autoFocus + /> + ) : ( + + )} + +
+ + {isCreatingFolder ? ( + + ) : ( + + )} +
+
+
+
+ ); +} diff --git a/frontend/src/modals/WrapperModal.tsx b/frontend/src/modals/WrapperModal.tsx index 1e00c2d7..7884745c 100644 --- a/frontend/src/modals/WrapperModal.tsx +++ b/frontend/src/modals/WrapperModal.tsx @@ -42,8 +42,15 @@ export default function WrapperModal({ }, [close, isPerformingTask]); const modalContent = ( -
-
+
e.stopPropagation()} + onMouseDown={(e: React.MouseEvent) => e.stopPropagation()} + > +
) => { state.modelsLoading = action.payload; }, + setAgentFolders: (state, action: PayloadAction) => { + state.agentFolders = action.payload; + }, }, }); @@ -151,6 +156,7 @@ export const { setSelectedModel, setAvailableModels, setModelsLoading, + setAgentFolders, } = prefSlice.actions; export default prefSlice.reducer; @@ -278,3 +284,5 @@ export const selectAvailableModels = (state: RootState) => state.preference.availableModels; export const selectModelsLoading = (state: RootState) => state.preference.modelsLoading; +export const selectAgentFolders = (state: RootState) => + state.preference.agentFolders; diff --git a/frontend/src/store.ts b/frontend/src/store.ts index 68100698..f5844a9a 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -54,6 +54,7 @@ const preloadedState: { preference: Preference } = { selectedModel: selectedModel ? JSON.parse(selectedModel) : null, availableModels: [], modelsLoading: false, + agentFolders: null, }, }; const store = configureStore({