mirror of
https://github.com/arc53/DocsGPT.git
synced 2026-01-23 07:20:33 +00:00
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 <action@github.com>
This commit is contained in:
@@ -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"]
|
||||
|
||||
261
application/api/user/agents/folders.py
Normal file
261
application/api/user/agents/folders.py
Normal file
@@ -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("/<string:folder_id>")
|
||||
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)
|
||||
@@ -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"]:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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)
|
||||
|
||||
23
frontend/package-lock.json
generated
23
frontend/package-lock.json
generated
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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<boolean>(false);
|
||||
const [deleteConfirmation, setDeleteConfirmation] =
|
||||
useState<ActiveState>('INACTIVE');
|
||||
const [moveModalState, setMoveModalState] = useState<ActiveState>('INACTIVE');
|
||||
|
||||
const menuRef = useRef<HTMLDivElement>(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 (
|
||||
<div
|
||||
className={`relative flex h-44 w-full flex-col justify-between rounded-[1.2rem] bg-[#F6F6F6] px-6 py-5 hover:bg-[#ECECEC] md:w-48 dark:bg-[#383838] dark:hover:bg-[#383838]/80 ${agent.status === 'published' && 'cursor-pointer'}`}
|
||||
className={`relative flex h-44 flex-col justify-between rounded-[1.2rem] bg-[#F6F6F6] px-4 py-5 hover:bg-[#ECECEC] sm:w-48 sm:px-6 dark:bg-[#383838] dark:hover:bg-[#383838]/80 ${agent.status === 'published' && 'cursor-pointer'}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleClick();
|
||||
@@ -279,6 +306,14 @@ export default function AgentCard({
|
||||
cancelLabel="Cancel"
|
||||
variant="danger"
|
||||
/>
|
||||
<MoveToFolderModal
|
||||
modalState={moveModalState}
|
||||
setModalState={setMoveModalState}
|
||||
agentName={agent.name}
|
||||
agentId={agent.id ?? ''}
|
||||
currentFolderId={agent.folder_id}
|
||||
onMoveSuccess={handleMoveSuccess}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string[]>(() => {
|
||||
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]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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<boolean>;
|
||||
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<HTMLInputElement>(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 = () => (
|
||||
<svg
|
||||
width="6"
|
||||
height="10"
|
||||
viewBox="0 0 6 10"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M5.54027 4.45973C5.68108 4.60058 5.76018 4.79159 5.76018 4.99075C5.76018 5.18992 5.68108 5.38092 5.54027 5.52177L1.29134 9.7707C1.22206 9.84244 1.13918 9.89966 1.04754 9.93902C0.955906 9.97839 0.857348 9.9991 0.757618 9.99997C0.657889 10.0008 0.558986 9.98183 0.466679 9.94407C0.374373 9.9063 0.290512 9.85053 0.21999 9.78001C0.149467 9.70949 0.0936966 9.62563 0.055931 9.53332C0.0181655 9.44101 -0.000838292 9.34211 2.83259e-05 9.24238C0.000894943 9.14265 0.0216148 9.04409 0.0609787 8.95246C0.100343 8.86082 0.157562 8.77794 0.229299 8.70866L3.9472 4.99075L0.229299 1.27285C0.0924814 1.13119 0.0167756 0.941464 0.0184869 0.744531C0.0201982 0.547597 0.0991896 0.359213 0.238448 0.219954C0.377707 0.0806961 0.56609 0.00170419 0.763024 -7.66275e-06C0.959958 -0.00171856 1.14969 0.073987 1.29134 0.210805L5.54027 4.45973Z"
|
||||
fill="currentColor"
|
||||
fillOpacity="0.5"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mt-8 flex flex-col gap-4">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex w-full flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-[18px] font-semibold text-[#18181B] dark:text-[#E0E0E0]">
|
||||
{t(`agents.sections.${config.id}.title`)}
|
||||
<h2 className="flex flex-wrap items-center gap-2 text-[18px] font-semibold text-[#18181B] dark:text-[#E0E0E0]">
|
||||
{config.id === 'user' && folderPath.length > 0 ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleNavigateToPath(-1)}
|
||||
className="text-[#71717A] hover:text-[#18181B] dark:hover:text-white"
|
||||
>
|
||||
{t(`agents.sections.${config.id}.title`)}
|
||||
</button>
|
||||
{breadcrumbItems.map((item, index) => (
|
||||
<span key={item.id} className="flex items-center gap-2">
|
||||
<ChevronIcon />
|
||||
{index === breadcrumbItems.length - 1 ? (
|
||||
<span>{item.name}</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleNavigateToPath(index)}
|
||||
className="text-[#71717A] hover:text-[#18181B] dark:hover:text-white"
|
||||
>
|
||||
{item.name}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
t(`agents.sections.${config.id}.title`)
|
||||
)}
|
||||
</h2>
|
||||
<p className="text-[13px] text-[#71717A]">
|
||||
{t(`agents.sections.${config.id}.description`)}
|
||||
</p>
|
||||
</div>
|
||||
{config.showNewAgentButton && (
|
||||
<button
|
||||
className="bg-purple-30 hover:bg-violets-are-blue rounded-full px-4 py-2 text-sm text-white"
|
||||
onClick={() => navigate('/agents/new')}
|
||||
>
|
||||
{t('agents.newAgent')}
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{config.id === 'user' &&
|
||||
(isCreatingFolder ? (
|
||||
<input
|
||||
ref={newFolderInputRef}
|
||||
type="text"
|
||||
value={newFolderName}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
className="shrink-0 whitespace-nowrap rounded-full border border-[#E5E5E5] bg-white px-4 py-2 text-sm text-[#18181B] hover:bg-[#F5F5F5] dark:border-[#3A3A3A] dark:bg-[#2C2C2C] dark:text-white dark:hover:bg-[#383838]"
|
||||
onClick={() => {
|
||||
setIsCreatingFolder(true);
|
||||
setTimeout(() => newFolderInputRef.current?.focus(), 0);
|
||||
}}
|
||||
>
|
||||
{t('agents.folders.newFolder')}
|
||||
</button>
|
||||
))}
|
||||
{config.showNewAgentButton && (
|
||||
<button
|
||||
className="bg-purple-30 hover:bg-violets-are-blue shrink-0 whitespace-nowrap rounded-full px-4 py-2 text-sm text-white"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
currentFolderId
|
||||
? `/agents/new?folder_id=${currentFolderId}`
|
||||
: '/agents/new',
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('agents.newAgent')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{isLoading ? (
|
||||
<div className="flex h-40 w-full items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : filteredAgents.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-4 sm:flex sm:flex-wrap">
|
||||
{filteredAgents.map((agent) => (
|
||||
<AgentCard
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
agents={allAgents || []}
|
||||
updateAgents={updateAgents}
|
||||
section={config.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : hasNoAgentsAtAll ? (
|
||||
<div className="flex h-40 w-full flex-col items-center justify-center gap-3 text-[#71717A]">
|
||||
<p>{t(`agents.sections.${config.id}.emptyState`)}</p>
|
||||
{config.showNewAgentButton && (
|
||||
<button
|
||||
className="bg-purple-30 hover:bg-violets-are-blue ml-2 rounded-full px-4 py-2 text-sm text-white"
|
||||
onClick={() => navigate('/agents/new')}
|
||||
>
|
||||
{t('agents.newAgent')}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
{/* Show subfolders at current level */}
|
||||
{config.id === 'user' && currentLevelFolders.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-3 sm:flex sm:flex-wrap">
|
||||
{currentLevelFolders.map((folder) => (
|
||||
<FolderCard
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
agentCount={getAgentsForFolder(folder.id).length}
|
||||
onDelete={onDeleteFolder}
|
||||
onRename={onRenameFolder}
|
||||
isExpanded={false}
|
||||
onToggleExpand={handleNavigateIntoFolder}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Show agents at current level */}
|
||||
{unfolderedAgents.length > 0 ? (
|
||||
<div className="grid grid-cols-2 gap-3 sm:flex sm:flex-wrap">
|
||||
{unfolderedAgents.map((agent) => (
|
||||
<AgentCard
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
agents={allAgents || []}
|
||||
updateAgents={updateAgents}
|
||||
section={config.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : hasNoAgentsAtAll && currentLevelFolders.length === 0 ? (
|
||||
<div className="flex h-40 w-full flex-col items-center justify-center gap-3 text-[#71717A]">
|
||||
<p>
|
||||
{currentFolderId
|
||||
? t('agents.folders.empty')
|
||||
: t(`agents.sections.${config.id}.emptyState`)}
|
||||
</p>
|
||||
{config.showNewAgentButton && !currentFolderId && (
|
||||
<button
|
||||
className="bg-purple-30 hover:bg-violets-are-blue ml-2 rounded-full px-4 py-2 text-sm text-white"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
currentFolderId
|
||||
? `/agents/new?folder_id=${currentFolderId}`
|
||||
: '/agents/new',
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('agents.newAgent')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
128
frontend/src/agents/FolderCard.tsx
Normal file
128
frontend/src/agents/FolderCard.tsx
Normal file
@@ -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<boolean>;
|
||||
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<ActiveState>('INACTIVE');
|
||||
const [renameModalState, setRenameModalState] =
|
||||
useState<ActiveState>('INACTIVE');
|
||||
const menuRef = useRef<HTMLDivElement>(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 (
|
||||
<>
|
||||
<div
|
||||
className={`relative flex cursor-pointer items-center justify-between rounded-[1.2rem] px-4 py-3 sm:w-48 ${
|
||||
isExpanded
|
||||
? 'bg-[#E5E5E5] dark:bg-[#454545]'
|
||||
: 'bg-[#F6F6F6] hover:bg-[#ECECEC] dark:bg-[#383838] dark:hover:bg-[#383838]/80'
|
||||
}`}
|
||||
onClick={() => onToggleExpand(folder.id)}
|
||||
>
|
||||
<div className="flex items-center gap-2 overflow-hidden">
|
||||
<span className="truncate text-sm font-medium text-[#18181B] dark:text-[#E0E0E0]">
|
||||
{folder.name}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-[#71717A]">
|
||||
({agentCount})
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
ref={menuRef}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsMenuOpen(true);
|
||||
}}
|
||||
className="ml-2 shrink-0 cursor-pointer"
|
||||
>
|
||||
<img src={ThreeDots} alt="menu" className="h-4 w-4" />
|
||||
<ContextMenu
|
||||
isOpen={isMenuOpen}
|
||||
setIsOpen={setIsMenuOpen}
|
||||
options={menuOptions}
|
||||
anchorRef={menuRef}
|
||||
position="bottom-right"
|
||||
offset={{ x: 0, y: 0 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ConfirmationModal
|
||||
message={t('agents.folders.deleteConfirm')}
|
||||
modalState={deleteConfirmation}
|
||||
setModalState={setDeleteConfirmation}
|
||||
submitLabel={t('convTile.delete')}
|
||||
handleSubmit={() => {
|
||||
onDelete(folder.id);
|
||||
setDeleteConfirmation('INACTIVE');
|
||||
}}
|
||||
cancelLabel={t('cancel')}
|
||||
variant="danger"
|
||||
/>
|
||||
<FolderNameModal
|
||||
modalState={renameModalState}
|
||||
setModalState={setRenameModalState}
|
||||
mode="rename"
|
||||
initialName={folder.name}
|
||||
onSubmit={handleRename}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
|
||||
const [effectiveMode, setEffectiveMode] = useState(mode);
|
||||
const [agent, setAgent] = useState<Agent>({
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<AgentSectionId, boolean>;
|
||||
isAllLoaded: boolean;
|
||||
refetchFolders: () => Promise<void>;
|
||||
refetchUserAgents: () => Promise<void>;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -139,6 +139,27 @@ const userService = {
|
||||
token,
|
||||
);
|
||||
},
|
||||
getAgentFolders: (token: string | null): Promise<any> =>
|
||||
apiClient.get(endpoints.USER.AGENT_FOLDERS, token),
|
||||
createAgentFolder: (
|
||||
data: { name: string; parent_id?: string },
|
||||
token: string | null,
|
||||
): Promise<any> =>
|
||||
apiClient.post(endpoints.USER.AGENT_FOLDERS, data, token),
|
||||
getAgentFolder: (id: string, token: string | null): Promise<any> =>
|
||||
apiClient.get(endpoints.USER.AGENT_FOLDER(id), token),
|
||||
updateAgentFolder: (
|
||||
id: string,
|
||||
data: { name?: string; parent_id?: string },
|
||||
token: string | null,
|
||||
): Promise<any> => apiClient.put(endpoints.USER.AGENT_FOLDER(id), data, token),
|
||||
deleteAgentFolder: (id: string, token: string | null): Promise<any> =>
|
||||
apiClient.delete(endpoints.USER.AGENT_FOLDER(id), token),
|
||||
moveAgentToFolder: (
|
||||
data: { agent_id: string; folder_id?: string | null },
|
||||
token: string | null,
|
||||
): Promise<any> =>
|
||||
apiClient.post(endpoints.USER.MOVE_AGENT_TO_FOLDER, data, token),
|
||||
};
|
||||
|
||||
export default userService;
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
87
frontend/src/modals/FolderManagementModal.tsx
Normal file
87
frontend/src/modals/FolderManagementModal.tsx
Normal file
@@ -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 (
|
||||
<WrapperModal close={() => setModalState('INACTIVE')}>
|
||||
<div className="w-72">
|
||||
<h2 className="text-jet dark:text-bright-gray mb-4 text-lg font-semibold">
|
||||
{mode === 'create'
|
||||
? t('agents.folders.newFolder')
|
||||
: t('agents.folders.rename')}
|
||||
</h2>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setModalState('INACTIVE');
|
||||
setName('');
|
||||
}}
|
||||
className="dark:text-light-gray cursor-pointer rounded-3xl px-5 py-2 text-sm font-medium hover:bg-gray-100 dark:bg-transparent dark:hover:bg-[#767183]/50"
|
||||
>
|
||||
{t('cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!name.trim()}
|
||||
className="bg-purple-30 hover:bg-violets-are-blue rounded-3xl px-5 py-2 text-sm text-white disabled:opacity-50"
|
||||
>
|
||||
{mode === 'create'
|
||||
? t('agents.folders.createFolder')
|
||||
: t('agents.folders.rename')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</WrapperModal>
|
||||
);
|
||||
}
|
||||
374
frontend/src/modals/MoveToFolderModal.tsx
Normal file
374
frontend/src/modals/MoveToFolderModal.tsx
Normal file
@@ -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<AgentFolder[]>([]);
|
||||
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isCreatingFolder, setIsCreatingFolder] = useState(false);
|
||||
const [newFolderName, setNewFolderName] = useState('');
|
||||
const newFolderInputRef = useRef<HTMLInputElement>(null);
|
||||
// Track navigation path for nested folders
|
||||
const [folderPath, setFolderPath] = useState<string[]>([]);
|
||||
|
||||
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 (
|
||||
<WrapperModal close={() => setModalState('INACTIVE')} className="!p-0">
|
||||
<div className="w-[800px] max-w-[90vw]">
|
||||
<div className="px-6 pt-4">
|
||||
<h2
|
||||
className="text-jet dark:text-bright-gray mb-2 font-semibold"
|
||||
style={{
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
fontSize: '22px',
|
||||
lineHeight: '28px',
|
||||
letterSpacing: '0.15px',
|
||||
}}
|
||||
>
|
||||
{t('agents.folders.move')} "{agentName}" to
|
||||
</h2>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center gap-1 bg-[#F6F8FA] px-8 py-2 text-xs font-semibold text-[#59636E] dark:bg-[#2A2A2A] dark:text-gray-400"
|
||||
style={{ fontFamily: "'Segoe UI', sans-serif" }}
|
||||
>
|
||||
<button
|
||||
onClick={() => handleNavigateToPath(-1)}
|
||||
className={`hover:text-[#18181B] dark:hover:text-white ${folderPath.length > 0 ? 'opacity-70' : ''}`}
|
||||
>
|
||||
{t('agents.filters.byMe')}
|
||||
</button>
|
||||
{breadcrumbItems.map((item, index) => (
|
||||
<span key={item.id} className="flex items-center gap-1">
|
||||
<svg
|
||||
className="mx-1"
|
||||
width="5"
|
||||
height="10"
|
||||
viewBox="0 0 5 10"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M0.134367 9.15687C0.0914459 9.20458 0.0578918 9.2607 0.0356192 9.32203C0.0133471 9.38335 0.00279279 9.44869 0.00455995 9.5143C0.00632664 9.57992 0.0203805 9.64452 0.045918 9.70443C0.0714555 9.76434 0.107977 9.81837 0.153397 9.86346C0.198817 9.90854 0.252247 9.94378 0.310635 9.96718C0.369022 9.99057 0.431225 10.0017 0.493692 9.9998C0.556159 9.99794 0.617665 9.98318 0.674701 9.95636C0.731736 9.92954 0.783183 9.89118 0.826104 9.84347L4.86996 5.34611C4.95347 5.25333 5 5.13049 5 5.00281C5 4.87513 4.95347 4.75229 4.86996 4.65951L0.826103 0.161649C0.783465 0.112896 0.73203 0.0735287 0.674785 0.045833C0.617539 0.0181364 0.555626 0.00266495 0.49264 0.000314153C0.429653 -0.00203665 0.36685 0.00878279 0.307878 0.0321411C0.248906 0.0555004 0.19494 0.0909342 0.149116 0.136384C0.103292 0.181836 0.0665217 0.236396 0.0409428 0.296899C0.0153638 0.357402 0.00148499 0.422641 0.000112656 0.488825C-0.00125968 0.55501 0.00990166 0.620821 0.0329486 0.682436C0.0559961 0.744051 0.0904695 0.800243 0.134366 0.847745L3.86994 5.00281L0.134367 9.15687Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
{index === breadcrumbItems.length - 1 ? (
|
||||
<span>{item.name}</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleNavigateToPath(index)}
|
||||
className="opacity-70 hover:text-[#18181B] dark:hover:text-white"
|
||||
>
|
||||
{item.name}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="max-h-60 min-h-[200px] overflow-y-auto border-t border-gray-200 dark:border-[#3A3A3A]">
|
||||
{isLoading ? (
|
||||
<div className="flex h-[200px] items-center justify-center">
|
||||
<span className="text-[14px] text-gray-500">
|
||||
{t('loading')}...
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex w-full flex-col">
|
||||
{/* Option to move to root (no folder) - only show at root level */}
|
||||
{currentFolderId && folderPath.length === 0 && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedFolderId(null);
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 border-b border-gray-200 px-8 py-2 text-left text-[14px] dark:border-[#3A3A3A] ${
|
||||
selectedFolderId === null
|
||||
? 'bg-[#7D54D1] text-white'
|
||||
: 'bg-[#F9F9F9] hover:bg-gray-100 dark:bg-[#2A2A2A] dark:hover:bg-[#383838]'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
selectedFolderId === null
|
||||
? 'text-white'
|
||||
: 'text-gray-600 dark:text-gray-300'
|
||||
}
|
||||
>
|
||||
{t('agents.folders.noFolder')}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{currentLevelFolders.map((folder) => (
|
||||
<button
|
||||
key={folder.id}
|
||||
onClick={() => setSelectedFolderId(folder.id)}
|
||||
className={`flex w-full cursor-pointer items-center justify-between border-b border-gray-200 px-8 py-2 text-left text-[14px] dark:border-[#3A3A3A] ${
|
||||
selectedFolderId === folder.id
|
||||
? 'bg-[#7D54D1] text-white'
|
||||
: 'bg-[#F9F9F9] hover:bg-gray-100 dark:bg-[#2A2A2A] dark:hover:bg-[#383838]'
|
||||
}`}
|
||||
>
|
||||
<span className="flex flex-1 items-center gap-2">
|
||||
<img
|
||||
src={FolderIcon}
|
||||
alt="folder"
|
||||
className={`h-4 w-4 ${selectedFolderId === folder.id ? 'brightness-0 invert' : ''}`}
|
||||
/>
|
||||
<span
|
||||
className={`truncate ${selectedFolderId === folder.id ? 'text-white' : 'text-[#18181B] dark:text-[#E0E0E0]'}`}
|
||||
>
|
||||
{folder.name}
|
||||
</span>
|
||||
</span>
|
||||
{/* Check if folder has subfolders */}
|
||||
{folders.some((f) => f.parent_id === folder.id) && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleNavigateIntoFolder(folder.id);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.stopPropagation();
|
||||
handleNavigateIntoFolder(folder.id);
|
||||
}
|
||||
}}
|
||||
className="ml-2 flex h-6 w-6 items-center justify-center rounded-full hover:bg-[#FFFFFF2B]"
|
||||
>
|
||||
<img
|
||||
src={ChevronRight}
|
||||
alt="expand"
|
||||
className={`h-3 w-3 ${selectedFolderId === folder.id ? 'brightness-0 invert' : ''}`}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{currentLevelFolders.length === 0 && folderPath.length > 0 && (
|
||||
<div className="flex h-[200px] items-center justify-center text-sm text-[#71717A]">
|
||||
{t('agents.folders.noSubfolders')}
|
||||
</div>
|
||||
)}
|
||||
{currentLevelFolders.length === 0 &&
|
||||
folderPath.length === 0 &&
|
||||
!currentFolderId && (
|
||||
<div className="flex h-[200px] items-center justify-center text-sm text-[#71717A]">
|
||||
{t('agents.folders.noFolders')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-gray-200 px-8 py-4 dark:border-[#3A3A3A]">
|
||||
{isCreatingFolder ? (
|
||||
<input
|
||||
ref={newFolderInputRef}
|
||||
type="text"
|
||||
value={newFolderName}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsCreatingFolder(true);
|
||||
setTimeout(() => newFolderInputRef.current?.focus(), 0);
|
||||
}}
|
||||
className="rounded-full border border-[#7D54D1] bg-transparent px-6 py-2 text-sm font-medium text-[#7D54D1] hover:bg-[#E5DDF6]"
|
||||
>
|
||||
{t('agents.folders.newFolder')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (isCreatingFolder) {
|
||||
setNewFolderName('');
|
||||
setIsCreatingFolder(false);
|
||||
} else {
|
||||
setModalState('INACTIVE');
|
||||
}
|
||||
}}
|
||||
className="dark:text-light-gray cursor-pointer rounded-3xl px-5 py-2 text-sm font-medium hover:bg-gray-100 dark:bg-transparent dark:hover:bg-[#767183]/50"
|
||||
>
|
||||
{t('cancel')}
|
||||
</button>
|
||||
{isCreatingFolder ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (newFolderName.trim()) {
|
||||
handleCreateFolder(newFolderName.trim());
|
||||
setNewFolderName('');
|
||||
setIsCreatingFolder(false);
|
||||
}
|
||||
}}
|
||||
disabled={!newFolderName.trim()}
|
||||
className="bg-purple-30 hover:bg-violets-are-blue rounded-3xl px-5 py-2 text-sm text-white disabled:opacity-50"
|
||||
>
|
||||
{t('agents.folders.createFolder')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleMove();
|
||||
}}
|
||||
className="bg-purple-30 hover:bg-violets-are-blue rounded-3xl px-5 py-2 text-sm text-white disabled:opacity-50"
|
||||
>
|
||||
{t('agents.folders.move')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</WrapperModal>
|
||||
);
|
||||
}
|
||||
@@ -42,8 +42,15 @@ export default function WrapperModal({
|
||||
}, [close, isPerformingTask]);
|
||||
|
||||
const modalContent = (
|
||||
<div className="fixed top-0 left-0 z-30 flex h-screen w-screen items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/25 backdrop-blur-xs dark:bg-black/50" />
|
||||
<div
|
||||
className="fixed top-0 left-0 z-30 flex h-screen w-screen items-center justify-center"
|
||||
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
||||
onMouseDown={(e: React.MouseEvent) => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 bg-black/25 backdrop-blur-xs dark:bg-black/50"
|
||||
onClick={isPerformingTask ? undefined : close}
|
||||
/>
|
||||
<div
|
||||
ref={modalRef}
|
||||
className={`relative rounded-2xl bg-white p-8 shadow-[0px_4px_40px_-3px_#0000001A] dark:bg-[#26272E] ${className}`}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
PayloadAction,
|
||||
} from '@reduxjs/toolkit';
|
||||
|
||||
import { Agent } from '../agents/types';
|
||||
import { Agent, AgentFolder } from '../agents/types';
|
||||
import { ActiveState, Doc, Prompt } from '../models/misc';
|
||||
import { RootState } from '../store';
|
||||
import {
|
||||
@@ -37,6 +37,7 @@ export interface Preference {
|
||||
selectedModel: Model | null;
|
||||
availableModels: Model[];
|
||||
modelsLoading: boolean;
|
||||
agentFolders: AgentFolder[] | null;
|
||||
}
|
||||
|
||||
const initialState: Preference = {
|
||||
@@ -73,6 +74,7 @@ const initialState: Preference = {
|
||||
selectedModel: null,
|
||||
availableModels: [],
|
||||
modelsLoading: false,
|
||||
agentFolders: null,
|
||||
};
|
||||
|
||||
export const prefSlice = createSlice({
|
||||
@@ -130,6 +132,9 @@ export const prefSlice = createSlice({
|
||||
setModelsLoading: (state, action: PayloadAction<boolean>) => {
|
||||
state.modelsLoading = action.payload;
|
||||
},
|
||||
setAgentFolders: (state, action: PayloadAction<AgentFolder[] | null>) => {
|
||||
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;
|
||||
|
||||
@@ -54,6 +54,7 @@ const preloadedState: { preference: Preference } = {
|
||||
selectedModel: selectedModel ? JSON.parse(selectedModel) : null,
|
||||
availableModels: [],
|
||||
modelsLoading: false,
|
||||
agentFolders: null,
|
||||
},
|
||||
};
|
||||
const store = configureStore({
|
||||
|
||||
Reference in New Issue
Block a user