Merge branch 'main' into main

This commit is contained in:
Manish Madan
2025-04-20 16:01:13 +05:30
committed by GitHub
48 changed files with 3156 additions and 930 deletions

View File

@@ -10,6 +10,7 @@ from application.core.mongo_db import MongoDB
from application.llm.llm_creator import LLMCreator
from application.logging import build_stack_data, log_activity, LogContext
from application.retriever.base import BaseRetriever
from bson.objectid import ObjectId
class BaseAgent(ABC):
@@ -23,7 +24,7 @@ class BaseAgent(ABC):
prompt: str = "",
chat_history: Optional[List[Dict]] = None,
decoded_token: Optional[Dict] = None,
attachments: Optional[List[Dict]]=None,
attachments: Optional[List[Dict]] = None,
):
self.endpoint = endpoint
self.llm_name = llm_name
@@ -58,6 +59,27 @@ class BaseAgent(ABC):
) -> Generator[Dict, None, None]:
pass
def _get_tools(self, api_key: str = None) -> Dict[str, Dict]:
mongo = MongoDB.get_client()
db = mongo["docsgpt"]
agents_collection = db["agents"]
tools_collection = db["user_tools"]
agent_data = agents_collection.find_one({"key": api_key or self.user_api_key})
tool_ids = agent_data.get("tools", []) if agent_data else []
tools = (
tools_collection.find(
{"_id": {"$in": [ObjectId(tool_id) for tool_id in tool_ids]}}
)
if tool_ids
else []
)
tools = list(tools)
tools_by_id = {str(tool["_id"]): tool for tool in tools} if tools else {}
return tools_by_id
def _get_user_tools(self, user="local"):
mongo = MongoDB.get_client()
db = mongo["docsgpt"]
@@ -243,9 +265,11 @@ class BaseAgent(ABC):
tools_dict: Dict,
messages: List[Dict],
log_context: Optional[LogContext] = None,
attachments: Optional[List[Dict]] = None
attachments: Optional[List[Dict]] = None,
):
resp = self.llm_handler.handle_response(self, resp, tools_dict, messages, attachments)
resp = self.llm_handler.handle_response(
self, resp, tools_dict, messages, attachments
)
if log_context:
data = build_stack_data(self.llm_handler)
log_context.stacks.append({"component": "llm_handler", "data": data})

View File

@@ -5,21 +5,25 @@ from application.logging import LogContext
from application.retriever.base import BaseRetriever
import logging
logger = logging.getLogger(__name__)
class ClassicAgent(BaseAgent):
def _gen_inner(
self, query: str, retriever: BaseRetriever, log_context: LogContext
) -> Generator[Dict, None, None]:
retrieved_data = self._retriever_search(retriever, query, log_context)
tools_dict = self._get_user_tools(self.user)
if self.user_api_key:
tools_dict = self._get_tools(self.user_api_key)
else:
tools_dict = self._get_user_tools(self.user)
self._prepare_tools(tools_dict)
messages = self._build_messages(self.prompt, query, retrieved_data)
resp = self._llm_gen(messages, log_context)
attachments = self.attachments
if isinstance(resp, str):
@@ -33,7 +37,7 @@ class ClassicAgent(BaseAgent):
yield {"answer": resp.message.content}
return
resp = self._llm_handler(resp, tools_dict, messages, log_context,attachments)
resp = self._llm_handler(resp, tools_dict, messages, log_context, attachments)
if isinstance(resp, str):
yield {"answer": resp}

View File

@@ -30,7 +30,10 @@ class ReActAgent(BaseAgent):
) -> Generator[Dict, None, None]:
retrieved_data = self._retriever_search(retriever, query, log_context)
tools_dict = self._get_user_tools(self.user)
if self.user_api_key:
tools_dict = self._get_tools(self.user_api_key)
else:
tools_dict = self._get_user_tools(self.user)
self._prepare_tools(tools_dict)
docs_together = "\n".join([doc["text"] for doc in retrieved_data])

View File

@@ -27,7 +27,7 @@ db = mongo["docsgpt"]
conversations_collection = db["conversations"]
sources_collection = db["sources"]
prompts_collection = db["prompts"]
api_key_collection = db["api_keys"]
agents_collection = db["agents"]
user_logs_collection = db["user_logs"]
attachments_collection = db["attachments"]
@@ -86,19 +86,42 @@ def run_async_chain(chain, question, chat_history):
return result
def get_data_from_api_key(api_key):
data = api_key_collection.find_one({"key": api_key})
# # Raise custom exception if the API key is not found
if data is None:
raise Exception("Invalid API Key, please generate new key", 401)
def get_agent_key(agent_id, user_id):
if not agent_id:
return None
if "source" in data and isinstance(data["source"], DBRef):
source_doc = db.dereference(data["source"])
try:
agent = agents_collection.find_one({"_id": ObjectId(agent_id)})
if agent is None:
raise Exception("Agent not found", 404)
if agent.get("user") == user_id:
agents_collection.update_one(
{"_id": ObjectId(agent_id)},
{"$set": {"lastUsedAt": datetime.datetime.now(datetime.timezone.utc)}},
)
return str(agent["key"])
raise Exception("Unauthorized access to the agent", 403)
except Exception as e:
logger.error(f"Error in get_agent_key: {str(e)}")
raise
def get_data_from_api_key(api_key):
data = agents_collection.find_one({"key": api_key})
if not data:
raise Exception("Invalid API Key, please generate a new key", 401)
source = data.get("source")
if isinstance(source, DBRef):
source_doc = db.dereference(source)
data["source"] = str(source_doc["_id"])
if "retriever" in source_doc:
data["retriever"] = source_doc["retriever"]
data["retriever"] = source_doc.get("retriever", data.get("retriever"))
else:
data["source"] = {}
return data
@@ -128,7 +151,8 @@ def save_conversation(
llm,
decoded_token,
index=None,
api_key=None
api_key=None,
agent_id=None,
):
current_time = datetime.datetime.now(datetime.timezone.utc)
if conversation_id is not None and index is not None:
@@ -202,7 +226,9 @@ def save_conversation(
],
}
if api_key:
api_key_doc = api_key_collection.find_one({"key": api_key})
if agent_id:
conversation_data["agent_id"] = agent_id
api_key_doc = agents_collection.find_one({"key": api_key})
if api_key_doc:
conversation_data["api_key"] = api_key_doc["key"]
conversation_id = conversations_collection.insert_one(
@@ -234,6 +260,7 @@ def complete_stream(
index=None,
should_save_conversation=True,
attachments=None,
agent_id=None,
):
try:
response_full, thought, source_log_docs, tool_calls = "", "", [], []
@@ -241,7 +268,9 @@ def complete_stream(
if attachments:
attachment_ids = [attachment["id"] for attachment in attachments]
logger.info(f"Processing request with {len(attachments)} attachments: {attachment_ids}")
logger.info(
f"Processing request with {len(attachments)} attachments: {attachment_ids}"
)
answer = agent.gen(query=question, retriever=retriever)
@@ -294,7 +323,8 @@ def complete_stream(
llm,
decoded_token,
index,
api_key=user_api_key
api_key=user_api_key,
agent_id=agent_id,
)
else:
conversation_id = None
@@ -366,7 +396,9 @@ class Stream(Resource):
required=False, description="Index of the query to update"
),
"save_conversation": fields.Boolean(
required=False, default=True, description="Whether to save the conversation"
required=False,
default=True,
description="Whether to save the conversation",
),
"attachments": fields.List(
fields.String, required=False, description="List of attachment IDs"
@@ -400,6 +432,14 @@ class Stream(Resource):
chunks = int(data.get("chunks", 2))
token_limit = data.get("token_limit", settings.DEFAULT_MAX_HISTORY)
retriever_name = data.get("retriever", "classic")
agent_id = data.get("agent_id", None)
agent_type = settings.AGENT_NAME
agent_key = get_agent_key(agent_id, request.decoded_token.get("sub"))
if agent_key:
data.update({"api_key": agent_key})
else:
agent_id = None
if "api_key" in data:
data_key = get_data_from_api_key(data["api_key"])
@@ -408,6 +448,7 @@ class Stream(Resource):
source = {"active_docs": data_key.get("source")}
retriever_name = data_key.get("retriever", retriever_name)
user_api_key = data["api_key"]
agent_type = data_key.get("agent_type", agent_type)
decoded_token = {"sub": data_key.get("user")}
elif "active_docs" in data:
@@ -423,8 +464,10 @@ class Stream(Resource):
if not decoded_token:
return make_response({"error": "Unauthorized"}, 401)
attachments = get_attachments_content(attachment_ids, decoded_token.get("sub"))
attachments = get_attachments_content(
attachment_ids, decoded_token.get("sub")
)
logger.info(
f"/stream - request_data: {data}, source: {source}, attachments: {len(attachments)}",
@@ -436,7 +479,7 @@ class Stream(Resource):
chunks = 0
agent = AgentCreator.create_agent(
settings.AGENT_NAME,
agent_type,
endpoint="stream",
llm_name=settings.LLM_NAME,
gpt_model=gpt_model,
@@ -471,6 +514,7 @@ class Stream(Resource):
isNoneDoc=data.get("isNoneDoc"),
index=index,
should_save_conversation=save_conv,
agent_id=agent_id,
),
mimetype="text/event-stream",
)
@@ -552,6 +596,7 @@ class Answer(Resource):
chunks = int(data.get("chunks", 2))
token_limit = data.get("token_limit", settings.DEFAULT_MAX_HISTORY)
retriever_name = data.get("retriever", "classic")
agent_type = settings.AGENT_NAME
if "api_key" in data:
data_key = get_data_from_api_key(data["api_key"])
@@ -560,6 +605,7 @@ class Answer(Resource):
source = {"active_docs": data_key.get("source")}
retriever_name = data_key.get("retriever", retriever_name)
user_api_key = data["api_key"]
agent_type = data_key.get("agent_type", agent_type)
decoded_token = {"sub": data_key.get("user")}
elif "active_docs" in data:
@@ -584,7 +630,7 @@ class Answer(Resource):
)
agent = AgentCreator.create_agent(
settings.AGENT_NAME,
agent_type,
endpoint="api/answer",
llm_name=settings.LLM_NAME,
gpt_model=gpt_model,
@@ -815,28 +861,27 @@ class Search(Resource):
def get_attachments_content(attachment_ids, user):
"""
Retrieve content from attachment documents based on their IDs.
Args:
attachment_ids (list): List of attachment document IDs
user (str): User identifier to verify ownership
Returns:
list: List of dictionaries containing attachment content and metadata
"""
if not attachment_ids:
return []
attachments = []
for attachment_id in attachment_ids:
try:
attachment_doc = attachments_collection.find_one({
"_id": ObjectId(attachment_id),
"user": user
})
attachment_doc = attachments_collection.find_one(
{"_id": ObjectId(attachment_id), "user": user}
)
if attachment_doc:
attachments.append(attachment_doc)
except Exception as e:
logger.error(f"Error retrieving attachment {attachment_id}: {e}")
return attachments

View File

@@ -28,7 +28,7 @@ conversations_collection = db["conversations"]
sources_collection = db["sources"]
prompts_collection = db["prompts"]
feedback_collection = db["feedback"]
api_key_collection = db["api_keys"]
agents_collection = db["agents"]
token_usage_collection = db["token_usage"]
shared_conversations_collections = db["shared_conversations"]
user_logs_collection = db["user_logs"]
@@ -138,14 +138,24 @@ class GetConversations(Resource):
try:
conversations = (
conversations_collection.find(
{"api_key": {"$exists": False}, "user": decoded_token.get("sub")}
{
"$or": [
{"api_key": {"$exists": False}},
{"agent_id": {"$exists": True}},
],
"user": decoded_token.get("sub"),
}
)
.sort("date", -1)
.limit(30)
)
list_conversations = [
{"id": str(conversation["_id"]), "name": conversation["name"]}
{
"id": str(conversation["_id"]),
"name": conversation["name"],
"agent_id": conversation.get("agent_id", None),
}
for conversation in conversations
]
except Exception as err:
@@ -179,7 +189,12 @@ class GetSingleConversation(Resource):
except Exception as err:
current_app.logger.error(f"Error retrieving conversation: {err}")
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify(conversation["queries"]), 200)
data = {
"queries": conversation["queries"],
"agent_id": conversation.get("agent_id"),
}
return make_response(jsonify(data), 200)
@user_ns.route("/api/update_conversation_name")
@@ -920,124 +935,398 @@ class UpdatePrompt(Resource):
return make_response(jsonify({"success": True}), 200)
@user_ns.route("/api/get_api_keys")
class GetApiKeys(Resource):
@api.doc(description="Retrieve API keys for the user")
@user_ns.route("/api/get_agent")
class GetAgent(Resource):
@api.doc(params={"id": "ID of the agent"}, description="Get a single agent by ID")
def get(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
agent_id = request.args.get("id")
if not agent_id:
return make_response(
jsonify({"success": False, "message": "ID is required"}), 400
)
try:
agent = agents_collection.find_one(
{"_id": ObjectId(agent_id), "user": user}
)
if not agent:
return make_response(jsonify({"status": "Not found"}), 404)
data = {
"id": str(agent["_id"]),
"name": agent["name"],
"description": agent["description"],
"source": (
str(db.dereference(agent["source"])["_id"])
if "source" in agent and isinstance(agent["source"], DBRef)
else ""
),
"chunks": agent["chunks"],
"retriever": agent.get("retriever", ""),
"prompt_id": agent["prompt_id"],
"tools": agent.get("tools", []),
"agent_type": agent["agent_type"],
"status": agent["status"],
"createdAt": agent["createdAt"],
"updatedAt": agent["updatedAt"],
"lastUsedAt": agent["lastUsedAt"],
"key": f"{agent['key'][:4]}...{agent['key'][-4:]}",
}
except Exception as err:
current_app.logger.error(f"Error retrieving agent: {err}")
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify(data), 200)
@user_ns.route("/api/get_agents")
class GetAgents(Resource):
@api.doc(description="Retrieve agents for the user")
def get(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
try:
keys = api_key_collection.find({"user": user})
list_keys = []
for key in keys:
if "source" in key and isinstance(key["source"], DBRef):
source = db.dereference(key["source"])
if source is None:
continue
source_name = source["name"]
elif "retriever" in key:
source_name = key["retriever"]
else:
continue
list_keys.append(
{
"id": str(key["_id"]),
"name": key["name"],
"key": key["key"][:4] + "..." + key["key"][-4:],
"source": source_name,
"prompt_id": key["prompt_id"],
"chunks": key["chunks"],
}
)
agents = agents_collection.find({"user": user})
list_agents = [
{
"id": str(agent["_id"]),
"name": agent["name"],
"description": agent["description"],
"source": (
str(db.dereference(agent["source"])["_id"])
if "source" in agent and isinstance(agent["source"], DBRef)
else ""
),
"chunks": agent["chunks"],
"retriever": agent.get("retriever", ""),
"prompt_id": agent["prompt_id"],
"tools": agent.get("tools", []),
"agent_type": agent["agent_type"],
"status": agent["status"],
"created_at": agent["createdAt"],
"updated_at": agent["updatedAt"],
"last_used_at": agent["lastUsedAt"],
"key": f"{agent['key'][:4]}...{agent['key'][-4:]}",
}
for agent in agents
if "source" in agent or "retriever" in agent
]
except Exception as err:
current_app.logger.error(f"Error retrieving API keys: {err}")
current_app.logger.error(f"Error retrieving agents: {err}")
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify(list_keys), 200)
return make_response(jsonify(list_agents), 200)
@user_ns.route("/api/create_api_key")
class CreateApiKey(Resource):
create_api_key_model = api.model(
"CreateApiKeyModel",
@user_ns.route("/api/create_agent")
class CreateAgent(Resource):
create_agent_model = api.model(
"CreateAgentModel",
{
"name": fields.String(required=True, description="Name of the API key"),
"prompt_id": fields.String(required=True, description="Prompt ID"),
"name": fields.String(required=True, description="Name of the agent"),
"description": fields.String(
required=True, description="Description of the agent"
),
"image": fields.String(
required=False, description="Image URL or identifier"
),
"source": fields.String(required=True, description="Source ID"),
"chunks": fields.Integer(required=True, description="Chunks count"),
"source": fields.String(description="Source ID (optional)"),
"retriever": fields.String(description="Retriever (optional)"),
"retriever": fields.String(required=True, description="Retriever ID"),
"prompt_id": fields.String(required=True, description="Prompt ID"),
"tools": fields.List(
fields.String, required=False, description="List of tool identifiers"
),
"agent_type": fields.String(required=True, description="Type of the agent"),
"status": fields.String(
required=True, description="Status of the agent (draft or published)"
),
},
)
@api.expect(create_api_key_model)
@api.doc(description="Create a new API key")
@api.expect(create_agent_model)
@api.doc(description="Create a new agent")
def post(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
data = request.get_json()
required_fields = ["name", "prompt_id", "chunks"]
if data.get("status") not in ["draft", "published"]:
return make_response(
jsonify({"success": False, "message": "Invalid status"}), 400
)
required_fields = []
if data.get("status") == "published":
required_fields = [
"name",
"description",
"source",
"chunks",
"retriever",
"prompt_id",
"agent_type",
]
else:
required_fields = ["name"]
missing_fields = check_required_fields(data, required_fields)
if missing_fields:
return missing_fields
try:
key = str(uuid.uuid4())
new_api_key = {
"name": data["name"],
"key": key,
new_agent = {
"user": user,
"prompt_id": data["prompt_id"],
"chunks": data["chunks"],
"name": data.get("name"),
"description": data.get("description", ""),
"image": data.get("image", ""),
"source": (
DBRef("sources", ObjectId(data.get("source")))
if ObjectId.is_valid(data.get("source"))
else ""
),
"chunks": data.get("chunks", ""),
"retriever": data.get("retriever", ""),
"prompt_id": data.get("prompt_id", ""),
"tools": data.get("tools", []),
"agent_type": data.get("agent_type", ""),
"status": data.get("status"),
"createdAt": datetime.datetime.now(datetime.timezone.utc),
"updatedAt": datetime.datetime.now(datetime.timezone.utc),
"lastUsedAt": None,
"key": key,
}
if "source" in data and ObjectId.is_valid(data["source"]):
new_api_key["source"] = DBRef("sources", ObjectId(data["source"]))
if "retriever" in data:
new_api_key["retriever"] = data["retriever"]
resp = api_key_collection.insert_one(new_api_key)
resp = agents_collection.insert_one(new_agent)
new_id = str(resp.inserted_id)
except Exception as err:
current_app.logger.error(f"Error creating API key: {err}")
current_app.logger.error(f"Error creating agent: {err}")
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify({"id": new_id, "key": key}), 201)
@user_ns.route("/api/delete_api_key")
class DeleteApiKey(Resource):
delete_api_key_model = api.model(
"DeleteApiKeyModel",
{"id": fields.String(required=True, description="API Key ID to delete")},
@user_ns.route("/api/update_agent/<string:agent_id>")
class UpdateAgent(Resource):
update_agent_model = api.model(
"UpdateAgentModel",
{
"name": fields.String(required=True, description="New name of the agent"),
"description": fields.String(
required=True, description="New description of the agent"
),
"image": fields.String(
required=False, description="New image URL or identifier"
),
"source": fields.String(required=True, description="Source ID"),
"chunks": fields.Integer(required=True, description="Chunks count"),
"retriever": fields.String(required=True, description="Retriever ID"),
"prompt_id": fields.String(required=True, description="Prompt ID"),
"tools": fields.List(
fields.String, required=False, description="List of tool identifiers"
),
"agent_type": fields.String(required=True, description="Type of the agent"),
"status": fields.String(
required=True, description="Status of the agent (draft or published)"
),
},
)
@api.expect(delete_api_key_model)
@api.doc(description="Delete an API key by ID")
def post(self):
@api.expect(update_agent_model)
@api.doc(description="Update an existing agent")
def put(self, agent_id):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
data = request.get_json()
required_fields = ["id"]
missing_fields = check_required_fields(data, required_fields)
if missing_fields:
return missing_fields
if not ObjectId.is_valid(agent_id):
return make_response(
jsonify({"success": False, "message": "Invalid agent ID format"}), 400
)
oid = ObjectId(agent_id)
try:
result = api_key_collection.delete_one(
{"_id": ObjectId(data["id"]), "user": user}
)
if result.deleted_count == 0:
return {"success": False, "message": "API Key not found"}, 404
existing_agent = agents_collection.find_one({"_id": oid, "user": user})
except Exception as err:
current_app.logger.error(f"Error deleting API key: {err}")
return {"success": False}, 400
return make_response(
current_app.logger.error(f"Error finding agent {agent_id}: {err}"),
jsonify({"success": False, "message": "Database error finding agent"}),
500,
)
return {"success": True}, 200
if not existing_agent:
return make_response(
jsonify(
{"success": False, "message": "Agent not found or not authorized"}
),
404,
)
update_fields = {}
allowed_fields = [
"name",
"description",
"image",
"source",
"chunks",
"retriever",
"prompt_id",
"tools",
"agent_type",
"status",
]
for field in allowed_fields:
if field in data:
if field == "status":
new_status = data.get("status")
if new_status not in ["draft", "published"]:
return make_response(
jsonify(
{"success": False, "message": "Invalid status value"}
),
400,
)
update_fields[field] = new_status
elif field == "source":
source_id = data.get("source")
if source_id and ObjectId.is_valid(source_id):
update_fields[field] = DBRef("sources", ObjectId(source_id))
elif source_id:
return make_response(
jsonify(
{
"success": False,
"message": "Invalid source ID format provided",
}
),
400,
)
else:
update_fields[field] = ""
else:
update_fields[field] = data[field]
if not update_fields:
return make_response(
jsonify({"success": False, "message": "No update data provided"}), 400
)
final_status = update_fields.get("status", existing_agent.get("status"))
if final_status == "published":
required_published_fields = [
"name",
"description",
"source",
"chunks",
"retriever",
"prompt_id",
"agent_type",
]
missing_published_fields = []
for req_field in required_published_fields:
final_value = update_fields.get(
req_field, existing_agent.get(req_field)
)
if req_field == "source" and final_value:
if not isinstance(final_value, DBRef):
missing_published_fields.append(req_field)
if missing_published_fields:
return make_response(
jsonify(
{
"success": False,
"message": f"Cannot publish agent. Missing or invalid required fields: {', '.join(missing_published_fields)}",
}
),
400,
)
update_fields["updatedAt"] = datetime.datetime.now(datetime.timezone.utc)
try:
result = agents_collection.update_one(
{"_id": oid, "user": user}, {"$set": update_fields}
)
if result.matched_count == 0:
return make_response(
jsonify(
{
"success": False,
"message": "Agent not found or update failed unexpectedly",
}
),
404,
)
if result.modified_count == 0 and result.matched_count == 1:
return make_response(
jsonify(
{
"success": True,
"message": "Agent found, but no changes were applied.",
}
),
304,
)
except Exception as err:
current_app.logger.error(f"Error updating agent {agent_id}: {err}")
return make_response(
jsonify({"success": False, "message": "Database error during update"}),
500,
)
return make_response(
jsonify(
{
"success": True,
"id": agent_id,
"message": "Agent updated successfully",
}
),
200,
)
@user_ns.route("/api/delete_agent")
class DeleteAgent(Resource):
@api.doc(params={"id": "ID of the agent"}, description="Delete an agent by ID")
def delete(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
agent_id = request.args.get("id")
if not agent_id:
return make_response(
jsonify({"success": False, "message": "ID is required"}), 400
)
try:
deleted_agent = agents_collection.find_one_and_delete(
{"_id": ObjectId(agent_id), "user": user}
)
if not deleted_agent:
return make_response(
jsonify({"success": False, "message": "Agent not found"}), 404
)
deleted_id = str(deleted_agent["_id"])
except Exception as err:
current_app.logger.error(f"Error deleting agent: {err}")
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify({"id": deleted_id}), 200)
@user_ns.route("/api/share")
@@ -1112,9 +1401,7 @@ class ShareConversation(Resource):
if "retriever" in data:
new_api_key_data["retriever"] = data["retriever"]
pre_existing_api_document = api_key_collection.find_one(
new_api_key_data
)
pre_existing_api_document = agents_collection.find_one(new_api_key_data)
if pre_existing_api_document:
api_uuid = pre_existing_api_document["key"]
pre_existing = shared_conversations_collections.find_one(
@@ -1173,7 +1460,7 @@ class ShareConversation(Resource):
if "retriever" in data:
new_api_key_data["retriever"] = data["retriever"]
api_key_collection.insert_one(new_api_key_data)
agents_collection.insert_one(new_api_key_data)
shared_conversations_collections.insert_one(
{
"uuid": explicit_binary,
@@ -1331,9 +1618,9 @@ class GetMessageAnalytics(Resource):
try:
api_key = (
api_key_collection.find_one(
{"_id": ObjectId(api_key_id), "user": user}
)["key"]
agents_collection.find_one({"_id": ObjectId(api_key_id), "user": user})[
"key"
]
if api_key_id
else None
)
@@ -1375,7 +1662,7 @@ class GetMessageAnalytics(Resource):
}
if api_key:
match_stage["$match"]["api_key"] = api_key
pipeline = [
match_stage,
{"$unwind": "$queries"},
@@ -1455,9 +1742,9 @@ class GetTokenAnalytics(Resource):
try:
api_key = (
api_key_collection.find_one(
{"_id": ObjectId(api_key_id), "user": user}
)["key"]
agents_collection.find_one({"_id": ObjectId(api_key_id), "user": user})[
"key"
]
if api_key_id
else None
)
@@ -1614,9 +1901,9 @@ class GetFeedbackAnalytics(Resource):
try:
api_key = (
api_key_collection.find_one(
{"_id": ObjectId(api_key_id), "user": user}
)["key"]
agents_collection.find_one({"_id": ObjectId(api_key_id), "user": user})[
"key"
]
if api_key_id
else None
)
@@ -1779,7 +2066,7 @@ class GetUserLogs(Resource):
try:
api_key = (
api_key_collection.find_one({"_id": ObjectId(api_key_id)})["key"]
agents_collection.find_one({"_id": ObjectId(api_key_id)})["key"]
if api_key_id
else None
)
@@ -2534,3 +2821,4 @@ class StoreAttachment(Resource):
except Exception as err:
current_app.logger.error(f"Error storing attachment: {err}")
return make_response(jsonify({"success": False, "error": str(err)}), 400)

View File

@@ -15,7 +15,7 @@ Flask==3.1.0
faiss-cpu==1.9.0.post1
flask-restx==1.3.0
google-genai==1.3.0
google-generativeai==0.8.3
google-generativeai==0.8.5
gTTS==2.5.4
gunicorn==23.0.0
html2text==2024.2.26

View File

@@ -49,8 +49,8 @@
"husky": "^8.0.0",
"lint-staged": "^15.3.0",
"postcss": "^8.4.49",
"prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.9",
"prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.6.11",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2",
"vite": "^5.4.14",
@@ -1635,7 +1635,7 @@
"version": "18.3.0",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.0.tgz",
"integrity": "sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==",
"dev": true,
"devOptional": true,
"dependencies": {
"@types/react": "*"
}
@@ -7648,10 +7648,11 @@
}
},
"node_modules/prettier": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.2.tgz",
"integrity": "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==",
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz",
"integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -7675,10 +7676,11 @@
}
},
"node_modules/prettier-plugin-tailwindcss": {
"version": "0.6.9",
"resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.9.tgz",
"integrity": "sha512-r0i3uhaZAXYP0At5xGfJH876W3HHGHDp+LCRUJrs57PBeQ6mYHMwr25KH8NPX44F2yGTvdnH7OqCshlQx183Eg==",
"version": "0.6.11",
"resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.11.tgz",
"integrity": "sha512-YxaYSIvZPAqhrrEpRtonnrXdghZg1irNg4qrjboCXrpybLWVs55cW2N3juhspVJiO0JBvYJT8SYsJpc8OQSnsA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.21.3"
},
@@ -7687,7 +7689,7 @@
"@prettier/plugin-pug": "*",
"@shopify/prettier-plugin-liquid": "*",
"@trivago/prettier-plugin-sort-imports": "*",
"@zackad/prettier-plugin-twig-melody": "*",
"@zackad/prettier-plugin-twig": "*",
"prettier": "^3.0",
"prettier-plugin-astro": "*",
"prettier-plugin-css-order": "*",
@@ -7714,7 +7716,7 @@
"@trivago/prettier-plugin-sort-imports": {
"optional": true
},
"@zackad/prettier-plugin-twig-melody": {
"@zackad/prettier-plugin-twig": {
"optional": true
},
"prettier-plugin-astro": {
@@ -9376,7 +9378,7 @@
"version": "5.7.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz",
"integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==",
"dev": true,
"devOptional": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"

View File

@@ -28,8 +28,8 @@
"react-chartjs-2": "^5.3.0",
"react-copy-to-clipboard": "^5.1.0",
"react-dom": "^18.3.1",
"react-helmet": "^6.1.0",
"react-dropzone": "^14.3.5",
"react-helmet": "^6.1.0",
"react-i18next": "^15.4.0",
"react-markdown": "^9.0.1",
"react-redux": "^8.0.5",
@@ -60,8 +60,8 @@
"husky": "^8.0.0",
"lint-staged": "^15.3.0",
"postcss": "^8.4.49",
"prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.9",
"prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.6.11",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2",
"vite": "^5.4.14",

View File

@@ -4,4 +4,5 @@ module.exports = {
semi: true,
singleQuote: true,
printWidth: 80,
}
plugins: ['prettier-plugin-tailwindcss'],
};

View File

@@ -12,13 +12,14 @@ import useTokenAuth from './hooks/useTokenAuth';
import Navigation from './Navigation';
import PageNotFound from './PageNotFound';
import Setting from './settings';
import Agents from './agents';
function AuthWrapper({ children }: { children: React.ReactNode }) {
const { isAuthLoading } = useTokenAuth();
if (isAuthLoading) {
return (
<div className="h-screen flex items-center justify-center">
<div className="flex h-screen items-center justify-center">
<Spinner />
</div>
);
@@ -31,7 +32,7 @@ function MainLayout() {
const [navOpen, setNavOpen] = useState(!isMobile);
return (
<div className="dark:bg-raisin-black relative h-screen overflow-auto">
<div className="relative h-screen overflow-auto dark:bg-raisin-black">
<Navigation navOpen={navOpen} setNavOpen={setNavOpen} />
<div
className={`h-[calc(100dvh-64px)] md:h-screen ${
@@ -52,7 +53,7 @@ export default function App() {
return <div />;
}
return (
<div className="h-full relative overflow-auto">
<div className="relative h-full overflow-auto">
<Routes>
<Route
element={
@@ -64,6 +65,7 @@ export default function App() {
<Route index element={<Conversation />} />
<Route path="/about" element={<About />} />
<Route path="/settings/*" element={<Setting />} />
<Route path="/agents/*" element={<Agents />} />
</Route>
<Route path="/share/:identifier" element={<SharedConversation />} />
<Route path="/*" element={<PageNotFound />} />

View File

@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { NavLink, useNavigate } from 'react-router-dom';
import { Agent } from './agents/types';
import conversationService from './api/services/conversationService';
import userService from './api/services/userService';
import Add from './assets/add.svg';
@@ -12,11 +13,12 @@ import Expand from './assets/expand.svg';
import Github from './assets/github.svg';
import Hamburger from './assets/hamburger.svg';
import openNewChat from './assets/openNewChat.svg';
import Robot from './assets/robot.svg';
import SettingGear from './assets/settingGear.svg';
import Spark from './assets/spark.svg';
import SpinnerDark from './assets/spinner-dark.svg';
import Spinner from './assets/spinner.svg';
import Twitter from './assets/TwitterX.svg';
import UploadIcon from './assets/upload.svg';
import Help from './components/Help';
import {
handleAbort,
@@ -33,13 +35,15 @@ import JWTModal from './modals/JWTModal';
import { ActiveState } from './models/misc';
import { getConversations } from './preferences/preferenceApi';
import {
selectApiKeyStatus,
selectConversationId,
selectConversations,
selectModalStateDeleteConv,
selectSelectedAgent,
selectToken,
setConversations,
setModalStateDeleteConv,
setSelectedAgent,
setAgents,
} from './preferences/preferenceSlice';
import Upload from './upload/Upload';
@@ -50,36 +54,28 @@ interface NavigationProps {
export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
const dispatch = useDispatch();
const navigate = useNavigate();
const { t } = useTranslation();
const token = useSelector(selectToken);
const queries = useSelector(selectQueries);
const conversations = useSelector(selectConversations);
const modalStateDeleteConv = useSelector(selectModalStateDeleteConv);
const conversationId = useSelector(selectConversationId);
const [isDeletingConversation, setIsDeletingConversation] = useState(false);
const modalStateDeleteConv = useSelector(selectModalStateDeleteConv);
const selectedAgent = useSelector(selectSelectedAgent);
const { isMobile } = useMediaQuery();
const [isDarkTheme] = useDarkTheme();
const { t } = useTranslation();
const isApiKeySet = useSelector(selectApiKeyStatus);
const { showTokenModal, handleTokenSubmit } = useTokenAuth();
const [isDeletingConversation, setIsDeletingConversation] = useState(false);
const [uploadModalState, setUploadModalState] =
useState<ActiveState>('INACTIVE');
const [recentAgents, setRecentAgents] = useState<Agent[]>([]);
const navRef = useRef(null);
const navigate = useNavigate();
useEffect(() => {
if (!conversations?.data) {
fetchConversations();
}
if (queries.length === 0) {
resetConversation();
}
}, [conversations?.data, dispatch]);
async function fetchConversations() {
dispatch(setConversations({ ...conversations, loading: true }));
return await getConversations(token)
@@ -92,6 +88,29 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
});
}
async function getAgents() {
const response = await userService.getAgents(token);
if (!response.ok) throw new Error('Failed to fetch agents');
const data: Agent[] = await response.json();
dispatch(setAgents(data));
setRecentAgents(
data
.filter((agent: Agent) => agent.status === 'published')
.sort(
(a: Agent, b: Agent) =>
new Date(b.last_used_at ?? 0).getTime() -
new Date(a.last_used_at ?? 0).getTime(),
)
.slice(0, 3),
);
}
useEffect(() => {
if (recentAgents.length === 0) getAgents();
if (!conversations?.data) fetchConversations();
if (queries.length === 0) resetConversation();
}, [conversations?.data, dispatch]);
const handleDeleteAllConversations = () => {
setIsDeletingConversation(true);
conversationService
@@ -113,18 +132,34 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
.catch((error) => console.error(error));
};
const handleAgentClick = (agent: Agent) => {
resetConversation();
dispatch(setSelectedAgent(agent));
if (isMobile) setNavOpen(!navOpen);
navigate('/');
};
const handleConversationClick = (index: string) => {
conversationService
.getConversation(index, token)
.then((response) => response.json())
.then((data) => {
navigate('/');
dispatch(setConversation(data));
dispatch(setConversation(data.queries));
dispatch(
updateConversationId({
query: { conversationId: index },
}),
);
if (data.agent_id) {
userService.getAgent(data.agent_id, token).then((response) => {
if (response.ok) {
response.json().then((agent: Agent) => {
dispatch(setSelectedAgent(agent));
});
}
});
} else dispatch(setSelectedAgent(null));
});
};
@@ -136,6 +171,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
query: { conversationId: null },
}),
);
dispatch(setSelectedAgent(null));
};
const newChat = () => {
@@ -170,8 +206,8 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
return (
<>
{!navOpen && (
<div className="duration-25 absolute top-3 left-3 z-20 hidden transition-all md:block">
<div className="flex gap-3 items-center">
<div className="duration-25 absolute left-3 top-3 z-20 hidden transition-all md:block">
<div className="flex items-center gap-3">
<button
onClick={() => {
setNavOpen(!navOpen);
@@ -198,7 +234,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
/>
</button>
)}
<div className="text-[#949494] font-medium text-[20px]">
<div className="text-[20px] font-medium text-[#949494]">
DocsGPT
</div>
</div>
@@ -208,13 +244,13 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
ref={navRef}
className={`${
!navOpen && '-ml-96 md:-ml-[18rem]'
} duration-20 fixed top-0 z-20 flex h-full w-72 flex-col border-r-[1px] border-b-0 bg-lotion dark:bg-chinese-black transition-all dark:border-r-purple-taupe dark:text-white`}
} duration-20 fixed top-0 z-20 flex h-full w-72 flex-col border-b-0 border-r-[1px] bg-lotion transition-all dark:border-r-purple-taupe dark:bg-chinese-black dark:text-white`}
>
<div
className={'visible mt-2 flex h-[6vh] w-full justify-between md:h-12'}
>
<div
className="my-auto mx-4 flex cursor-pointer gap-1.5"
className="mx-4 my-auto flex cursor-pointer gap-1.5"
onClick={() => {
if (isMobile) {
setNavOpen(!navOpen);
@@ -252,7 +288,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
className={({ isActive }) =>
`${
isActive ? 'bg-transparent' : ''
} group sticky mx-4 mt-4 flex cursor-pointer gap-2.5 rounded-3xl border border-silver p-3 hover:border-rainy-gray dark:border-purple-taupe dark:text-white hover:bg-transparent`
} group sticky mx-4 mt-4 flex cursor-pointer gap-2.5 rounded-3xl border border-silver p-3 hover:border-rainy-gray hover:bg-transparent dark:border-purple-taupe dark:text-white`
}
>
<img
@@ -269,7 +305,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
className="mb-auto h-[78vh] overflow-y-auto overflow-x-hidden dark:text-white"
>
{conversations?.loading && !isDeletingConversation && (
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2">
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 transform">
<img
src={isDarkTheme ? SpinnerDark : Spinner}
className="animate-spin cursor-pointer bg-transparent"
@@ -277,10 +313,77 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
/>
</div>
)}
{conversations?.data && conversations.data.length > 0 ? (
{recentAgents?.length > 0 ? (
<div>
<div className="my-auto mx-4 mt-2 flex h-6 items-center justify-between gap-4 rounded-3xl">
<p className="mt-1 ml-4 text-sm font-semibold">{t('chats')}</p>
<div className="mx-4 my-auto mt-2 flex h-6 items-center">
<p className="ml-4 mt-1 text-sm font-semibold">Agents</p>
</div>
<div className="agents-container">
<div>
{recentAgents.map((agent, idx) => (
<div
key={idx}
className={`mx-4 my-auto mt-4 flex h-9 cursor-pointer items-center gap-2 rounded-3xl pl-4 hover:bg-bright-gray dark:hover:bg-dark-charcoal ${
agent.id === selectedAgent?.id && !conversationId
? 'bg-bright-gray dark:bg-dark-charcoal'
: ''
}`}
onClick={() => handleAgentClick(agent)}
>
<div className="flex w-6 justify-center">
<img
src={agent.image ?? Robot}
alt="agent-logo"
className="h-6 w-6 rounded-full"
/>
</div>
<p className="overflow-hidden overflow-ellipsis whitespace-nowrap text-sm leading-6 text-eerie-black dark:text-bright-gray">
{agent.name}
</p>
</div>
))}
</div>
<div
className="mx-4 my-auto mt-2 flex h-9 cursor-pointer items-center gap-2 rounded-3xl pl-4 hover:bg-bright-gray dark:hover:bg-dark-charcoal"
onClick={() => {
dispatch(setSelectedAgent(null));
navigate('/agents');
}}
>
<div className="flex w-6 justify-center">
<img
src={Spark}
alt="manage-agents"
className="h-[18px] w-[18px]"
/>
</div>
<p className="overflow-hidden overflow-ellipsis whitespace-nowrap text-sm leading-6 text-eerie-black dark:text-bright-gray">
Manage Agents
</p>
</div>
</div>
</div>
) : (
<div
className="mx-4 my-auto mt-2 flex h-9 cursor-pointer items-center gap-2 rounded-3xl pl-4 hover:bg-bright-gray dark:hover:bg-dark-charcoal"
onClick={() => navigate('/agents')}
>
<div className="flex w-6 justify-center">
<img
src={Spark}
alt="manage-agents"
className="h-[18px] w-[18px]"
/>
</div>
<p className="overflow-hidden overflow-ellipsis whitespace-nowrap text-sm leading-6 text-eerie-black dark:text-bright-gray">
Manage Agents
</p>
</div>
)}
{conversations?.data && conversations.data.length > 0 ? (
<div className="mt-7">
<div className="mx-4 my-auto mt-2 flex h-6 items-center justify-between gap-4 rounded-3xl">
<p className="ml-4 mt-1 text-sm font-semibold">{t('chats')}</p>
</div>
<div className="conversations-container">
{conversations.data?.map((conversation) => (
@@ -316,7 +419,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
}}
to="/settings"
className={({ isActive }) =>
`my-auto mx-4 flex h-9 cursor-pointer gap-4 rounded-3xl hover:bg-gray-100 dark:hover:bg-[#28292E] ${
`mx-4 my-auto flex h-9 cursor-pointer gap-4 rounded-3xl hover:bg-gray-100 dark:hover:bg-[#28292E] ${
isActive ? 'bg-gray-3000 dark:bg-transparent' : ''
}`
}
@@ -324,15 +427,15 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
<img
src={SettingGear}
alt="Settings"
className="ml-2 w- filter dark:invert"
className="w- ml-2 filter dark:invert"
/>
<p className="my-auto text-sm text-eerie-black dark:text-white">
<p className="my-auto text-sm text-eerie-black dark:text-white">
{t('settings.label')}
</p>
</NavLink>
</div>
<div className="flex flex-col justify-end text-eerie-black dark:text-white">
<div className="flex justify-between items-center py-1">
<div className="flex items-center justify-between py-1">
<Help />
<div className="flex items-center gap-1 pr-4">
@@ -381,9 +484,9 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
</div>
</div>
<div className="sticky z-10 h-16 w-full border-b-2 bg-gray-50 dark:border-b-purple-taupe dark:bg-chinese-black md:hidden">
<div className="flex gap-6 items-center h-full ml-6 ">
<div className="ml-6 flex h-full items-center gap-6">
<button
className=" h-6 w-6 md:hidden"
className="h-6 w-6 md:hidden"
onClick={() => setNavOpen(true)}
>
<img
@@ -392,7 +495,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
className="w-7 filter dark:invert"
/>
</button>
<div className="text-[#949494] font-medium text-[20px]">DocsGPT</div>
<div className="text-[20px] font-medium text-[#949494]">DocsGPT</div>
</div>
</div>
<DeleteConvModal

View File

@@ -0,0 +1,32 @@
import { useNavigate, useParams } from 'react-router-dom';
import ArrowLeft from '../assets/arrow-left.svg';
import Analytics from '../settings/Analytics';
import Logs from '../settings/Logs';
export default function AgentLogs() {
const navigate = useNavigate();
const { agentId } = useParams();
return (
<div className="p-4 md:p-12">
<div className="flex items-center gap-3 px-4">
<button
className="rounded-full border p-3 text-sm text-gray-400 dark:border-0 dark:bg-[#28292D] dark:text-gray-500 dark:hover:bg-[#2E2F34]"
onClick={() => navigate('/agents')}
>
<img src={ArrowLeft} alt="left-arrow" className="h-3 w-3" />
</button>
<p className="mt-px text-sm font-semibold text-eerie-black dark:text-bright-gray">
Back to all agents
</p>
</div>
<div className="mt-5 flex w-full flex-wrap items-center justify-between gap-2 px-4">
<h1 className="m-0 text-[40px] font-bold text-[#212121] dark:text-white">
Agent Logs
</h1>
</div>
<Analytics agentId={agentId} />
<Logs agentId={agentId} tableHeader="Agent endpoint logs" />
</div>
);
}

View File

@@ -0,0 +1,153 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import MessageInput from '../components/MessageInput';
import ConversationMessages from '../conversation/ConversationMessages';
import { Query } from '../conversation/conversationModels';
import {
addQuery,
fetchAnswer,
handleAbort,
resendQuery,
resetConversation,
selectQueries,
selectStatus,
} from '../conversation/conversationSlice';
import { selectSelectedAgent } from '../preferences/preferenceSlice';
import { AppDispatch } from '../store';
export default function AgentPreview() {
const dispatch = useDispatch<AppDispatch>();
const queries = useSelector(selectQueries);
const status = useSelector(selectStatus);
const selectedAgent = useSelector(selectSelectedAgent);
const [input, setInput] = useState('');
const [lastQueryReturnedErr, setLastQueryReturnedErr] = useState(false);
const fetchStream = useRef<any>(null);
const handleFetchAnswer = useCallback(
({ question, index }: { question: string; index?: number }) => {
fetchStream.current = dispatch(
fetchAnswer({ question, indx: index, isPreview: true }),
);
},
[dispatch],
);
const handleQuestion = useCallback(
({
question,
isRetry = false,
index = undefined,
}: {
question: string;
isRetry?: boolean;
index?: number;
}) => {
const trimmedQuestion = question.trim();
if (trimmedQuestion === '') return;
if (index !== undefined) {
if (!isRetry) dispatch(resendQuery({ index, prompt: trimmedQuestion }));
handleFetchAnswer({ question: trimmedQuestion, index });
} else {
if (!isRetry) {
const newQuery: Query = { prompt: trimmedQuestion };
dispatch(addQuery(newQuery));
}
handleFetchAnswer({ question: trimmedQuestion, index: undefined });
}
},
[dispatch, handleFetchAnswer],
);
const handleQuestionSubmission = (
updatedQuestion?: string,
updated?: boolean,
indx?: number,
) => {
if (
updated === true &&
updatedQuestion !== undefined &&
indx !== undefined
) {
handleQuestion({
question: updatedQuestion,
index: indx,
isRetry: false,
});
} else if (input.trim() && status !== 'loading') {
const currentInput = input.trim();
if (lastQueryReturnedErr && queries.length > 0) {
const lastQueryIndex = queries.length - 1;
handleQuestion({
question: currentInput,
isRetry: true,
index: lastQueryIndex,
});
} else {
handleQuestion({
question: currentInput,
isRetry: false,
index: undefined,
});
}
setInput('');
}
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
handleQuestionSubmission();
}
};
useEffect(() => {
dispatch(resetConversation());
return () => {
if (fetchStream.current) fetchStream.current.abort();
handleAbort();
dispatch(resetConversation());
};
}, [dispatch]);
useEffect(() => {
if (queries.length > 0) {
const lastQuery = queries[queries.length - 1];
setLastQueryReturnedErr(!!lastQuery.error);
} else setLastQueryReturnedErr(false);
}, [queries]);
return (
<div>
<div className="flex h-full flex-col items-center justify-between gap-2 overflow-y-hidden dark:bg-raisin-black">
<div className="h-[512px] w-full overflow-y-auto">
<ConversationMessages
handleQuestion={handleQuestion}
handleQuestionSubmission={handleQuestionSubmission}
queries={queries}
status={status}
showHeroOnEmpty={false}
/>
</div>
<div className="flex w-[95%] max-w-[1500px] flex-col items-center gap-4 pb-2 md:w-9/12 lg:w-8/12 xl:w-8/12 2xl:w-6/12">
<MessageInput
value={input}
onChange={(e) => setInput(e.target.value)}
onSubmit={() => handleQuestionSubmission()}
loading={status === 'loading'}
showSourceButton={selectedAgent ? false : true}
showToolButton={selectedAgent ? false : true}
/>
<p className="w-full self-center bg-transparent pt-2 text-center text-xs text-gray-4000 dark:text-sonic-silver md:inline">
This is a preview of the agent. You can publish it to start using it
in conversations.
</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,614 @@
import React, { useEffect, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useNavigate, useParams } from 'react-router-dom';
import userService from '../api/services/userService';
import ArrowLeft from '../assets/arrow-left.svg';
import SourceIcon from '../assets/source.svg';
import Dropdown from '../components/Dropdown';
import MultiSelectPopup, { OptionType } from '../components/MultiSelectPopup';
import AgentDetailsModal from '../modals/AgentDetailsModal';
import ConfirmationModal from '../modals/ConfirmationModal';
import { ActiveState, Doc, Prompt } from '../models/misc';
import {
selectSelectedAgent,
selectSourceDocs,
selectToken,
setSelectedAgent,
} from '../preferences/preferenceSlice';
import PromptsModal from '../preferences/PromptsModal';
import { UserToolType } from '../settings/types';
import AgentPreview from './AgentPreview';
import { Agent } from './types';
const embeddingsName =
import.meta.env.VITE_EMBEDDINGS_NAME ||
'huggingface_sentence-transformers/all-mpnet-base-v2';
export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
const navigate = useNavigate();
const dispatch = useDispatch();
const { agentId } = useParams();
const token = useSelector(selectToken);
const sourceDocs = useSelector(selectSourceDocs);
const selectedAgent = useSelector(selectSelectedAgent);
const [effectiveMode, setEffectiveMode] = useState(mode);
const [agent, setAgent] = useState<Agent>({
id: agentId || '',
name: '',
description: '',
image: '',
source: '',
chunks: '',
retriever: '',
prompt_id: '',
tools: [],
agent_type: '',
status: '',
});
const [prompts, setPrompts] = useState<
{ name: string; id: string; type: string }[]
>([]);
const [userTools, setUserTools] = useState<OptionType[]>([]);
const [isSourcePopupOpen, setIsSourcePopupOpen] = useState(false);
const [isToolsPopupOpen, setIsToolsPopupOpen] = useState(false);
const [selectedSourceIds, setSelectedSourceIds] = useState<
Set<string | number>
>(new Set());
const [selectedToolIds, setSelectedToolIds] = useState<Set<string | number>>(
new Set(),
);
const [deleteConfirmation, setDeleteConfirmation] =
useState<ActiveState>('INACTIVE');
const [agentDetails, setAgentDetails] = useState<ActiveState>('INACTIVE');
const [addPromptModal, setAddPromptModal] = useState<ActiveState>('INACTIVE');
const sourceAnchorButtonRef = useRef<HTMLButtonElement>(null);
const toolAnchorButtonRef = useRef<HTMLButtonElement>(null);
const modeConfig = {
new: {
heading: 'New Agent',
buttonText: 'Create Agent',
showDelete: false,
showSaveDraft: true,
showLogs: false,
showAccessDetails: false,
},
edit: {
heading: 'Edit Agent',
buttonText: 'Save Changes',
showDelete: true,
showSaveDraft: false,
showLogs: true,
showAccessDetails: true,
},
draft: {
heading: 'New Agent (Draft)',
buttonText: 'Publish Draft',
showDelete: true,
showSaveDraft: true,
showLogs: false,
showAccessDetails: false,
},
};
const chunks = ['0', '2', '4', '6', '8', '10'];
const agentTypes = [
{ label: 'Classic', value: 'classic' },
{ label: 'ReAct', value: 'react' },
];
const isPublishable = () => {
return (
agent.name &&
agent.description &&
(agent.source || agent.retriever) &&
agent.chunks &&
agent.prompt_id &&
agent.agent_type
);
};
const handleCancel = () => {
if (selectedAgent) dispatch(setSelectedAgent(null));
navigate('/agents');
};
const handleDelete = async (agentId: string) => {
const response = await userService.deleteAgent(agentId, token);
if (!response.ok) throw new Error('Failed to delete agent');
navigate('/agents');
};
const handleSaveDraft = async () => {
const response =
effectiveMode === 'new'
? await userService.createAgent({ ...agent, status: 'draft' }, token)
: await userService.updateAgent(
agent.id || '',
{ ...agent, status: 'draft' },
token,
);
if (!response.ok) throw new Error('Failed to create agent draft');
const data = await response.json();
if (effectiveMode === 'new') {
setEffectiveMode('draft');
setAgent((prev) => ({ ...prev, id: data.id }));
}
};
const handlePublish = async () => {
const response =
effectiveMode === 'new'
? await userService.createAgent(
{ ...agent, status: 'published' },
token,
)
: await userService.updateAgent(
agent.id || '',
{ ...agent, status: 'published' },
token,
);
if (!response.ok) throw new Error('Failed to publish agent');
const data = await response.json();
if (data.id) setAgent((prev) => ({ ...prev, id: data.id }));
if (data.key) setAgent((prev) => ({ ...prev, key: data.key }));
if (effectiveMode === 'new') {
setAgentDetails('ACTIVE');
setEffectiveMode('edit');
}
};
useEffect(() => {
const getTools = async () => {
const response = await userService.getUserTools(token);
if (!response.ok) throw new Error('Failed to fetch tools');
const data = await response.json();
const tools: OptionType[] = data.tools.map((tool: UserToolType) => ({
id: tool.id,
label: tool.displayName,
icon: `/toolIcons/tool_${tool.name}.svg`,
}));
setUserTools(tools);
};
const getPrompts = async () => {
const response = await userService.getPrompts(token);
if (!response.ok) {
throw new Error('Failed to fetch prompts');
}
const data = await response.json();
setPrompts(data);
};
getTools();
getPrompts();
}, [token]);
useEffect(() => {
if ((mode === 'edit' || mode === 'draft') && agentId) {
const getAgent = async () => {
const response = await userService.getAgent(agentId, token);
if (!response.ok) {
navigate('/agents');
throw new Error('Failed to fetch agent');
}
const data = await response.json();
if (data.source) setSelectedSourceIds(new Set([data.source]));
else if (data.retriever)
setSelectedSourceIds(new Set([data.retriever]));
if (data.tools) setSelectedToolIds(new Set(data.tools));
if (data.status === 'draft') setEffectiveMode('draft');
setAgent(data);
};
getAgent();
}
}, [agentId, mode, token]);
useEffect(() => {
const selectedSource = Array.from(selectedSourceIds).map((id) =>
sourceDocs?.find(
(source) =>
source.id === id || source.retriever === id || source.name === id,
),
);
if (selectedSource[0]?.model === embeddingsName) {
if (selectedSource[0] && 'id' in selectedSource[0]) {
setAgent((prev) => ({
...prev,
source: selectedSource[0]?.id || 'default',
retriever: '',
}));
} else
setAgent((prev) => ({
...prev,
source: '',
retriever: selectedSource[0]?.retriever || 'classic',
}));
}
}, [selectedSourceIds]);
useEffect(() => {
const selectedTool = Array.from(selectedToolIds).map((id) =>
userTools.find((tool) => tool.id === id),
);
setAgent((prev) => ({
...prev,
tools: selectedTool
.map((tool) => tool?.id)
.filter((id): id is string => typeof id === 'string'),
}));
}, [selectedToolIds]);
useEffect(() => {
if (isPublishable()) dispatch(setSelectedAgent(agent));
}, [agent, dispatch]);
return (
<div className="p-4 md:p-12">
<div className="flex items-center gap-3 px-4">
<button
className="rounded-full border p-3 text-sm text-gray-400 dark:border-0 dark:bg-[#28292D] dark:text-gray-500 dark:hover:bg-[#2E2F34]"
onClick={handleCancel}
>
<img src={ArrowLeft} alt="left-arrow" className="h-3 w-3" />
</button>
<p className="mt-px text-sm font-semibold text-eerie-black dark:text-bright-gray">
Back to all agents
</p>
</div>
<div className="mt-5 flex w-full flex-wrap items-center justify-between gap-2 px-4">
<h1 className="m-0 text-[40px] font-bold text-[#212121] dark:text-white">
{modeConfig[effectiveMode].heading}
</h1>
<div className="flex flex-wrap items-center gap-1">
<button
className="mr-4 rounded-3xl py-2 text-sm font-medium text-purple-30 dark:bg-transparent dark:text-light-gray"
onClick={handleCancel}
>
Cancel
</button>
{modeConfig[effectiveMode].showDelete && agent.id && (
<button
className="group flex items-center gap-2 rounded-3xl border border-solid border-red-2000 px-5 py-2 text-sm font-medium text-red-2000 transition-colors hover:bg-red-2000 hover:text-white"
onClick={() => setDeleteConfirmation('ACTIVE')}
>
<span className="block h-4 w-4 bg-[url('/src/assets/red-trash.svg')] bg-contain bg-center bg-no-repeat transition-all group-hover:bg-[url('/src/assets/white-trash.svg')]" />
Delete
</button>
)}
{modeConfig[effectiveMode].showSaveDraft && (
<button
className="hover:bg-vi</button>olets-are-blue rounded-3xl border border-solid border-violets-are-blue px-5 py-2 text-sm font-medium text-violets-are-blue transition-colors hover:bg-violets-are-blue hover:text-white"
onClick={handleSaveDraft}
>
Save Draft
</button>
)}
{modeConfig[effectiveMode].showAccessDetails && (
<button
className="hover:bg-vi</button>olets-are-blue rounded-3xl border border-solid border-violets-are-blue px-5 py-2 text-sm font-medium text-violets-are-blue transition-colors hover:bg-violets-are-blue hover:text-white"
onClick={() => navigate(`/agents/logs/${agent.id}`)}
>
Logs
</button>
)}
{modeConfig[effectiveMode].showAccessDetails && (
<button
className="hover:bg-vi</button>olets-are-blue rounded-3xl border border-solid border-violets-are-blue px-5 py-2 text-sm font-medium text-violets-are-blue transition-colors hover:bg-violets-are-blue hover:text-white"
onClick={() => setAgentDetails('ACTIVE')}
>
Access Details
</button>
)}
<button
disabled={!isPublishable()}
className={`${!isPublishable() && 'cursor-not-allowed opacity-30'} rounded-3xl bg-purple-30 px-5 py-2 text-sm font-medium text-white hover:bg-violets-are-blue`}
onClick={handlePublish}
>
Publish
</button>
</div>
</div>
<div className="mt-5 flex w-full grid-cols-5 flex-col gap-10 min-[1180px]:grid min-[1180px]:gap-5">
<div className="col-span-2 flex flex-col gap-5">
<div className="rounded-[30px] bg-[#F6F6F6] px-6 py-3 dark:bg-[#383838] dark:text-[#E0E0E0]">
<h2 className="text-lg font-semibold">Meta</h2>
<input
className="mt-3 w-full rounded-3xl border border-silver bg-white px-5 py-3 text-sm text-jet outline-none placeholder:text-gray-400 dark:border-[#7E7E7E] dark:bg-[#222327] dark:text-bright-gray placeholder:dark:text-silver"
type="text"
value={agent.name}
placeholder="Agent name"
onChange={(e) => setAgent({ ...agent, name: e.target.value })}
/>
<textarea
className="mt-3 h-32 w-full rounded-3xl border border-silver bg-white px-5 py-4 text-sm text-jet outline-none placeholder:text-gray-400 dark:border-[#7E7E7E] dark:bg-[#222327] dark:text-bright-gray placeholder:dark:text-silver"
placeholder="Describe your agent"
value={agent.description}
onChange={(e) =>
setAgent({ ...agent, description: e.target.value })
}
/>
</div>
<div className="rounded-[30px] bg-[#F6F6F6] px-6 py-3 dark:bg-[#383838] dark:text-[#E0E0E0]">
<h2 className="text-lg font-semibold">Source</h2>
<div className="mt-3">
<div className="flex flex-wrap items-center gap-1">
<button
ref={sourceAnchorButtonRef}
onClick={() => setIsSourcePopupOpen(!isSourcePopupOpen)}
className="w-full truncate rounded-3xl border border-silver bg-white px-5 py-3 text-left text-sm text-gray-400 dark:border-[#7E7E7E] dark:bg-[#222327] dark:text-silver"
>
{selectedSourceIds.size > 0
? Array.from(selectedSourceIds)
.map(
(id) =>
sourceDocs?.find(
(source) =>
source.id === id ||
source.name === id ||
source.retriever === id,
)?.name,
)
.filter(Boolean)
.join(', ')
: 'Select source'}
</button>
<MultiSelectPopup
isOpen={isSourcePopupOpen}
onClose={() => setIsSourcePopupOpen(false)}
anchorRef={sourceAnchorButtonRef}
options={
sourceDocs?.map((doc: Doc) => ({
id: doc.id || doc.retriever || doc.name,
label: doc.name,
icon: SourceIcon,
})) || []
}
selectedIds={selectedSourceIds}
onSelectionChange={(newSelectedIds: Set<string | number>) => {
setSelectedSourceIds(newSelectedIds);
setIsSourcePopupOpen(false);
}}
title="Select Source"
searchPlaceholder="Search sources..."
noOptionsMessage="No source available"
singleSelect={true}
/>
</div>
<div className="mt-3">
<Dropdown
options={chunks}
selectedValue={agent.chunks ? agent.chunks : null}
onSelect={(value: string) =>
setAgent({ ...agent, chunks: value })
}
size="w-full"
rounded="3xl"
buttonDarkBackgroundColor="[#222327]"
border="border"
darkBorderColor="[#7E7E7E]"
placeholder="Chunks per query"
placeholderTextColor="gray-400"
darkPlaceholderTextColor="silver"
contentSize="text-sm"
/>
</div>
</div>
</div>
<div className="rounded-[30px] bg-[#F6F6F6] px-6 py-3 dark:bg-[#383838] dark:text-[#E0E0E0]">
<h2 className="text-lg font-semibold">Prompt</h2>
<div className="mt-3 flex flex-wrap items-center gap-1">
<div className="min-w-20 flex-grow basis-full sm:basis-0">
<Dropdown
options={prompts.map((prompt) => ({
label: prompt.name,
value: prompt.id,
}))}
selectedValue={
agent.prompt_id
? prompts.filter(
(prompt) => prompt.id === agent.prompt_id,
)[0].name || null
: null
}
onSelect={(option: { label: string; value: string }) =>
setAgent({ ...agent, prompt_id: option.value })
}
size="w-full"
rounded="3xl"
buttonDarkBackgroundColor="[#222327]"
border="border"
darkBorderColor="[#7E7E7E]"
placeholder="Select a prompt"
placeholderTextColor="gray-400"
darkPlaceholderTextColor="silver"
contentSize="text-sm"
/>
</div>
<button
className="w-20 flex-shrink-0 basis-full rounded-3xl border-2 border-solid border-violets-are-blue px-5 py-[11px] text-sm text-violets-are-blue transition-colors hover:bg-violets-are-blue hover:text-white sm:basis-auto"
onClick={() => setAddPromptModal('ACTIVE')}
>
Add
</button>
</div>
</div>
<div className="rounded-[30px] bg-[#F6F6F6] px-6 py-3 dark:bg-[#383838] dark:text-[#E0E0E0]">
<h2 className="text-lg font-semibold">Tools</h2>
<div className="mt-3 flex flex-wrap items-center gap-1">
<button
ref={toolAnchorButtonRef}
onClick={() => setIsToolsPopupOpen(!isToolsPopupOpen)}
className="w-full truncate rounded-3xl border border-silver bg-white px-5 py-3 text-left text-sm text-gray-400 dark:border-[#7E7E7E] dark:bg-[#222327] dark:text-silver"
>
{selectedToolIds.size > 0
? Array.from(selectedToolIds)
.map(
(id) => userTools.find((tool) => tool.id === id)?.label,
)
.filter(Boolean)
.join(', ')
: 'Select tools'}
</button>
<MultiSelectPopup
isOpen={isToolsPopupOpen}
onClose={() => setIsToolsPopupOpen(false)}
anchorRef={toolAnchorButtonRef}
options={userTools}
selectedIds={selectedToolIds}
onSelectionChange={(newSelectedIds: Set<string | number>) =>
setSelectedToolIds(newSelectedIds)
}
title="Select Tools"
searchPlaceholder="Search tools..."
noOptionsMessage="No tools available"
/>
</div>
</div>
<div className="rounded-[30px] bg-[#F6F6F6] px-6 py-3 dark:bg-[#383838] dark:text-[#E0E0E0]">
<h2 className="text-lg font-semibold">Agent type</h2>
<div className="mt-3">
<Dropdown
options={agentTypes}
selectedValue={
agent.agent_type
? agentTypes.find((type) => type.value === agent.agent_type)
?.label || null
: null
}
onSelect={(option: { label: string; value: string }) =>
setAgent({ ...agent, agent_type: option.value })
}
size="w-full"
rounded="3xl"
buttonDarkBackgroundColor="[#222327]"
border="border"
darkBorderColor="[#7E7E7E]"
placeholder="Select type"
placeholderTextColor="gray-400"
darkPlaceholderTextColor="silver"
contentSize="text-sm"
/>
</div>
</div>
</div>
<div className="col-span-3 flex flex-col gap-3 rounded-[30px] bg-[#F6F6F6] px-6 py-3 dark:bg-[#383838] dark:text-[#E0E0E0]">
<h2 className="text-lg font-semibold">Preview</h2>
<AgentPreviewArea />
</div>
</div>
<ConfirmationModal
message="Are you sure you want to delete this agent?"
modalState={deleteConfirmation}
setModalState={setDeleteConfirmation}
submitLabel="Delete"
handleSubmit={() => {
handleDelete(agent.id || '');
setDeleteConfirmation('INACTIVE');
}}
cancelLabel="Cancel"
variant="danger"
/>
<AgentDetailsModal
agent={agent}
mode={effectiveMode}
modalState={agentDetails}
setModalState={setAgentDetails}
/>
<AddPromptModal
prompts={prompts}
setPrompts={setPrompts}
isOpen={addPromptModal}
onClose={() => setAddPromptModal('INACTIVE')}
onSelect={(name: string, id: string, type: string) => {
setAgent({ ...agent, prompt_id: id });
}}
/>
</div>
);
}
function AgentPreviewArea() {
const selectedAgent = useSelector(selectSelectedAgent);
return (
<div className="h-full w-full rounded-[30px] border border-[#F6F6F6] bg-white dark:border-[#7E7E7E] dark:bg-[#222327] max-[1180px]:h-[48rem]">
{selectedAgent?.id ? (
<div className="flex h-full w-full flex-col justify-end overflow-auto rounded-[30px]">
<AgentPreview />
</div>
) : (
<div className="flex h-full w-full flex-col items-center justify-center gap-2">
<span className="block h-12 w-12 bg-[url('/src/assets/science-spark.svg')] bg-contain bg-center bg-no-repeat transition-all dark:bg-[url('/src/assets/science-spark-dark.svg')]" />{' '}
<p className="text-xs text-[#18181B] dark:text-[#949494]">
Published agents can be previewd here
</p>
</div>
)}
</div>
);
}
function AddPromptModal({
prompts,
setPrompts,
isOpen,
onClose,
onSelect,
}: {
prompts: Prompt[];
setPrompts?: React.Dispatch<React.SetStateAction<Prompt[]>>;
isOpen: ActiveState;
onClose: () => void;
onSelect?: (name: string, id: string, type: string) => void;
}) {
const token = useSelector(selectToken);
const [newPromptName, setNewPromptName] = useState('');
const [newPromptContent, setNewPromptContent] = useState('');
const handleAddPrompt = async () => {
try {
const response = await userService.createPrompt(
{
name: newPromptName,
content: newPromptContent,
},
token,
);
if (!response.ok) {
throw new Error('Failed to add prompt');
}
const newPrompt = await response.json();
if (setPrompts) {
setPrompts([
...prompts,
{ name: newPromptName, id: newPrompt.id, type: 'private' },
]);
}
onClose();
setNewPromptName('');
setNewPromptContent('');
onSelect?.(newPromptName, newPrompt.id, newPromptContent);
} catch (error) {
console.error(error);
}
};
return (
<PromptsModal
modalState={isOpen}
setModalState={onClose}
type="ADD"
existingPrompts={prompts}
newPromptName={newPromptName}
setNewPromptName={setNewPromptName}
newPromptContent={newPromptContent}
setNewPromptContent={setNewPromptContent}
editPromptName={''}
setEditPromptName={() => {}}
editPromptContent={''}
setEditPromptContent={() => {}}
currentPromptEdit={{ id: '', name: '', type: '' }}
handleAddPrompt={handleAddPrompt}
/>
);
}

View File

@@ -0,0 +1,275 @@
import React, { SyntheticEvent, useEffect, useRef, useState } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { Route, Routes, useNavigate } from 'react-router-dom';
import userService from '../api/services/userService';
import Copy from '../assets/copy-linear.svg';
import Edit from '../assets/edit.svg';
import Monitoring from '../assets/monitoring.svg';
import Trash from '../assets/red-trash.svg';
import Robot from '../assets/robot.svg';
import ThreeDots from '../assets/three-dots.svg';
import ContextMenu, { MenuOption } from '../components/ContextMenu';
import ConfirmationModal from '../modals/ConfirmationModal';
import { ActiveState } from '../models/misc';
import { selectToken, setSelectedAgent } from '../preferences/preferenceSlice';
import AgentLogs from './AgentLogs';
import NewAgent from './NewAgent';
import { Agent } from './types';
import Spinner from '../components/Spinner';
export default function Agents() {
return (
<Routes>
<Route path="/" element={<AgentsList />} />
<Route path="/new" element={<NewAgent mode="new" />} />
<Route path="/edit/:agentId" element={<NewAgent mode="edit" />} />
<Route path="/logs/:agentId" element={<AgentLogs />} />
</Routes>
);
}
function AgentsList() {
const navigate = useNavigate();
const token = useSelector(selectToken);
const [userAgents, setUserAgents] = useState<Agent[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const getAgents = async () => {
try {
setLoading(true);
const response = await userService.getAgents(token);
if (!response.ok) throw new Error('Failed to fetch agents');
const data = await response.json();
setUserAgents(data);
setLoading(false);
} catch (error) {
console.error('Error:', error);
setLoading(false);
}
};
useEffect(() => {
getAgents();
}, [token]);
return (
<div className="p-4 md:p-12">
<h1 className="mb-0 text-[40px] font-bold text-[#212121] dark:text-[#E0E0E0]">
Agents
</h1>
<p className="mt-5 text-[15px] text-[#71717A] dark:text-[#949494]">
Discover and create custom versions of DocsGPT that combine
instructions, extra knowledge, and any combination of skills.
</p>
{/* <div className="mt-6">
<h2 className="text-[18px] font-semibold text-[#18181B] dark:text-[#E0E0E0]">
Premade by DocsGPT
</h2>
<div className="mt-4 flex w-full flex-wrap gap-4">
{Array.from({ length: 5 }, (_, index) => (
<div
key={index}
className="relative flex h-44 w-48 flex-col justify-between rounded-[1.2rem] bg-[#F6F6F6] px-6 py-5 dark:bg-[#383838]"
>
<button onClick={() => {}} className="absolute right-4 top-4">
<img
src={Copy}
alt={'use-agent'}
className="h-[19px] w-[19px]"
/>
</button>
<div className="w-full">
<div className="flex w-full items-center px-1">
<img
src={Robot}
alt="agent-logo"
className="h-7 w-7 rounded-full"
/>
</div>
<div className="mt-2">
<p
title={''}
className="truncate px-1 text-[13px] font-semibold capitalize leading-relaxed text-raisin-black-light dark:text-bright-gray"
>
{}
</p>
<p className="mt-1 h-20 overflow-auto px-1 text-[12px] leading-relaxed text-old-silver dark:text-sonic-silver-light">
{}
</p>
</div>
</div>
<div className="absolute bottom-4 right-4"></div>
</div>
))}
</div>
</div> */}
<div className="mt-8 flex flex-col gap-4">
<div className="flex w-full items-center justify-between">
<h2 className="text-[18px] font-semibold text-[#18181B] dark:text-[#E0E0E0]">
Created by You
</h2>
<button
className="rounded-full bg-purple-30 px-4 py-2 text-sm text-white hover:bg-violets-are-blue"
onClick={() => navigate('/agents/new')}
>
New Agent
</button>
</div>
<div className="flex w-full flex-wrap gap-4">
{loading ? (
<div className="flex h-72 w-full items-center justify-center">
<Spinner />
</div>
) : userAgents.length > 0 ? (
userAgents.map((agent) => (
<AgentCard
key={agent.id}
agent={agent}
setUserAgents={setUserAgents}
/>
))
) : (
<div className="flex h-72 w-full flex-col items-center justify-center gap-3 text-base text-[#18181B] dark:text-[#E0E0E0]">
<p>You dont have any created agents yet </p>
<button
className="ml-2 rounded-full bg-purple-30 px-4 py-2 text-sm text-white hover:bg-violets-are-blue"
onClick={() => navigate('/agents/new')}
>
New Agent
</button>
</div>
)}
</div>
</div>
</div>
);
}
function AgentCard({
agent,
setUserAgents,
}: {
agent: Agent;
setUserAgents: React.Dispatch<React.SetStateAction<Agent[]>>;
}) {
const navigate = useNavigate();
const dispatch = useDispatch();
const token = useSelector(selectToken);
const [isMenuOpen, setIsMenuOpen] = useState<boolean>(false);
const [deleteConfirmation, setDeleteConfirmation] =
useState<ActiveState>('INACTIVE');
const menuRef = useRef<HTMLDivElement>(null);
const menuOptions: MenuOption[] = [
{
icon: Monitoring,
label: 'Logs',
onClick: (e: SyntheticEvent) => {
e.stopPropagation();
navigate(`/agents/logs/${agent.id}`);
},
variant: 'primary',
iconWidth: 14,
iconHeight: 14,
},
{
icon: Edit,
label: 'Edit',
onClick: (e: SyntheticEvent) => {
e.stopPropagation();
navigate(`/agents/edit/${agent.id}`);
},
variant: 'primary',
iconWidth: 14,
iconHeight: 14,
},
{
icon: Trash,
label: 'Delete',
onClick: (e: SyntheticEvent) => {
e.stopPropagation();
setDeleteConfirmation('ACTIVE');
},
variant: 'danger',
iconWidth: 12,
iconHeight: 12,
},
];
const handleClick = () => {
dispatch(setSelectedAgent(agent));
navigate(`/`);
};
const handleDelete = async (agentId: string) => {
const response = await userService.deleteAgent(agentId, token);
if (!response.ok) throw new Error('Failed to delete agent');
const data = await response.json();
setUserAgents((prevAgents) =>
prevAgents.filter((prevAgent) => prevAgent.id !== data.id),
);
};
return (
<div
className="relative flex h-44 w-48 cursor-pointer flex-col justify-between rounded-[1.2rem] bg-[#F6F6F6] px-6 py-5 dark:bg-[#383838]"
onClick={(e) => handleClick()}
>
<div
ref={menuRef}
onClick={(e) => {
e.stopPropagation();
setIsMenuOpen(true);
}}
className="absolute right-4 top-4 z-50 cursor-pointer"
>
<img src={ThreeDots} alt={'use-agent'} className="h-[19px] w-[19px]" />
<ContextMenu
isOpen={isMenuOpen}
setIsOpen={setIsMenuOpen}
options={menuOptions}
anchorRef={menuRef}
position="top-right"
offset={{ x: 0, y: 0 }}
/>
</div>
<div className="w-full">
<div className="flex w-full items-center gap-1 px-1">
<img
src={agent.image ?? Robot}
alt={`${agent.name}`}
className="h-7 w-7 rounded-full"
/>
{agent.status === 'draft' && (
<p className="text-xs text-black opacity-50 dark:text-[#E0E0E0]">{`(Draft)`}</p>
)}
</div>
<div className="mt-2">
<p
title={agent.name}
className="truncate px-1 text-[13px] font-semibold capitalize leading-relaxed text-[#020617] dark:text-[#E0E0E0]"
>
{agent.name}
</p>
<p className="mt-1 h-20 overflow-auto px-1 text-[12px] leading-relaxed text-[#64748B] dark:text-sonic-silver-light">
{agent.description}
</p>
</div>
</div>
<ConfirmationModal
message="Are you sure you want to delete this agent?"
modalState={deleteConfirmation}
setModalState={setDeleteConfirmation}
submitLabel="Delete"
handleSubmit={() => {
handleDelete(agent.id || '');
setDeleteConfirmation('INACTIVE');
}}
cancelLabel="Cancel"
variant="danger"
/>
</div>
);
}

View File

@@ -0,0 +1,17 @@
export type Agent = {
id?: string;
name: string;
description: string;
image: string;
source: string;
chunks: string;
retriever: string;
prompt_id: string;
tools: string[];
agent_type: string;
status: string;
key?: string;
created_at?: string;
updated_at?: string;
last_used_at?: string;
};

View File

@@ -8,6 +8,11 @@ const endpoints = {
API_KEYS: '/api/get_api_keys',
CREATE_API_KEY: '/api/create_api_key',
DELETE_API_KEY: '/api/delete_api_key',
AGENT: (id: string) => `/api/get_agent?id=${id}`,
AGENTS: '/api/get_agents',
CREATE_AGENT: '/api/create_agent',
UPDATE_AGENT: (agent_id: string) => `/api/update_agent/${agent_id}`,
DELETE_AGENT: (id: string) => `/api/delete_agent?id=${id}`,
PROMPTS: '/api/get_prompts',
CREATE_PROMPT: '/api/create_prompt',
DELETE_PROMPT: '/api/delete_prompt',

View File

@@ -17,6 +17,20 @@ const userService = {
apiClient.post(endpoints.USER.CREATE_API_KEY, data, token),
deleteAPIKey: (data: any, token: string | null): Promise<any> =>
apiClient.post(endpoints.USER.DELETE_API_KEY, data, token),
getAgent: (id: string, token: string | null): Promise<any> =>
apiClient.get(endpoints.USER.AGENT(id), token),
getAgents: (token: string | null): Promise<any> =>
apiClient.get(endpoints.USER.AGENTS, token),
createAgent: (data: any, token: string | null): Promise<any> =>
apiClient.post(endpoints.USER.CREATE_AGENT, data, token),
updateAgent: (
agent_id: string,
data: any,
token: string | null,
): Promise<any> =>
apiClient.put(endpoints.USER.UPDATE_AGENT(agent_id), data, token),
deleteAgent: (id: string, token: string | null): Promise<any> =>
apiClient.delete(endpoints.USER.DELETE_AGENT(id), token),
getPrompts: (token: string | null): Promise<any> =>
apiClient.get(endpoints.USER.PROMPTS, token),
createPrompt: (data: any, token: string | null): Promise<any> =>

View File

@@ -0,0 +1,4 @@
<svg width="17" height="19" viewBox="0 0 17 19" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.75 8.70825C3.75 6.46942 3.75 5.34921 4.44588 4.65413C5.14096 3.95825 6.26117 3.95825 8.5 3.95825H10.875C13.1138 3.95825 14.234 3.95825 14.9291 4.65413C15.625 5.34921 15.625 6.46942 15.625 8.70825V12.6666C15.625 14.9054 15.625 16.0256 14.9291 16.7207C14.234 17.4166 13.1138 17.4166 10.875 17.4166H8.5C6.26117 17.4166 5.14096 17.4166 4.44588 16.7207C3.75 16.0256 3.75 14.9054 3.75 12.6666V8.70825Z" stroke="#B9B9B9" stroke-width="1.5"/>
<path d="M3.75 15.0416C3.12011 15.0416 2.51602 14.7914 2.07062 14.346C1.62522 13.9006 1.375 13.2965 1.375 12.6666V7.91658C1.375 4.93121 1.375 3.43813 2.30283 2.51109C3.23067 1.58404 4.72296 1.58325 7.70833 1.58325H10.875C11.5049 1.58325 12.109 1.83347 12.5544 2.27887C12.9998 2.72427 13.25 3.32836 13.25 3.95825" stroke="#B9B9B9" stroke-width="1.5"/>
</svg>

After

Width:  |  Height:  |  Size: 901 B

View File

@@ -0,0 +1,3 @@
<svg width="19" height="17" viewBox="0 0 19 17" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.85 16.087C4.84924 16.2663 4.91145 16.4239 5.03693 16.5494C5.16233 16.6747 5.31971 16.7373 5.499 16.7373L5.4997 16.7373C5.67889 16.7364 5.83627 16.6743 5.9617 16.5497C6.08758 16.4247 6.15 16.267 6.15 16.0873V10.7993C6.15 10.619 6.08842 10.4607 5.96269 10.3358C5.83772 10.2117 5.68058 10.1501 5.5017 10.1493C5.32173 10.1484 5.16366 10.2105 5.03793 10.3362C4.9124 10.4618 4.85 10.6195 4.85 10.7993V16.087ZM4.85 16.087C4.85 16.0868 4.85 16.0867 4.85 16.0866L5 16.0873M4.85 16.087V16.0873H5M5 16.0873C4.99933 16.2286 5.047 16.3473 5.143 16.4433C5.239 16.5393 5.35767 16.5873 5.499 16.5873L5 16.0873ZM17.4984 0.850356C17.6844 0.844257 17.8447 0.916616 17.9702 1.05647C18.0903 1.18276 18.1491 1.33782 18.1439 1.51269C18.1389 1.6837 18.0787 1.83386 17.9605 1.95296L17.9601 1.95335L12.7431 7.17035L12.7421 7.17131C12.5779 7.33262 12.3846 7.45674 12.1641 7.54381C11.9451 7.63023 11.7235 7.67428 11.5 7.67428C11.277 7.67428 11.0585 7.63017 10.8453 7.54316M17.4984 0.850356L10.8453 7.54316M17.4984 0.850356C17.3204 0.855502 17.1648 0.921665 17.0399 1.04721L17.4984 0.850356ZM10.8453 7.54316C10.632 7.45637 10.4379 7.33351 10.2633 7.17548L10.2578 7.1705L10.258 7.17037L7.83596 4.74937L7.83593 4.74935C7.75166 4.66508 7.6439 4.62029 7.5 4.62029C7.3561 4.62029 7.24834 4.66508 7.16407 4.74935L1.96007 9.95335C1.83516 10.0783 1.67973 10.1443 1.50197 10.1502L1.50128 10.1502C1.31531 10.1555 1.15551 10.0825 1.03084 9.94215C0.910023 9.81657 0.850921 9.66177 0.856065 9.48688C0.861095 9.31586 0.921352 9.1658 1.03993 9.04722L6.25793 3.82922L6.25859 3.82856C6.43436 3.65497 6.63139 3.52612 6.84951 3.44477C7.06174 3.36537 7.27883 3.32528 7.5 3.32528C7.72114 3.32528 7.94133 3.36536 8.15989 3.44418C8.38508 3.5254 8.58031 3.65486 8.74407 3.83122L11.164 6.2502L11.1641 6.25022C11.2483 6.33449 11.3561 6.37928 11.5 6.37928C11.6439 6.37928 11.7517 6.33449 11.8359 6.25022L11.8359 6.25021L17.0396 1.04758L10.8453 7.54316ZM1.5 16.7373L1.5007 16.7373C1.68022 16.7364 1.83649 16.6741 1.9617 16.5497C2.08758 16.4247 2.15 16.267 2.15 16.0873V15.2993C2.15 15.119 2.08842 14.9607 1.96269 14.8358C1.83772 14.7117 1.68058 14.6501 1.5017 14.6493C1.32173 14.6484 1.16366 14.7105 1.03793 14.8362C0.912396 14.9618 0.85 15.1195 0.85 15.2993V16.0873C0.85 16.2663 0.91197 16.4235 1.03657 16.549C1.16172 16.675 1.31979 16.7373 1.5 16.7373ZM9.499 16.7373L9.4997 16.7373C9.67889 16.7364 9.83627 16.6743 9.9617 16.5497C10.0876 16.4247 10.15 16.267 10.15 16.0873V12.7993C10.15 12.619 10.0884 12.4607 9.96269 12.3358C9.83772 12.2117 9.68058 12.1501 9.5017 12.1493C9.32173 12.1484 9.16366 12.2105 9.03793 12.3362C8.9124 12.4618 8.85 12.6195 8.85 12.7993V16.0873C8.85 16.2663 8.91197 16.4235 9.03656 16.549C9.16158 16.6749 9.31924 16.7373 9.499 16.7373ZM13.499 16.7373L13.4997 16.7373C13.6789 16.7364 13.8363 16.6743 13.9617 16.5497C14.0876 16.4247 14.15 16.267 14.15 16.0873V11.2993C14.15 11.119 14.0884 10.9607 13.9627 10.8358C13.8377 10.7117 13.6806 10.6501 13.5017 10.6493C13.3217 10.6484 13.1637 10.7105 13.0379 10.8362C12.9124 10.9618 12.85 11.1195 12.85 11.2993L12.85 16.0866L13 16.0873H12.85V16.0868C12.8492 16.2662 12.9114 16.4238 13.0369 16.5494C13.1623 16.6747 13.3197 16.7373 13.499 16.7373ZM17.499 16.7373L17.4997 16.7373C17.6789 16.7364 17.8363 16.6743 17.9617 16.5497C18.0876 16.4247 18.15 16.267 18.15 16.0873V7.29928C18.15 7.11895 18.0884 6.9607 17.9627 6.83585C17.8377 6.71175 17.6806 6.65013 17.5017 6.64929C17.3217 6.64844 17.1637 6.7105 17.0379 6.83622C16.9124 6.96176 16.85 7.11954 16.85 7.29928L16.85 16.0866L17 16.0873H16.85V16.0868C16.8492 16.2662 16.9114 16.4238 17.0369 16.5494C17.1623 16.6747 17.3197 16.7373 17.499 16.7373Z" fill="#747474" stroke="#747474" stroke-width="0.3"/>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@@ -1,3 +1,3 @@
<svg width="24" height="25" viewBox="0 0 24 25" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.66427 17.7747C6.66427 18.2167 6.84488 18.6406 7.16637 18.9532C7.48787 19.2658 7.9239 19.4413 8.37856 19.4413H15.2357C15.6904 19.4413 16.1264 19.2658 16.4479 18.9532C16.7694 18.6406 16.95 18.2167 16.95 17.7747V7.77468H6.66427V17.7747ZM8.37856 9.44135H15.2357V17.7747H8.37856V9.44135ZM14.8071 5.27468L13.95 4.44135H9.66427L8.80713 5.27468H5.80713V6.94135H17.8071V5.27468H14.8071Z" fill="#D30000"/>
<svg width="13" height="16" viewBox="0 0 13 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.66574 13.7747C1.66574 14.2168 1.84635 14.6407 2.16784 14.9533C2.48933 15.2658 2.92537 15.4414 3.38002 15.4414H10.2372C10.6918 15.4414 11.1279 15.2658 11.4493 14.9533C11.7708 14.6407 11.9515 14.2168 11.9515 13.7747V3.77474H1.66574V13.7747ZM3.38002 5.44141H10.2372V13.7747H3.38002V5.44141ZM9.80859 1.27474L8.95145 0.441406H4.66574L3.80859 1.27474H0.808594V2.94141H12.8086V1.27474H9.80859Z" fill="#f44336"/>
</svg>

Before

Width:  |  Height:  |  Size: 511 B

After

Width:  |  Height:  |  Size: 520 B

View File

@@ -0,0 +1,22 @@
<svg width="22" height="18" viewBox="0 0 22 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.394 1.001H7.982C7.32776 1.00074 6.67989 1.12939 6.07539 1.3796C5.47089 1.62982 4.92162 1.99669 4.45896 2.45926C3.9963 2.92182 3.62932 3.47102 3.37898 4.07547C3.12865 4.67992 2.99987 5.32776 3 5.982V11.394C2.99974 12.0483 3.12842 12.6963 3.3787 13.3008C3.62897 13.9054 3.99593 14.4547 4.45861 14.9174C4.92128 15.3801 5.4706 15.747 6.07516 15.9973C6.67972 16.2476 7.32768 16.3763 7.982 16.376H13.394C14.0483 16.3763 14.6963 16.2476 15.3008 15.9973C15.9054 15.747 16.4547 15.3801 16.9174 14.9174C17.3801 14.4547 17.747 13.9054 17.9973 13.3008C18.2476 12.6963 18.3763 12.0483 18.376 11.394V5.982C18.3763 5.32768 18.2476 4.67972 17.9973 4.07516C17.747 3.4706 17.3801 2.92128 16.9174 2.45861C16.4547 1.99593 15.9054 1.62897 15.3008 1.3787C14.6963 1.12842 14.0483 0.999738 13.394 1V1.001Z" stroke="url(#paint0_linear_8958_15228)" stroke-width="1.5"/>
<path d="M18.606 12.5881H20.225C20.4968 12.5881 20.7576 12.4801 20.9498 12.2879C21.142 12.0956 21.25 11.8349 21.25 11.5631V6.43809C21.25 6.16624 21.142 5.90553 20.9498 5.7133C20.7576 5.52108 20.4968 5.41309 20.225 5.41309H18.605M3.395 12.5881H1.775C1.6404 12.5881 1.50711 12.5616 1.38275 12.5101C1.25839 12.4586 1.1454 12.3831 1.05022 12.2879C0.955035 12.1927 0.879535 12.0797 0.828023 11.9553C0.776512 11.831 0.75 11.6977 0.75 11.5631V6.43809C0.75 6.16624 0.857991 5.90553 1.05022 5.7133C1.24244 5.52108 1.50315 5.41309 1.775 5.41309H3.395" stroke="url(#paint1_linear_8958_15228)" stroke-width="1.5"/>
<path d="M1.76562 5.41323V1.31323M20.2256 5.41323L20.2156 1.31323M7.91562 5.76323V8.46123M14.0656 5.76323V8.46123M8.94062 12.5882H13.0406" stroke="url(#paint2_linear_8958_15228)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<defs>
<linearGradient id="paint0_linear_8958_15228" x1="10.688" y1="1" x2="10.688" y2="16.376" gradientUnits="userSpaceOnUse">
<stop stop-color="#58E2E1"/>
<stop offset="0.524038" stop-color="#657797"/>
<stop offset="1" stop-color="#CC7871"/>
</linearGradient>
<linearGradient id="paint1_linear_8958_15228" x1="11" y1="5.41309" x2="11" y2="12.5881" gradientUnits="userSpaceOnUse">
<stop stop-color="#58E2E1"/>
<stop offset="0.524038" stop-color="#657797"/>
<stop offset="1" stop-color="#CC7871"/>
</linearGradient>
<linearGradient id="paint2_linear_8958_15228" x1="10.9956" y1="1.31323" x2="10.9956" y2="12.5882" gradientUnits="userSpaceOnUse">
<stop stop-color="#58E2E1"/>
<stop offset="0.524038" stop-color="#657797"/>
<stop offset="1" stop-color="#CC7871"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -0,0 +1,12 @@
<svg width="60" height="60" viewBox="0 0 60 60" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_8836_14948)">
<path d="M25.7094 18.5522C22.8007 20.7575 20.0485 23.1618 17.4725 25.7479C5.33468 37.8856 -0.811037 51.4105 3.7385 55.9643C8.28803 60.5138 21.8171 54.3638 33.9507 42.2303C36.5369 39.6529 38.9412 36.8992 41.1463 33.9891" stroke="#3F4147" stroke-width="4.26786" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M41.1421 33.989C48.2993 43.4892 51.2142 52.4261 47.6804 55.9599C43.1266 60.5137 29.6018 54.3637 17.464 42.2302C5.33477 30.0881 -0.810942 16.5676 3.73859 12.0138C7.27238 8.48424 16.2093 11.3992 25.7095 18.5521" stroke="#3F4147" stroke-width="4.26786" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M23.5781 33.9891C23.5781 34.5551 23.8029 35.0978 24.2031 35.498C24.6033 35.8982 25.1461 36.123 25.7121 36.123C26.278 36.123 26.8208 35.8982 27.221 35.498C27.6212 35.0978 27.846 34.5551 27.846 33.9891C27.846 33.4232 27.6212 32.8804 27.221 32.4802C26.8208 32.08 26.278 31.8552 25.7121 31.8552C25.1461 31.8552 24.6033 32.08 24.2031 32.4802C23.8029 32.8804 23.5781 33.4232 23.5781 33.9891ZM34.2904 15.4069C32.9461 15.1721 32.9461 13.2473 34.2904 13.0169C36.6655 12.6055 38.864 11.4952 40.6049 9.82805C42.3458 8.16089 43.5501 6.01251 44.0638 3.65746L44.1407 3.28616C44.4309 1.96312 46.3173 1.95459 46.616 3.27335L46.7184 3.70441C47.2541 6.04779 48.4698 8.18083 50.2131 9.83599C51.9563 11.4912 54.1495 12.5947 56.5174 13.0083C57.8661 13.2431 57.8661 15.1807 56.5174 15.4111C54.1495 15.8247 51.9563 16.9283 50.2131 18.5835C48.4698 20.2386 47.2541 22.3717 46.7184 24.7151L46.616 25.1461C46.3173 26.4692 44.4309 26.4606 44.1407 25.1376L44.0553 24.7663C43.5415 22.4112 42.3372 20.2629 40.5963 18.5957C38.8554 16.9285 36.657 15.8183 34.2819 15.4069H34.2904Z" stroke="#5F6167" stroke-width="4.26786" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_8836_14948">
<rect width="59.75" height="59.75" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1,12 @@
<svg width="60" height="60" viewBox="0 0 60 60" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_8612_14680)">
<path d="M25.7094 18.5525C22.8007 20.7577 20.0485 23.162 17.4725 25.7481C5.33468 37.8859 -0.811037 51.4107 3.7385 55.9645C8.28803 60.5141 21.8171 54.3641 33.9507 42.2306C36.5369 39.6532 38.9412 36.8995 41.1463 33.9893" stroke="#DCDCDC" stroke-width="4.26786" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M41.1421 33.9891C48.2993 43.4893 51.2142 52.4262 47.6804 55.96C43.1266 60.5138 29.6018 54.3638 17.464 42.2303C5.33477 30.0883 -0.810942 16.5677 3.73859 12.0139C7.27238 8.48436 16.2093 11.3993 25.7095 18.5522" stroke="#DCDCDC" stroke-width="4.26786" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M23.5781 33.9891C23.5781 34.5551 23.8029 35.0978 24.2031 35.498C24.6033 35.8982 25.1461 36.123 25.7121 36.123C26.278 36.123 26.8208 35.8982 27.221 35.498C27.6212 35.0978 27.846 34.5551 27.846 33.9891C27.846 33.4232 27.6212 32.8804 27.221 32.4802C26.8208 32.08 26.278 31.8552 25.7121 31.8552C25.1461 31.8552 24.6033 32.08 24.2031 32.4802C23.8029 32.8804 23.5781 33.4232 23.5781 33.9891ZM34.2904 15.4069C32.9461 15.1721 32.9461 13.2473 34.2904 13.0169C36.6655 12.6055 38.864 11.4952 40.6049 9.82805C42.3458 8.16089 43.5501 6.01251 44.0638 3.65746L44.1407 3.28616C44.4309 1.96312 46.3173 1.95459 46.616 3.27335L46.7184 3.70441C47.2541 6.04779 48.4698 8.18083 50.2131 9.83599C51.9563 11.4912 54.1495 12.5947 56.5174 13.0083C57.8661 13.2431 57.8661 15.1807 56.5174 15.4111C54.1495 15.8247 51.9563 16.9283 50.2131 18.5835C48.4698 20.2386 47.2541 22.3717 46.7184 24.7151L46.616 25.1461C46.3173 26.4692 44.4309 26.4606 44.1407 25.1376L44.0553 24.7663C43.5415 22.4112 42.3372 20.2629 40.5963 18.5957C38.8554 16.9285 36.657 15.8183 34.2819 15.4069H34.2904Z" stroke="#999999" stroke-width="4.26786" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_8612_14680">
<rect width="59.75" height="59.75" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1,3 @@
<svg width="19" height="19" viewBox="0 0 19 19" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.04071 11.3081H1.51004C1.33769 11.3088 1.1726 11.3776 1.05073 11.4995C0.928855 11.6213 0.860077 11.7864 0.859375 11.9588V17.4921C0.859375 17.8521 1.15137 18.1428 1.51004 18.1428H7.04337C7.40337 18.1428 7.69404 17.8508 7.69404 17.4921V11.9588C7.69334 11.7864 7.62456 11.6213 7.50269 11.4995C7.38082 11.3776 7.21573 11.3088 7.04337 11.3081H7.04071ZM17.1594 11.3081H11.626C11.4537 11.3088 11.2886 11.3776 11.1667 11.4995C11.0449 11.6213 10.9761 11.7864 10.9754 11.9588V17.4921C10.9754 17.8521 11.266 18.1428 11.626 18.1428H17.1594C17.5194 18.1428 17.81 17.8508 17.81 17.4921V11.9588C17.8093 11.7864 17.7406 11.6213 17.6187 11.4995C17.4968 11.3776 17.3317 11.3088 17.1594 11.3081ZM7.04071 1.19079H1.51004C1.33769 1.19149 1.1726 1.26027 1.05073 1.38214C0.928855 1.50401 0.860077 1.6691 0.859375 1.84145V7.37479C0.859375 7.73479 1.15137 8.02545 1.51004 8.02545H7.04337C7.40337 8.02545 7.69404 7.73345 7.69404 7.37479V1.84012C7.69334 1.66777 7.62456 1.50268 7.50269 1.3808C7.38082 1.25893 7.21573 1.19016 7.04337 1.18945L7.04071 1.19079ZM10.862 5.11212C10.4607 5.04279 10.4607 4.46812 10.862 4.39745C11.5717 4.27403 12.2286 3.94205 12.7489 3.44385C13.2692 2.94565 13.6293 2.3038 13.7834 1.60012L13.8074 1.48945C13.894 1.09345 14.458 1.08945 14.5487 1.48545L14.578 1.61479C14.7379 2.31545 15.1012 2.95326 15.6224 3.4481C16.1436 3.94294 16.7994 4.27276 17.5074 4.39612C17.91 4.46545 17.91 5.04412 17.5074 5.11479C16.7994 5.23815 16.1436 5.56796 15.6224 6.0628C15.1012 6.55765 14.7379 7.19545 14.578 7.89612L14.5487 8.02412C14.458 8.42012 13.8954 8.41745 13.8074 8.02145L13.7834 7.91079C13.6295 7.20686 13.2695 6.56472 12.7492 6.06627C12.2289 5.56781 11.5719 5.23564 10.862 5.11212Z" stroke="#747474" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1,3 @@
<svg width="13" height="16" viewBox="0 0 13 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.66574 13.7747C1.66574 14.2168 1.84635 14.6407 2.16784 14.9533C2.48933 15.2658 2.92537 15.4414 3.38002 15.4414H10.2372C10.6918 15.4414 11.1279 15.2658 11.4493 14.9533C11.7708 14.6407 11.9515 14.2168 11.9515 13.7747V3.77474H1.66574V13.7747ZM3.38002 5.44141H10.2372V13.7747H3.38002V5.44141ZM9.80859 1.27474L8.95145 0.441406H4.66574L3.80859 1.27474H0.808594V2.94141H12.8086V1.27474H9.80859Z" fill="#ffffff"/>
</svg>

After

Width:  |  Height:  |  Size: 520 B

View File

@@ -90,7 +90,7 @@ export default function ContextMenu({
onClick={(e) => e.stopPropagation()}
>
<div
className="flex w-32 flex-col rounded-xl text-sm shadow-xl md:w-36 dark:bg-charleston-green-2 bg-lotion"
className="flex w-32 flex-col rounded-xl bg-lotion text-sm shadow-xl dark:bg-charleston-green-2 md:w-36"
style={{ minWidth: '144px' }}
>
{options.map((option, index) => (
@@ -102,26 +102,22 @@ export default function ContextMenu({
option.onClick(event);
setIsOpen(false);
}}
className={`
flex justify-start items-center gap-4 p-3
transition-colors duration-200 ease-in-out
${index === 0 ? 'rounded-t-xl' : ''}
${index === options.length - 1 ? 'rounded-b-xl' : ''}
${
option.variant === 'danger'
? 'dark:text-red-2000 dark:hover:bg-charcoal-grey text-rosso-corsa hover:bg-bright-gray'
: 'dark:text-bright-gray dark:hover:bg-charcoal-grey text-eerie-black hover:bg-bright-gray'
}
`}
className={`flex items-center justify-start gap-4 p-3 transition-colors duration-200 ease-in-out ${index === 0 ? 'rounded-t-xl' : ''} ${index === options.length - 1 ? 'rounded-b-xl' : ''} ${
option.variant === 'danger'
? 'text-rosso-corsa hover:bg-bright-gray dark:text-red-2000 dark:hover:bg-charcoal-grey'
: 'text-eerie-black hover:bg-bright-gray dark:text-bright-gray dark:hover:bg-charcoal-grey'
} `}
>
{option.icon && (
<img
width={option.iconWidth || 16}
height={option.iconHeight || 16}
src={option.icon}
alt={option.label}
className={`cursor-pointer hover:opacity-75 ${option.iconClassName || ''}`}
/>
<div className="flex w-4 justify-center">
<img
width={option.iconWidth || 16}
height={option.iconHeight || 16}
src={option.icon}
alt={option.label}
className={`cursor-pointer hover:opacity-75 ${option.iconClassName || ''}`}
/>
</div>
)}
<span>{option.label}</span>
</button>

View File

@@ -1,4 +1,5 @@
import React from 'react';
import Arrow2 from '../assets/dropdown-arrow.svg';
import Edit from '../assets/edit.svg';
import Trash from '../assets/trash.svg';
@@ -9,6 +10,10 @@ function Dropdown({
onSelect,
size = 'w-32',
rounded = 'xl',
buttonBackgroundColor = 'white',
buttonDarkBackgroundColor = 'transparent',
optionsBackgroundColor = 'white',
optionsDarkBackgroundColor = 'dark-charcoal',
border = 'border-2',
borderColor = 'silver',
darkBorderColor = 'dim-gray',
@@ -17,6 +22,8 @@ function Dropdown({
showDelete,
onDelete,
placeholder,
placeholderTextColor = 'gray-500',
darkPlaceholderTextColor = 'gray-400',
contentSize = 'text-base',
}: {
options:
@@ -37,6 +44,10 @@ function Dropdown({
| ((value: { value: number; description: string }) => void);
size?: string;
rounded?: 'xl' | '3xl';
buttonBackgroundColor?: string;
buttonDarkBackgroundColor?: string;
optionsBackgroundColor?: string;
optionsDarkBackgroundColor?: string;
border?: 'border' | 'border-2';
borderColor?: string;
darkBorderColor?: string;
@@ -45,6 +56,8 @@ function Dropdown({
showDelete?: boolean;
onDelete?: (value: string) => void;
placeholder?: string;
placeholderTextColor?: string;
darkPlaceholderTextColor?: string;
contentSize?: string;
}) {
const dropdownRef = React.useRef<HTMLDivElement>(null);
@@ -71,7 +84,7 @@ function Dropdown({
<div
className={[
typeof selectedValue === 'string'
? 'relative mt-2'
? 'relative'
: 'relative align-middle',
size,
].join(' ')}
@@ -79,7 +92,7 @@ function Dropdown({
>
<button
onClick={() => setIsOpen(!isOpen)}
className={`flex w-full cursor-pointer items-center justify-between ${border} border-${borderColor} bg-white px-5 py-3 dark:border-${darkBorderColor} dark:bg-transparent ${
className={`flex w-full cursor-pointer items-center justify-between ${border} border-${borderColor} bg-${buttonBackgroundColor} px-5 py-3 dark:border-${darkBorderColor} dark:bg-${buttonDarkBackgroundColor} ${
isOpen ? `${borderTopRadius}` : `${borderRadius}`
}`}
>
@@ -89,8 +102,9 @@ function Dropdown({
</span>
) : (
<span
className={`truncate dark:text-bright-gray ${
!selectedValue && 'text-silver dark:text-gray-400'
className={`truncate ${selectedValue && `dark:text-bright-gray`} ${
!selectedValue &&
`text-${placeholderTextColor} dark:text-${darkPlaceholderTextColor}`
} ${contentSize}`}
>
{selectedValue && 'label' in selectedValue
@@ -116,7 +130,7 @@ function Dropdown({
</button>
{isOpen && (
<div
className={`absolute left-0 right-0 z-20 -mt-1 max-h-40 overflow-y-auto rounded-b-xl ${border} border-${borderColor} bg-white shadow-lg dark:border-${darkBorderColor} dark:bg-dark-charcoal`}
className={`absolute left-0 right-0 z-20 -mt-1 max-h-40 overflow-y-auto rounded-b-xl ${border} border-${borderColor} bg-${optionsBackgroundColor} shadow-lg dark:border-${darkBorderColor} dark:bg-${optionsDarkBackgroundColor}`}
>
{options.map((option: any, index) => (
<div

View File

@@ -13,6 +13,7 @@ const Input = ({
className = '',
colorVariant = 'silver',
borderVariant = 'thick',
textSize = 'medium',
children,
labelBgClassName = 'bg-white dark:bg-raisin-black',
onChange,
@@ -28,6 +29,10 @@ const Input = ({
thin: 'border',
thick: 'border-2',
};
const textSizeStyles = {
small: 'text-sm',
medium: 'text-base',
};
const inputRef = useRef<HTMLInputElement>(null);
@@ -35,15 +40,7 @@ const Input = ({
<div className={`relative ${className}`}>
<input
ref={inputRef}
className={`peer h-[42px] w-full rounded-full px-3 py-1
bg-transparent outline-none
text-jet dark:text-bright-gray
placeholder-transparent
${colorStyles[colorVariant]}
${borderStyles[borderVariant]}
[&:-webkit-autofill]:bg-transparent
[&:-webkit-autofill]:appearance-none
[&:-webkit-autofill_selected]:bg-transparent`}
className={`peer h-[42px] w-full rounded-full bg-transparent px-3 py-1 text-jet placeholder-transparent outline-none dark:text-bright-gray ${colorStyles[colorVariant]} ${borderStyles[borderVariant]} ${textSizeStyles[textSize]} [&:-webkit-autofill]:appearance-none [&:-webkit-autofill]:bg-transparent [&:-webkit-autofill_selected]:bg-transparent`}
type={type}
id={id}
name={name}
@@ -61,15 +58,11 @@ const Input = ({
{placeholder && (
<label
htmlFor={id}
className={`absolute left-3 -top-2.5 px-2 text-xs transition-all
peer-placeholder-shown:top-2.5 peer-placeholder-shown:left-3 peer-placeholder-shown:text-base
peer-placeholder-shown:text-gray-4000 peer-focus:-top-2.5 peer-focus:left-3 peer-focus:text-xs
peer-focus:text-gray-4000 dark:text-silver dark:peer-placeholder-shown:text-gray-400
cursor-none pointer-events-none ${labelBgClassName}`}
className={`absolute -top-2.5 left-3 px-2 ${textSizeStyles[textSize]} transition-all peer-placeholder-shown:left-3 peer-placeholder-shown:top-2.5 peer-placeholder-shown:${textSizeStyles[textSize]} pointer-events-none cursor-none peer-placeholder-shown:text-gray-4000 peer-focus:-top-2.5 peer-focus:left-3 peer-focus:text-xs peer-focus:text-gray-4000 dark:text-silver dark:peer-placeholder-shown:text-gray-400 ${labelBgClassName}`}
>
{placeholder}
{required && (
<span className="text-[#D30000] dark:text-[#D42626] ml-0.5">*</span>
<span className="ml-0.5 text-[#D30000] dark:text-[#D42626]">*</span>
)}
</label>
)}

View File

@@ -1,52 +1,59 @@
import { useEffect, useRef,useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDarkTheme } from '../hooks';
import { useSelector, useDispatch } from 'react-redux';
import userService from '../api/services/userService';
import { useDispatch, useSelector } from 'react-redux';
import endpoints from '../api/endpoints';
import { getOS, isTouchDevice } from '../utils/browserUtils';
import userService from '../api/services/userService';
import AlertIcon from '../assets/alert.svg';
import ClipIcon from '../assets/clip.svg';
import ExitIcon from '../assets/exit.svg';
import PaperPlane from '../assets/paper_plane.svg';
import SourceIcon from '../assets/source.svg';
import ToolIcon from '../assets/tool.svg';
import SpinnerDark from '../assets/spinner-dark.svg';
import Spinner from '../assets/spinner.svg';
import ExitIcon from '../assets/exit.svg';
import AlertIcon from '../assets/alert.svg';
import ToolIcon from '../assets/tool.svg';
import {
addAttachment,
removeAttachment,
selectAttachments,
updateAttachment,
} from '../conversation/conversationSlice';
import { useDarkTheme } from '../hooks';
import { ActiveState } from '../models/misc';
import {
selectSelectedDocs,
selectToken,
} from '../preferences/preferenceSlice';
import Upload from '../upload/Upload';
import { getOS, isTouchDevice } from '../utils/browserUtils';
import SourcesPopup from './SourcesPopup';
import ToolsPopup from './ToolsPopup';
import { selectSelectedDocs, selectToken } from '../preferences/preferenceSlice';
import { ActiveState } from '../models/misc';
import Upload from '../upload/Upload';
import ClipIcon from '../assets/clip.svg';
import {
addAttachment,
updateAttachment,
removeAttachment,
selectAttachments
} from '../conversation/conversationSlice';
interface MessageInputProps {
type MessageInputProps = {
value: string;
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
onSubmit: () => void;
loading: boolean;
}
showSourceButton?: boolean;
showToolButton?: boolean;
};
interface UploadState {
type UploadState = {
taskId: string;
fileName: string;
progress: number;
attachment_id?: string;
token_count?: number;
status: 'uploading' | 'processing' | 'completed' | 'failed';
}
};
export default function MessageInput({
value,
onChange,
onSubmit,
loading,
showSourceButton = true,
showToolButton = true,
}: MessageInputProps) {
const { t } = useTranslation();
const [isDarkTheme] = useDarkTheme();
@@ -55,12 +62,13 @@ export default function MessageInput({
const toolButtonRef = useRef<HTMLButtonElement>(null);
const [isSourcesPopupOpen, setIsSourcesPopupOpen] = useState(false);
const [isToolsPopupOpen, setIsToolsPopupOpen] = useState(false);
const [uploadModalState, setUploadModalState] = useState<ActiveState>('INACTIVE');
const [uploadModalState, setUploadModalState] =
useState<ActiveState>('INACTIVE');
const selectedDocs = useSelector(selectSelectedDocs);
const token = useSelector(selectToken);
const attachments = useSelector(selectAttachments);
const dispatch = useDispatch();
const browserOS = getOS();
@@ -69,7 +77,9 @@ export default function MessageInput({
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
((browserOS === 'win' || browserOS === 'linux') && event.ctrlKey && event.key === 'k') ||
((browserOS === 'win' || browserOS === 'linux') &&
event.ctrlKey &&
event.key === 'k') ||
(browserOS === 'mac' && event.metaKey && event.key === 'k')
) {
event.preventDefault();
@@ -105,10 +115,12 @@ export default function MessageInput({
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
const progress = Math.round((event.loaded / event.total) * 100);
dispatch(updateAttachment({
taskId: newAttachment.taskId,
updates: { progress }
}));
dispatch(
updateAttachment({
taskId: newAttachment.taskId,
updates: { progress },
}),
);
}
});
@@ -116,28 +128,34 @@ export default function MessageInput({
if (xhr.status === 200) {
const response = JSON.parse(xhr.responseText);
if (response.task_id) {
dispatch(updateAttachment({
taskId: newAttachment.taskId,
updates: {
taskId: response.task_id,
status: 'processing',
progress: 10
}
}));
dispatch(
updateAttachment({
taskId: newAttachment.taskId,
updates: {
taskId: response.task_id,
status: 'processing',
progress: 10,
},
}),
);
}
} else {
dispatch(updateAttachment({
taskId: newAttachment.taskId,
updates: { status: 'failed' }
}));
dispatch(
updateAttachment({
taskId: newAttachment.taskId,
updates: { status: 'failed' },
}),
);
}
};
xhr.onerror = () => {
dispatch(updateAttachment({
taskId: newAttachment.taskId,
updates: { status: 'failed' }
}));
dispatch(
updateAttachment({
taskId: newAttachment.taskId,
updates: { status: 'failed' },
}),
);
};
xhr.open('POST', `${apiHost}${endpoints.USER.STORE_ATTACHMENT}`);
@@ -148,48 +166,56 @@ export default function MessageInput({
useEffect(() => {
const checkTaskStatus = () => {
const processingAttachments = attachments.filter(att =>
att.status === 'processing' && att.taskId
const processingAttachments = attachments.filter(
(att) => att.status === 'processing' && att.taskId,
);
processingAttachments.forEach(attachment => {
processingAttachments.forEach((attachment) => {
userService
.getTaskStatus(attachment.taskId!, null)
.then((data) => data.json())
.then((data) => {
if (data.status === 'SUCCESS') {
dispatch(updateAttachment({
taskId: attachment.taskId!,
updates: {
status: 'completed',
progress: 100,
id: data.result?.attachment_id,
token_count: data.result?.token_count
}
}));
dispatch(
updateAttachment({
taskId: attachment.taskId!,
updates: {
status: 'completed',
progress: 100,
id: data.result?.attachment_id,
token_count: data.result?.token_count,
},
}),
);
} else if (data.status === 'FAILURE') {
dispatch(updateAttachment({
taskId: attachment.taskId!,
updates: { status: 'failed' }
}));
dispatch(
updateAttachment({
taskId: attachment.taskId!,
updates: { status: 'failed' },
}),
);
} else if (data.status === 'PROGRESS' && data.result?.current) {
dispatch(updateAttachment({
taskId: attachment.taskId!,
updates: { progress: data.result.current }
}));
dispatch(
updateAttachment({
taskId: attachment.taskId!,
updates: { progress: data.result.current },
}),
);
}
})
.catch(() => {
dispatch(updateAttachment({
taskId: attachment.taskId!,
updates: { status: 'failed' }
}));
dispatch(
updateAttachment({
taskId: attachment.taskId!,
updates: { status: 'failed' },
}),
);
});
});
};
const interval = setInterval(() => {
if (attachments.some(att => att.status === 'processing')) {
if (attachments.some((att) => att.status === 'processing')) {
checkTaskStatus();
}
}, 2000);
@@ -228,27 +254,28 @@ export default function MessageInput({
console.log('Selected document:', doc);
};
const handleSubmit = () => {
onSubmit();
};
return (
<div className="flex flex-col w-full mx-2">
<div className="flex flex-col w-full rounded-[23px] border dark:border-grey border-dark-gray bg-lotion dark:bg-transparent relative">
<div className="flex flex-wrap gap-1.5 sm:gap-2 px-4 sm:px-6 pt-3 pb-0">
<div className="mx-2 flex w-full flex-col">
<div className="relative flex w-full flex-col rounded-[23px] border border-dark-gray bg-lotion dark:border-grey dark:bg-transparent">
<div className="flex flex-wrap gap-1.5 px-4 pb-0 pt-3 sm:gap-2 sm:px-6">
{attachments.map((attachment, index) => (
<div
key={index}
className={`flex items-center px-2 sm:px-3 py-1 sm:py-1.5 rounded-[32px] border border-[#AAAAAA] dark:border-purple-taupe bg-white dark:bg-[#1F2028] text-[12px] sm:text-[14px] text-[#5D5D5D] dark:text-bright-gray group relative ${
className={`group relative flex items-center rounded-[32px] border border-[#AAAAAA] bg-white px-2 py-1 text-[12px] text-[#5D5D5D] dark:border-purple-taupe dark:bg-[#1F2028] dark:text-bright-gray sm:px-3 sm:py-1.5 sm:text-[14px] ${
attachment.status !== 'completed' ? 'opacity-70' : 'opacity-100'
}`}
title={attachment.fileName}
>
<span className="font-medium truncate max-w-[120px] sm:max-w-[150px]">{attachment.fileName}</span>
<span className="max-w-[120px] truncate font-medium sm:max-w-[150px]">
{attachment.fileName}
</span>
{attachment.status === 'completed' && (
<button
className="absolute right-2 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity bg-white dark:bg-[#1F2028] rounded-full p-1 hover:bg-white/95 dark:hover:bg-[#1F2028]/95"
<button
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-full bg-white p-1 opacity-0 transition-opacity hover:bg-white/95 focus:opacity-100 group-hover:opacity-100 dark:bg-[#1F2028] dark:hover:bg-[#1F2028]/95"
onClick={() => {
if (attachment.id) {
dispatch(removeAttachment(attachment.id));
@@ -256,26 +283,27 @@ export default function MessageInput({
}}
aria-label="Remove attachment"
>
<img
src={ExitIcon}
alt="Remove"
className="w-2.5 h-2.5 filter dark:invert"
<img
src={ExitIcon}
alt="Remove"
className="h-2.5 w-2.5 filter dark:invert"
/>
</button>
)}
{attachment.status === 'failed' && (
<img
src={AlertIcon}
alt="Upload failed"
className="ml-2 w-3.5 h-3.5"
<img
src={AlertIcon}
alt="Upload failed"
className="ml-2 h-3.5 w-3.5"
title="Upload failed"
/>
)}
{(attachment.status === 'uploading' || attachment.status === 'processing') && (
<div className="ml-2 w-4 h-4 relative">
<svg className="w-4 h-4" viewBox="0 0 24 24">
{(attachment.status === 'uploading' ||
attachment.status === 'processing') && (
<div className="relative ml-2 h-4 w-4">
<svg className="h-4 w-4" viewBox="0 0 24 24">
{/* Background circle */}
<circle
className="text-gray-200 dark:text-gray-700"
@@ -316,47 +344,67 @@ export default function MessageInput({
onChange={onChange}
tabIndex={1}
placeholder={t('inputPlaceholder')}
className="inputbox-style w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap rounded-t-[23px] bg-lotion dark:bg-transparent py-3 sm:py-5 text-base leading-tight opacity-100 focus:outline-none dark:text-bright-gray dark:placeholder-bright-gray dark:placeholder-opacity-50 px-4 sm:px-6 no-scrollbar"
className="inputbox-style no-scrollbar w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap rounded-t-[23px] bg-lotion px-4 py-3 text-base leading-tight opacity-100 focus:outline-none dark:bg-transparent dark:text-bright-gray dark:placeholder-bright-gray dark:placeholder-opacity-50 sm:px-6 sm:py-5"
onInput={handleInput}
onKeyDown={handleKeyDown}
aria-label={t('inputPlaceholder')}
/>
</div>
<div className="flex items-center px-3 sm:px-4 py-1.5 sm:py-2">
<div className="flex-grow flex flex-wrap gap-1 sm:gap-2">
<button
ref={sourceButtonRef}
className="flex items-center px-2 xs:px-3 py-1 xs:py-1.5 rounded-[32px] border border-[#AAAAAA] dark:border-purple-taupe hover:bg-gray-100 dark:hover:bg-[#2C2E3C] transition-colors max-w-[130px] sm:max-w-[150px]"
onClick={() => setIsSourcesPopupOpen(!isSourcesPopupOpen)}
title={selectedDocs ? selectedDocs.name : t('conversation.sources.title')}
>
<img src={SourceIcon} alt="Sources" className="w-3.5 h-3.5 sm:h-4 mr-1 sm:mr-1.5 flex-shrink-0" />
<span className="text-[10px] xs:text-[12px] sm:text-[14px] text-[#5D5D5D] dark:text-bright-gray font-medium truncate overflow-hidden">
{selectedDocs
? selectedDocs.name
: t('conversation.sources.title')}
</span>
{!isTouch && (
<span className="hidden sm:inline-block ml-1 text-[10px] text-gray-500 dark:text-gray-400">
{browserOS === 'mac' ? '(⌘K)' : '(ctrl+K)'}
<div className="flex items-center px-3 py-1.5 sm:px-4 sm:py-2">
<div className="flex flex-grow flex-wrap gap-1 sm:gap-2">
{showSourceButton && (
<button
ref={sourceButtonRef}
className="xs:px-3 xs:py-1.5 flex max-w-[130px] items-center rounded-[32px] border border-[#AAAAAA] px-2 py-1 transition-colors hover:bg-gray-100 dark:border-purple-taupe dark:hover:bg-[#2C2E3C] sm:max-w-[150px]"
onClick={() => setIsSourcesPopupOpen(!isSourcesPopupOpen)}
title={
selectedDocs
? selectedDocs.name
: t('conversation.sources.title')
}
>
<img
src={SourceIcon}
alt="Sources"
className="mr-1 h-3.5 w-3.5 flex-shrink-0 sm:mr-1.5 sm:h-4"
/>
<span className="xs:text-[12px] overflow-hidden truncate text-[10px] font-medium text-[#5D5D5D] dark:text-bright-gray sm:text-[14px]">
{selectedDocs
? selectedDocs.name
: t('conversation.sources.title')}
</span>
)}
</button>
{!isTouch && (
<span className="ml-1 hidden text-[10px] text-gray-500 dark:text-gray-400 sm:inline-block">
{browserOS === 'mac' ? '(⌘K)' : '(ctrl+K)'}
</span>
)}
</button>
)}
<button
ref={toolButtonRef}
className="flex items-center px-2 xs:px-3 py-1 xs:py-1.5 rounded-[32px] border border-[#AAAAAA] dark:border-purple-taupe hover:bg-gray-100 dark:hover:bg-[#2C2E3C] transition-colors max-w-[130px] xs:max-w-[150px]"
onClick={() => setIsToolsPopupOpen(!isToolsPopupOpen)}
>
<img src={ToolIcon} alt="Tools" className="w-3.5 sm:w-4 h-3.5 sm:h-4 mr-1 sm:mr-1.5 flex-shrink-0" />
<span className="text-[10px] xs:text-[12px] sm:text-[14px] text-[#5D5D5D] dark:text-bright-gray font-medium truncate overflow-hidden">
{t('settings.tools.label')}
</span>
</button>
<label className="flex items-center px-2 xs:px-3 py-1 xs:py-1.5 rounded-[32px] border border-[#AAAAAA] dark:border-purple-taupe hover:bg-gray-100 dark:hover:bg-[#2C2E3C] transition-colors cursor-pointer">
<img src={ClipIcon} alt="Attach" className="w-3.5 sm:w-4 h-3.5 sm:h-4 mr-1 sm:mr-1.5" />
<span className="text-[10px] xs:text-[12px] sm:text-[14px] text-[#5D5D5D] dark:text-bright-gray font-medium">
{showToolButton && (
<button
ref={toolButtonRef}
className="xs:px-3 xs:py-1.5 xs:max-w-[150px] flex max-w-[130px] items-center rounded-[32px] border border-[#AAAAAA] px-2 py-1 transition-colors hover:bg-gray-100 dark:border-purple-taupe dark:hover:bg-[#2C2E3C]"
onClick={() => setIsToolsPopupOpen(!isToolsPopupOpen)}
>
<img
src={ToolIcon}
alt="Tools"
className="mr-1 h-3.5 w-3.5 flex-shrink-0 sm:mr-1.5 sm:h-4 sm:w-4"
/>
<span className="xs:text-[12px] overflow-hidden truncate text-[10px] font-medium text-[#5D5D5D] dark:text-bright-gray sm:text-[14px]">
{t('settings.tools.label')}
</span>
</button>
)}
<label className="xs:px-3 xs:py-1.5 flex cursor-pointer items-center rounded-[32px] border border-[#AAAAAA] px-2 py-1 transition-colors hover:bg-gray-100 dark:border-purple-taupe dark:hover:bg-[#2C2E3C]">
<img
src={ClipIcon}
alt="Attach"
className="mr-1 h-3.5 w-3.5 sm:mr-1.5 sm:h-4 sm:w-4"
/>
<span className="xs:text-[12px] text-[10px] font-medium text-[#5D5D5D] dark:text-bright-gray sm:text-[14px]">
Attach
</span>
<input
@@ -372,18 +420,18 @@ export default function MessageInput({
<button
onClick={loading ? undefined : handleSubmit}
aria-label={loading ? t('loading') : t('send')}
className={`flex items-center justify-center p-2 sm:p-2.5 rounded-full ${loading ? 'bg-gray-300 dark:bg-gray-600' : 'bg-black dark:bg-white'} ml-auto flex-shrink-0`}
className={`flex items-center justify-center rounded-full p-2 sm:p-2.5 ${loading ? 'bg-gray-300 dark:bg-gray-600' : 'bg-black dark:bg-white'} ml-auto flex-shrink-0`}
disabled={loading}
>
{loading ? (
<img
src={isDarkTheme ? SpinnerDark : Spinner}
className="w-3.5 sm:w-4 h-3.5 sm:h-4 animate-spin"
className="h-3.5 w-3.5 animate-spin sm:h-4 sm:w-4"
alt={t('loading')}
/>
) : (
<img
className={`w-3.5 sm:w-4 h-3.5 sm:h-4 ${isDarkTheme ? 'filter invert' : ''}`}
className={`h-3.5 w-3.5 sm:h-4 sm:w-4 ${isDarkTheme ? 'invert filter' : ''}`}
src={PaperPlane}
alt={t('send')}
/>

View File

@@ -0,0 +1,278 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import CheckmarkIcon from '../assets/checkmark.svg';
import NoFilesDarkIcon from '../assets/no-files-dark.svg';
import NoFilesIcon from '../assets/no-files.svg';
import { useDarkTheme } from '../hooks';
import Input from './Input';
export type OptionType = {
id: string | number;
label: string;
icon?: string | React.ReactNode;
[key: string]: any;
};
type MultiSelectPopupProps = {
isOpen: boolean;
onClose: () => void;
anchorRef: React.RefObject<HTMLElement>;
options: OptionType[];
selectedIds: Set<string | number>;
onSelectionChange: (newSelectedIds: Set<string | number>) => void;
title?: string;
searchPlaceholder?: string;
noOptionsMessage?: string;
loading?: boolean;
footerContent?: React.ReactNode;
showSearch?: boolean;
singleSelect?: boolean;
};
export default function MultiSelectPopup({
isOpen,
onClose,
anchorRef,
options,
selectedIds,
onSelectionChange,
title,
searchPlaceholder,
noOptionsMessage,
loading = false,
footerContent,
showSearch = true,
singleSelect = false,
}: MultiSelectPopupProps) {
const { t } = useTranslation();
const [isDarkTheme] = useDarkTheme();
const popupRef = useRef<HTMLDivElement>(null);
const [searchTerm, setSearchTerm] = useState('');
const [popupPosition, setPopupPosition] = useState({
top: 0,
left: 0,
maxHeight: 0,
showAbove: false,
});
const filteredOptions = options.filter((option) =>
option.label.toLowerCase().includes(searchTerm.toLowerCase()),
);
const handleOptionClick = (optionId: string | number) => {
let newSelectedIds: Set<string | number>;
if (singleSelect) newSelectedIds = new Set<string | number>();
else newSelectedIds = new Set(selectedIds);
if (newSelectedIds.has(optionId)) {
newSelectedIds.delete(optionId);
} else newSelectedIds.add(optionId);
onSelectionChange(newSelectedIds);
};
const renderIcon = (icon: string | React.ReactNode) => {
if (typeof icon === 'string') {
if (icon.startsWith('/') || icon.startsWith('http')) {
return (
<img
src={icon}
alt=""
className="mr-3 h-5 w-5 flex-shrink-0"
aria-hidden="true"
/>
);
}
return (
<span className="mr-3 h-5 w-5 flex-shrink-0" aria-hidden="true">
{icon}
</span>
);
}
return <span className="mr-3 flex-shrink-0">{icon}</span>;
};
useLayoutEffect(() => {
if (!isOpen || !anchorRef.current) return;
const updatePosition = () => {
if (!anchorRef.current) return;
const rect = anchorRef.current.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
const popupPadding = 16;
const popupMinWidth = 300;
const popupMaxWidth = 462;
const popupDefaultHeight = 300;
const spaceAbove = rect.top;
const spaceBelow = viewportHeight - rect.bottom;
const showAbove =
spaceBelow < popupDefaultHeight && spaceAbove >= popupDefaultHeight;
const maxHeight = Math.max(
150,
showAbove ? spaceAbove - popupPadding : spaceBelow - popupPadding,
);
const availableWidth = viewportWidth - 20;
const calculatedWidth = Math.min(popupMaxWidth, availableWidth);
let left = rect.left;
if (left + calculatedWidth > viewportWidth - 10) {
left = viewportWidth - calculatedWidth - 10;
}
left = Math.max(10, left);
setPopupPosition({
top: showAbove ? rect.top - 8 : rect.bottom + 8,
left: left,
maxHeight: Math.min(600, maxHeight),
showAbove,
});
};
updatePosition();
window.addEventListener('resize', updatePosition);
window.addEventListener('scroll', updatePosition, true);
return () => {
window.removeEventListener('resize', updatePosition);
window.removeEventListener('scroll', updatePosition, true);
};
}, [isOpen, anchorRef]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
popupRef.current &&
!popupRef.current.contains(event.target as Node) &&
anchorRef.current &&
!anchorRef.current.contains(event.target as Node)
)
onClose();
};
if (isOpen) document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [onClose, anchorRef, isOpen]);
useEffect(() => {
if (!isOpen) setSearchTerm('');
}, [isOpen]);
if (!isOpen) return null;
return (
<div
ref={popupRef}
className="fixed z-[9999] flex flex-col rounded-lg border border-light-silver bg-lotion shadow-[0px_9px_46px_8px_#0000001F,0px_24px_38px_3px_#00000024,0px_11px_15px_-7px_#00000033] dark:border-dim-gray dark:bg-charleston-green-2"
style={{
top: popupPosition.showAbove ? undefined : popupPosition.top,
bottom: popupPosition.showAbove
? window.innerHeight - popupPosition.top + 8
: undefined,
left: popupPosition.left,
maxWidth: `${Math.min(462, window.innerWidth - 20)}px`,
width: '100%',
maxHeight: `${popupPosition.maxHeight}px`,
}}
>
{(title || showSearch) && (
<div className="flex-shrink-0 p-4">
{title && (
<h3 className="mb-4 text-lg font-medium text-gray-900 dark:text-white">
{title}
</h3>
)}
{showSearch && (
<Input
id="multi-select-search"
name="multi-select-search"
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder={
searchPlaceholder ||
t('settings.tools.searchPlaceholder', 'Search...')
}
labelBgClassName="bg-lotion dark:bg-charleston-green-2"
borderVariant="thin"
className="mb-4"
textSize="small"
/>
)}
</div>
)}
<div className="mx-4 mb-4 flex-grow overflow-auto rounded-md border border-[#D9D9D9] dark:border-dim-gray">
{loading ? (
<div className="flex h-full items-center justify-center py-4">
<div className="h-6 w-6 animate-spin rounded-full border-b-2 border-gray-900 dark:border-white"></div>
</div>
) : (
<div className="h-full overflow-y-auto [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-400 dark:[&::-webkit-scrollbar-thumb]:bg-gray-600 [&::-webkit-scrollbar-track]:bg-gray-200 dark:[&::-webkit-scrollbar-track]:bg-[#2C2E3C] [&::-webkit-scrollbar]:w-2">
{filteredOptions.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center px-4 py-8 text-center">
<img
src={isDarkTheme ? NoFilesDarkIcon : NoFilesIcon}
alt="No options found"
className="mx-auto mb-3 h-16 w-16"
/>
<p className="text-sm text-gray-500 dark:text-gray-400">
{searchTerm
? 'No results found'
: noOptionsMessage || 'No options available'}
</p>
</div>
) : (
filteredOptions.map((option) => {
const isSelected = selectedIds.has(option.id);
return (
<div
key={option.id}
onClick={() => handleOptionClick(option.id)}
className="flex cursor-pointer items-center justify-between border-b border-[#D9D9D9] p-3 last:border-b-0 hover:bg-gray-100 dark:border-dim-gray dark:hover:bg-charleston-green-3"
role="option"
aria-selected={isSelected}
>
<div className="mr-3 flex flex-grow items-center overflow-hidden">
{option.icon && renderIcon(option.icon)}
<p
className="overflow-hidden overflow-ellipsis whitespace-nowrap text-sm font-medium text-gray-900 dark:text-white"
title={option.label}
>
{option.label}
</p>
</div>
<div className="flex-shrink-0">
<div
className={`flex h-4 w-4 items-center justify-center rounded-sm border border-[#C6C6C6] bg-white dark:border-[#757783] dark:bg-charleston-green-2`}
aria-hidden="true"
>
{isSelected && (
<img
src={CheckmarkIcon}
alt="checkmark"
width={10}
height={10}
/>
)}
</div>
</div>
</div>
);
})
)}
</div>
)}
</div>
{footerContent && (
<div className="flex-shrink-0 border-t border-light-silver p-4 dark:border-dim-gray">
{footerContent}
</div>
)}
</div>
);
}

View File

@@ -1,7 +1,8 @@
import React, { useCallback, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import ArrowLeft from '../assets/arrow-left.svg';
import ArrowRight from '../assets/arrow-right.svg';
import { useTranslation } from 'react-i18next';
type HiddenGradientType = 'left' | 'right' | undefined;
@@ -10,7 +11,6 @@ const useTabs = () => {
const tabs = [
t('settings.general.label'),
t('settings.documents.label'),
t('settings.apiKeys.label'),
t('settings.analytics.label'),
t('settings.logs.label'),
t('settings.tools.label'),
@@ -48,18 +48,18 @@ const SettingsBar = ({ setActiveTab, activeTab }: SettingsBarProps) => {
[containerRef.current],
);
return (
<div className="relative mt-6 flex flex-row items-center space-x-1 md:space-x-0 overflow-auto">
<div className="relative mt-6 flex flex-row items-center space-x-1 overflow-auto md:space-x-0">
<div
className={`${hiddenGradient === 'left' ? 'hidden' : ''} md:hidden absolute inset-y-0 left-6 w-14 bg-gradient-to-r from-white dark:from-raisin-black pointer-events-none`}
className={`${hiddenGradient === 'left' ? 'hidden' : ''} pointer-events-none absolute inset-y-0 left-6 w-14 bg-gradient-to-r from-white dark:from-raisin-black md:hidden`}
></div>
<div
className={`${hiddenGradient === 'right' ? 'hidden' : ''} md:hidden absolute inset-y-0 right-6 w-14 bg-gradient-to-l from-white dark:from-raisin-black pointer-events-none`}
className={`${hiddenGradient === 'right' ? 'hidden' : ''} pointer-events-none absolute inset-y-0 right-6 w-14 bg-gradient-to-l from-white dark:from-raisin-black md:hidden`}
></div>
<div className="md:hidden z-10">
<div className="z-10 md:hidden">
<button
onClick={() => scrollTabs(-1)}
className="flex h-6 w-6 items-center rounded-full justify-center transition-all hover:bg-gray-200 dark:hover:bg-gray-700"
className="flex h-6 w-6 items-center justify-center rounded-full transition-all hover:bg-gray-200 dark:hover:bg-gray-700"
aria-label="Scroll tabs left"
>
<img src={ArrowLeft} alt="left-arrow" className="h-3" />
@@ -67,7 +67,7 @@ const SettingsBar = ({ setActiveTab, activeTab }: SettingsBarProps) => {
</div>
<div
ref={containerRef}
className="flex flex-nowrap overflow-x-auto no-scrollbar md:space-x-4 scroll-smooth snap-x"
className="no-scrollbar flex snap-x flex-nowrap overflow-x-auto scroll-smooth md:space-x-4"
role="tablist"
aria-label="Settings tabs"
>
@@ -75,7 +75,7 @@ const SettingsBar = ({ setActiveTab, activeTab }: SettingsBarProps) => {
<button
key={index}
onClick={() => setActiveTab(tab)}
className={`snap-start h-9 rounded-3xl px-4 font-bold transition-colors ${
className={`h-9 snap-start rounded-3xl px-4 font-bold transition-colors ${
activeTab === tab
? 'bg-[#F4F4F5] text-neutral-900 dark:bg-dark-charcoal dark:text-white'
: 'text-neutral-700 hover:text-neutral-900 dark:text-neutral-300 dark:hover:text-white'
@@ -89,10 +89,10 @@ const SettingsBar = ({ setActiveTab, activeTab }: SettingsBarProps) => {
</button>
))}
</div>
<div className="md:hidden z-10">
<div className="z-10 md:hidden">
<button
onClick={() => scrollTabs(1)}
className="flex h-6 w-6 rounded-full items-center justify-center hover:bg-gray-200 dark:hover:bg-gray-700"
className="flex h-6 w-6 items-center justify-center rounded-full hover:bg-gray-200 dark:hover:bg-gray-700"
aria-label="Scroll tabs right"
>
<img src={ArrowRight} alt="right-arrow" className="h-3" />

View File

@@ -3,6 +3,7 @@ export type InputProps = {
value: string | string[] | number;
colorVariant?: 'silver' | 'jet' | 'gray';
borderVariant?: 'thin' | 'thick';
textSize?: 'small' | 'medium';
isAutoFocused?: boolean;
id?: string;
maxLength?: number;

View File

@@ -1,18 +1,24 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useDropzone } from 'react-dropzone';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { useDropzone } from 'react-dropzone';
import DragFileUpload from '../assets/DragFileUpload.svg';
import newChatIcon from '../assets/openNewChat.svg';
import ShareIcon from '../assets/share.svg';
import MessageInput from '../components/MessageInput';
import { useMediaQuery } from '../hooks';
import { ShareConversationModal } from '../modals/ShareConversationModal';
import { ActiveState } from '../models/misc';
import {
selectConversationId,
selectSelectedAgent,
selectToken,
} from '../preferences/preferenceSlice';
import { AppDispatch } from '../store';
import Upload from '../upload/Upload';
import { handleSendFeedback } from './conversationHandlers';
import ConversationMessages from './ConversationMessages';
import { FEEDBACK, Query } from './conversationModels';
import {
addQuery,
@@ -24,28 +30,29 @@ import {
updateConversationId,
updateQuery,
} from './conversationSlice';
import Upload from '../upload/Upload';
import { ActiveState } from '../models/misc';
import ConversationMessages from './ConversationMessages';
import MessageInput from '../components/MessageInput';
export default function Conversation() {
const { t } = useTranslation();
const { isMobile } = useMediaQuery();
const dispatch = useDispatch<AppDispatch>();
const token = useSelector(selectToken);
const queries = useSelector(selectQueries);
const status = useSelector(selectStatus);
const conversationId = useSelector(selectConversationId);
const dispatch = useDispatch<AppDispatch>();
const [input, setInput] = useState('');
const fetchStream = useRef<any>(null);
const [lastQueryReturnedErr, setLastQueryReturnedErr] = useState(false);
const [isShareModalOpen, setShareModalState] = useState<boolean>(false);
const { t } = useTranslation();
const { isMobile } = useMediaQuery();
const selectedAgent = useSelector(selectSelectedAgent);
const [input, setInput] = useState<string>('');
const [uploadModalState, setUploadModalState] =
useState<ActiveState>('INACTIVE');
const [files, setFiles] = useState<File[]>([]);
const [lastQueryReturnedErr, setLastQueryReturnedErr] =
useState<boolean>(false);
const [isShareModalOpen, setShareModalState] = useState<boolean>(false);
const [handleDragActive, setHandleDragActive] = useState<boolean>(false);
const fetchStream = useRef<any>(null);
const onDrop = useCallback((acceptedFiles: File[]) => {
setUploadModalState('ACTIVE');
setFiles(acceptedFiles);
@@ -83,35 +90,36 @@ export default function Conversation() {
},
});
useEffect(() => {
if (queries.length) {
queries[queries.length - 1].error && setLastQueryReturnedErr(true);
queries[queries.length - 1].response && setLastQueryReturnedErr(false); //considering a query that initially returned error can later include a response property on retry
}
}, [queries[queries.length - 1]]);
const handleFetchAnswer = useCallback(
({ question, index }: { question: string; index?: number }) => {
fetchStream.current = dispatch(fetchAnswer({ question, indx: index }));
},
[dispatch, selectedAgent],
);
const handleQuestion = ({
question,
isRetry = false,
updated = null,
indx = undefined,
}: {
question: string;
isRetry?: boolean;
updated?: boolean | null;
indx?: number;
}) => {
if (updated === true) {
!isRetry &&
dispatch(resendQuery({ index: indx as number, prompt: question }));
fetchStream.current = dispatch(fetchAnswer({ question, indx }));
} else {
question = question.trim();
if (question === '') return;
!isRetry && dispatch(addQuery({ prompt: question }));
fetchStream.current = dispatch(fetchAnswer({ question }));
}
};
const handleQuestion = useCallback(
({
question,
isRetry = false,
index = undefined,
}: {
question: string;
isRetry?: boolean;
index?: number;
}) => {
const trimmedQuestion = question.trim();
if (trimmedQuestion === '') return;
if (index !== undefined) {
if (!isRetry) dispatch(resendQuery({ index, prompt: trimmedQuestion }));
handleFetchAnswer({ question: trimmedQuestion, index });
} else {
if (!isRetry) dispatch(addQuery({ prompt: trimmedQuestion }));
handleFetchAnswer({ question: trimmedQuestion, index });
}
},
[dispatch, handleFetchAnswer],
);
const handleFeedback = (query: Query, feedback: FEEDBACK, index: number) => {
const prevFeedback = query.feedback;
@@ -143,10 +151,9 @@ export default function Conversation() {
indx?: number,
) => {
if (updated === true) {
handleQuestion({ question: updatedQuestion as string, updated, indx });
handleQuestion({ question: updatedQuestion as string, index: indx });
} else if (input && status !== 'loading') {
if (lastQueryReturnedErr) {
// update last failed query with new prompt
dispatch(
updateQuery({
index: queries.length - 1,
@@ -160,13 +167,14 @@ export default function Conversation() {
isRetry: true,
});
} else {
handleQuestion({
handleQuestion({
question: input,
});
}
setInput('');
}
};
const resetConversation = () => {
dispatch(setConversation([]));
dispatch(
@@ -175,22 +183,29 @@ export default function Conversation() {
}),
);
};
const newChat = () => {
if (queries && queries.length > 0) resetConversation();
};
useEffect(() => {
if (queries.length) {
queries[queries.length - 1].error && setLastQueryReturnedErr(true);
queries[queries.length - 1].response && setLastQueryReturnedErr(false);
}
}, [queries[queries.length - 1]]);
return (
<div className="flex flex-col gap-1 h-full justify-end">
<div className="flex h-full flex-col justify-end gap-1">
{conversationId && queries.length > 0 && (
<div className="absolute top-4 right-20">
<div className="flex mt-2 items-center gap-4">
<div className="absolute right-20 top-4">
<div className="mt-2 flex items-center gap-4">
{isMobile && queries.length > 0 && (
<button
title="Open New Chat"
onClick={() => {
newChat();
}}
className="hover:bg-bright-gray dark:hover:bg-[#28292E] rounded-full p-2"
className="rounded-full p-2 hover:bg-bright-gray dark:hover:bg-[#28292E]"
>
<img
className="h-5 w-5 filter dark:invert"
@@ -205,7 +220,7 @@ export default function Conversation() {
onClick={() => {
setShareModalState(true);
}}
className="hover:bg-bright-gray dark:hover:bg-[#28292E] rounded-full p-2"
className="rounded-full p-2 hover:bg-bright-gray dark:hover:bg-[#28292E]"
>
<img
className="h-5 w-5 filter dark:invert"
@@ -233,7 +248,7 @@ export default function Conversation() {
status={status}
/>
<div className="flex flex-col items-end self-center rounded-2xl bg-opacity-0 z-3 w-full md:w-9/12 lg:w-8/12 xl:w-8/12 2xl:w-6/12 max-w-[1300px] h-auto py-1">
<div className="z-3 flex h-auto w-full max-w-[1300px] flex-col items-end self-center rounded-2xl bg-opacity-0 py-1 md:w-9/12 lg:w-8/12 xl:w-8/12 2xl:w-6/12">
<div
{...getRootProps()}
className="flex w-full items-center rounded-[40px]"
@@ -247,20 +262,22 @@ export default function Conversation() {
onChange={(e) => setInput(e.target.value)}
onSubmit={handleQuestionSubmission}
loading={status === 'loading'}
showSourceButton={selectedAgent ? false : true}
showToolButton={selectedAgent ? false : true}
/>
</div>
<p className="text-gray-4000 hidden w-[100vw] self-center bg-transparent py-2 text-center text-xs dark:text-sonic-silver md:inline md:w-full">
<p className="hidden w-[100vw] self-center bg-transparent py-2 text-center text-xs text-gray-4000 dark:text-sonic-silver md:inline md:w-full">
{t('tagline')}
</p>
</div>
{handleDragActive && (
<div className="pointer-events-none fixed top-0 left-0 z-30 flex flex-col size-full items-center justify-center bg-opacity-50 bg-white dark:bg-gray-alpha">
<div className="pointer-events-none fixed left-0 top-0 z-30 flex size-full flex-col items-center justify-center bg-white bg-opacity-50 dark:bg-gray-alpha">
<img className="filter dark:invert" src={DragFileUpload} />
<span className="px-2 text-2xl font-bold text-outer-space dark:text-silver">
{t('modals.uploadDoc.drag.title')}
</span>
<span className="p-2 text-s w-48 text-center text-outer-space dark:text-silver">
<span className="text-s w-48 p-2 text-center text-outer-space dark:text-silver">
{t('modals.uploadDoc.drag.description')}
</span>
</div>

View File

@@ -23,6 +23,7 @@ interface ConversationMessagesProps {
handleFeedback?: (query: Query, feedback: FEEDBACK, index: number) => void;
queries: Query[];
status: Status;
showHeroOnEmpty?: boolean;
}
export default function ConversationMessages({
@@ -31,6 +32,7 @@ export default function ConversationMessages({
queries,
status,
handleFeedback,
showHeroOnEmpty = true,
}: ConversationMessagesProps) {
const [isDarkTheme] = useDarkTheme();
const { t } = useTranslation();
@@ -141,7 +143,7 @@ export default function ConversationMessages({
ref={conversationRef}
onWheel={handleUserInterruption}
onTouchMove={handleUserInterruption}
className="flex justify-center w-full overflow-y-auto h-screen sm:pt-12"
className="flex justify-center w-full overflow-y-auto h-full sm:pt-12"
>
{queries.length > 0 && !hasScrolledToLast && (
<button
@@ -173,9 +175,9 @@ export default function ConversationMessages({
{prepResponseView(query, index)}
</Fragment>
))
) : (
) : showHeroOnEmpty ? (
<Hero handleQuestion={handleQuestion} />
)}
) : null}
</div>
</div>
);

View File

@@ -13,7 +13,9 @@ export function handleFetchAnswer(
promptId: string | null,
chunks: string,
token_limit: number,
agentId?: string,
attachments?: string[],
save_conversation: boolean = true,
): Promise<
| {
result: any;
@@ -50,13 +52,15 @@ export function handleFetchAnswer(
chunks: chunks,
token_limit: token_limit,
isNoneDoc: selectedDocs === null,
agent_id: agentId,
save_conversation: save_conversation,
};
// Add attachments to payload if they exist
if (attachments && attachments.length > 0) {
payload.attachments = attachments;
}
if (selectedDocs && 'id' in selectedDocs) {
payload.active_docs = selectedDocs.id as string;
}
@@ -97,7 +101,9 @@ export function handleFetchAnswerSteaming(
token_limit: number,
onEvent: (event: MessageEvent) => void,
indx?: number,
agentId?: string,
attachments?: string[],
save_conversation: boolean = true,
): Promise<Answer> {
history = history.map((item) => {
return {
@@ -116,13 +122,15 @@ export function handleFetchAnswerSteaming(
token_limit: token_limit,
isNoneDoc: selectedDocs === null,
index: indx,
agent_id: agentId,
save_conversation: save_conversation,
};
// Add attachments to payload if they exist
if (attachments && attachments.length > 0) {
payload.attachments = attachments;
}
if (selectedDocs && 'id' in selectedDocs) {
payload.active_docs = selectedDocs.id as string;
}

View File

@@ -61,5 +61,7 @@ export interface RetrievalPayload {
token_limit: number;
isNoneDoc: boolean;
index?: number;
agent_id?: string;
attachments?: string[];
save_conversation?: boolean;
}

View File

@@ -7,7 +7,13 @@ import {
handleFetchAnswer,
handleFetchAnswerSteaming,
} from './conversationHandlers';
import { Answer, Query, Status, ConversationState, Attachment } from './conversationModels';
import {
Answer,
Query,
Status,
ConversationState,
Attachment,
} from './conversationModels';
const initialState: ConversationState = {
queries: [],
@@ -28,37 +34,159 @@ export function handleAbort() {
export const fetchAnswer = createAsyncThunk<
Answer,
{ question: string; indx?: number }
>('fetchAnswer', async ({ question, indx }, { dispatch, getState }) => {
if (abortController) {
abortController.abort();
}
abortController = new AbortController();
const { signal } = abortController;
{ question: string; indx?: number; isPreview?: boolean }
>(
'fetchAnswer',
async ({ question, indx, isPreview = false }, { dispatch, getState }) => {
if (abortController) abortController.abort();
abortController = new AbortController();
const { signal } = abortController;
let isSourceUpdated = false;
const state = getState() as RootState;
const attachmentIds = state.conversation.attachments
.filter(a => a.id && a.status === 'completed')
.map(a => a.id) as string[];
if (state.preference) {
if (API_STREAMING) {
await handleFetchAnswerSteaming(
question,
signal,
state.preference.token,
state.preference.selectedDocs!,
state.conversation.queries,
state.conversation.conversationId,
state.preference.prompt.id,
state.preference.chunks,
state.preference.token_limit,
(event) => {
const data = JSON.parse(event.data);
let isSourceUpdated = false;
const state = getState() as RootState;
const attachmentIds = state.conversation.attachments
.filter((a) => a.id && a.status === 'completed')
.map((a) => a.id) as string[];
const currentConversationId = state.conversation.conversationId;
const conversationIdToSend = isPreview ? null : currentConversationId;
const save_conversation = isPreview ? false : true;
if (data.type === 'end') {
dispatch(conversationSlice.actions.setStatus('idle'));
if (state.preference) {
if (API_STREAMING) {
await handleFetchAnswerSteaming(
question,
signal,
state.preference.token,
state.preference.selectedDocs!,
state.conversation.queries,
conversationIdToSend,
state.preference.prompt.id,
state.preference.chunks,
state.preference.token_limit,
(event) => {
const data = JSON.parse(event.data);
const targetIndex = indx ?? state.conversation.queries.length - 1;
if (data.type === 'end') {
dispatch(conversationSlice.actions.setStatus('idle'));
if (!isPreview) {
getConversations(state.preference.token)
.then((fetchedConversations) => {
dispatch(setConversations(fetchedConversations));
})
.catch((error) => {
console.error('Failed to fetch conversations: ', error);
});
}
if (!isSourceUpdated) {
dispatch(
updateStreamingSource({
index: targetIndex,
query: { sources: [] },
}),
);
}
} else if (data.type === 'id') {
if (!isPreview) {
dispatch(
updateConversationId({
query: { conversationId: data.id },
}),
);
}
} else if (data.type === 'thought') {
const result = data.thought;
dispatch(
updateThought({
index: targetIndex,
query: { thought: result },
}),
);
} else if (data.type === 'source') {
isSourceUpdated = true;
dispatch(
updateStreamingSource({
index: targetIndex,
query: { sources: data.source ?? [] },
}),
);
} else if (data.type === 'tool_calls') {
dispatch(
updateToolCalls({
index: targetIndex,
query: { tool_calls: data.tool_calls },
}),
);
} else if (data.type === 'error') {
// set status to 'failed'
dispatch(conversationSlice.actions.setStatus('failed'));
dispatch(
conversationSlice.actions.raiseError({
index: targetIndex,
message: data.error,
}),
);
} else {
dispatch(
updateStreamingQuery({
index: targetIndex,
query: { response: data.answer },
}),
);
}
},
indx,
state.preference.selectedAgent?.id,
attachmentIds,
save_conversation,
);
} else {
const answer = await handleFetchAnswer(
question,
signal,
state.preference.token,
state.preference.selectedDocs!,
state.conversation.queries,
state.conversation.conversationId,
state.preference.prompt.id,
state.preference.chunks,
state.preference.token_limit,
state.preference.selectedAgent?.id,
attachmentIds,
save_conversation,
);
if (answer) {
let sourcesPrepped = [];
sourcesPrepped = answer.sources.map((source: { title: string }) => {
if (source && source.title) {
const titleParts = source.title.split('/');
return {
...source,
title: titleParts[titleParts.length - 1],
};
}
return source;
});
const targetIndex = indx ?? state.conversation.queries.length - 1;
dispatch(
updateQuery({
index: targetIndex,
query: {
response: answer.answer,
thought: answer.thought,
sources: sourcesPrepped,
tool_calls: answer.toolCalls,
},
}),
);
if (!isPreview) {
dispatch(
updateConversationId({
query: { conversationId: answer.conversationId },
}),
);
getConversations(state.preference.token)
.then((fetchedConversations) => {
dispatch(setConversations(fetchedConversations));
@@ -66,130 +194,23 @@ export const fetchAnswer = createAsyncThunk<
.catch((error) => {
console.error('Failed to fetch conversations: ', error);
});
if (!isSourceUpdated) {
dispatch(
updateStreamingSource({
index: indx ?? state.conversation.queries.length - 1,
query: { sources: [] },
}),
);
}
} else if (data.type === 'id') {
dispatch(
updateConversationId({
query: { conversationId: data.id },
}),
);
} else if (data.type === 'thought') {
const result = data.thought;
console.log('thought', result);
dispatch(
updateThought({
index: indx ?? state.conversation.queries.length - 1,
query: { thought: result },
}),
);
} else if (data.type === 'source') {
isSourceUpdated = true;
dispatch(
updateStreamingSource({
index: indx ?? state.conversation.queries.length - 1,
query: { sources: data.source ?? [] },
}),
);
} else if (data.type === 'tool_calls') {
dispatch(
updateToolCalls({
index: indx ?? state.conversation.queries.length - 1,
query: { tool_calls: data.tool_calls },
}),
);
} else if (data.type === 'error') {
// set status to 'failed'
dispatch(conversationSlice.actions.setStatus('failed'));
dispatch(
conversationSlice.actions.raiseError({
index: indx ?? state.conversation.queries.length - 1,
message: data.error,
}),
);
} else {
const result = data.answer;
dispatch(
updateStreamingQuery({
index: indx ?? state.conversation.queries.length - 1,
query: { response: result },
}),
);
}
},
indx,
attachmentIds
);
} else {
const answer = await handleFetchAnswer(
question,
signal,
state.preference.token,
state.preference.selectedDocs!,
state.conversation.queries,
state.conversation.conversationId,
state.preference.prompt.id,
state.preference.chunks,
state.preference.token_limit,
attachmentIds
);
if (answer) {
let sourcesPrepped = [];
sourcesPrepped = answer.sources.map((source: { title: string }) => {
if (source && source.title) {
const titleParts = source.title.split('/');
return {
...source,
title: titleParts[titleParts.length - 1],
};
}
return source;
});
dispatch(
updateQuery({
index: indx ?? state.conversation.queries.length - 1,
query: {
response: answer.answer,
thought: answer.thought,
sources: sourcesPrepped,
tool_calls: answer.toolCalls,
},
}),
);
dispatch(
updateConversationId({
query: { conversationId: answer.conversationId },
}),
);
dispatch(conversationSlice.actions.setStatus('idle'));
getConversations(state.preference.token)
.then((fetchedConversations) => {
dispatch(setConversations(fetchedConversations));
})
.catch((error) => {
console.error('Failed to fetch conversations: ', error);
});
dispatch(conversationSlice.actions.setStatus('idle'));
}
}
}
}
return {
conversationId: null,
title: null,
answer: '',
query: question,
result: '',
thought: '',
sources: [],
tool_calls: [],
};
});
return {
conversationId: null,
title: null,
answer: '',
query: question,
result: '',
thought: '',
sources: [],
tool_calls: [],
};
},
);
export const conversationSlice = createSlice({
name: 'conversation',
@@ -294,23 +315,35 @@ export const conversationSlice = createSlice({
addAttachment: (state, action: PayloadAction<Attachment>) => {
state.attachments.push(action.payload);
},
updateAttachment: (state, action: PayloadAction<{
taskId: string;
updates: Partial<Attachment>;
}>) => {
const index = state.attachments.findIndex(att => att.taskId === action.payload.taskId);
updateAttachment: (
state,
action: PayloadAction<{
taskId: string;
updates: Partial<Attachment>;
}>,
) => {
const index = state.attachments.findIndex(
(att) => att.taskId === action.payload.taskId,
);
if (index !== -1) {
state.attachments[index] = {
...state.attachments[index],
...action.payload.updates
...action.payload.updates,
};
}
},
removeAttachment: (state, action: PayloadAction<string>) => {
state.attachments = state.attachments.filter(att =>
att.taskId !== action.payload && att.id !== action.payload
state.attachments = state.attachments.filter(
(att) => att.taskId !== action.payload && att.id !== action.payload,
);
},
resetConversation: (state) => {
state.queries = initialState.queries;
state.status = initialState.status;
state.conversationId = initialState.conversationId;
state.attachments = initialState.attachments;
handleAbort();
},
},
extraReducers(builder) {
builder
@@ -334,9 +367,10 @@ export const selectQueries = (state: RootState) => state.conversation.queries;
export const selectStatus = (state: RootState) => state.conversation.status;
export const selectAttachments = (state: RootState) => state.conversation.attachments;
export const selectCompletedAttachments = (state: RootState) =>
state.conversation.attachments.filter(att => att.status === 'completed');
export const selectAttachments = (state: RootState) =>
state.conversation.attachments;
export const selectCompletedAttachments = (state: RootState) =>
state.conversation.attachments.filter((att) => att.status === 'completed');
export const {
addQuery,
@@ -352,5 +386,6 @@ export const {
addAttachment,
updateAttachment,
removeAttachment,
resetConversation,
} = conversationSlice.actions;
export default conversationSlice.reducer;

View File

@@ -0,0 +1,168 @@
import { useCallback, useState, useEffect } from 'react';
import { useSelector } from 'react-redux';
import userService from '../api/services/userService';
import { Prompt } from '../models/misc';
import { selectToken } from '../preferences/preferenceSlice';
type UsePromptManagerProps = {
initialPrompts: Prompt[];
onPromptSelect: (name: string, id: string, type: string) => void;
onPromptsUpdate: (updatedPrompts: Prompt[]) => void;
};
type PromptContentResponse = {
id: string;
name: string;
content: string;
};
type PromptCreateResponse = {
id: string;
};
export const usePromptManager = ({
initialPrompts,
onPromptSelect,
onPromptsUpdate,
}: UsePromptManagerProps) => {
const token = useSelector(selectToken);
const [prompts, setPrompts] = useState<Prompt[]>(initialPrompts);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setPrompts(initialPrompts);
}, [initialPrompts]);
const handleApiCall = async <T>(
apiCall: () => Promise<Response>,
errorMessage: string,
): Promise<T | null> => {
setIsLoading(true);
setError(null);
try {
const response = await apiCall();
if (!response.ok) {
const errorData = await response.text();
console.error(`${errorMessage}: ${response.status} ${errorData}`);
throw new Error(`${errorMessage} (Status: ${response.status})`);
}
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
return (await response.json()) as T;
}
return null;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setError(message);
console.error(err);
return null;
} finally {
setIsLoading(false);
}
};
const addPrompt = useCallback(
async (name: string, content: string): Promise<Prompt | null> => {
const newPromptData = await handleApiCall<PromptCreateResponse>(
() => userService.createPrompt({ name, content }, token),
'Failed to add prompt',
);
if (newPromptData) {
const newPrompt: Prompt = {
name,
id: newPromptData.id,
type: 'private',
};
const updatedPrompts = [...prompts, newPrompt];
setPrompts(updatedPrompts);
onPromptsUpdate(updatedPrompts);
onPromptSelect(newPrompt.name, newPrompt.id, newPrompt.type);
return newPrompt;
}
return null;
},
[token, prompts, onPromptsUpdate, onPromptSelect],
);
const deletePrompt = useCallback(
async (idToDelete: string): Promise<void> => {
const originalPrompts = [...prompts];
const updatedPrompts = prompts.filter(
(prompt) => prompt.id !== idToDelete,
);
setPrompts(updatedPrompts);
onPromptsUpdate(updatedPrompts);
const result = await handleApiCall<null>(
() => userService.deletePrompt({ id: idToDelete }, token),
'Failed to delete prompt',
);
if (result === null && error) {
setPrompts(originalPrompts);
onPromptsUpdate(originalPrompts);
} else {
if (updatedPrompts.length > 0) {
onPromptSelect(
updatedPrompts[0].name,
updatedPrompts[0].id,
updatedPrompts[0].type,
);
}
}
},
[token, prompts, onPromptsUpdate, onPromptSelect, error],
);
const fetchPromptContent = useCallback(
async (id: string): Promise<string | null> => {
const promptDetails = await handleApiCall<PromptContentResponse>(
() => userService.getSinglePrompt(id, token),
'Failed to fetch prompt content',
);
return promptDetails ? promptDetails.content : null;
},
[token],
);
const updatePrompt = useCallback(
async (
id: string,
name: string,
content: string,
type: string,
): Promise<boolean> => {
const result = await handleApiCall<{ success: boolean }>(
() => userService.updatePrompt({ id, name, content }, token),
'Failed to update prompt',
);
if (result?.success) {
const updatedPrompts = prompts.map((p) =>
p.id === id ? { ...p, name, type } : p,
);
setPrompts(updatedPrompts);
onPromptsUpdate(updatedPrompts);
onPromptSelect(name, id, type);
return true;
}
return false;
},
[token, prompts, onPromptsUpdate, onPromptSelect],
);
return {
prompts,
isLoading,
error,
addPrompt,
deletePrompt,
fetchPromptContent,
updatePrompt,
setError,
};
};

View File

@@ -0,0 +1,68 @@
import { Agent } from '../agents/types';
import { ActiveState } from '../models/misc';
import WrapperModal from './WrapperModal';
import { useNavigate } from 'react-router-dom';
type AgentDetailsModalProps = {
agent: Agent;
mode: 'new' | 'edit' | 'draft';
modalState: ActiveState;
setModalState: (state: ActiveState) => void;
};
export default function AgentDetailsModal({
agent,
mode,
modalState,
setModalState,
}: AgentDetailsModalProps) {
const navigate = useNavigate();
if (modalState !== 'ACTIVE') return null;
return (
<WrapperModal
className="sm:w-[512px]"
close={() => {
// if (mode === 'new') navigate('/agents');
setModalState('INACTIVE');
}}
>
<div>
<h2 className="text-xl font-semibold text-jet dark:text-bright-gray">
Access Details
</h2>
<div className="mt-8 flex flex-col gap-6">
<div className="flex flex-col gap-3">
<h2 className="text-base font-semibold text-jet dark:text-bright-gray">
Public link
</h2>
<button className="hover:bg-vi</button>olets-are-blue w-28 rounded-3xl border border-solid border-violets-are-blue px-5 py-2 text-sm font-medium text-violets-are-blue transition-colors hover:bg-violets-are-blue hover:text-white">
Generate
</button>
</div>
<div className="flex flex-col gap-3">
<h2 className="text-base font-semibold text-jet dark:text-bright-gray">
API Key
</h2>
{agent.key ? (
<span className="font-mono text-sm text-gray-700 dark:text-[#ECECF1]">
{agent.key}
</span>
) : (
<button className="hover:bg-vi</button>olets-are-blue w-28 rounded-3xl border border-solid border-violets-are-blue px-5 py-2 text-sm font-medium text-violets-are-blue transition-colors hover:bg-violets-are-blue hover:text-white">
Generate
</button>
)}
</div>
<div className="flex flex-col gap-3">
<h2 className="text-base font-semibold text-jet dark:text-bright-gray">
Webhooks
</h2>
<button className="hover:bg-vi</button>olets-are-blue w-28 rounded-3xl border border-solid border-violets-are-blue px-5 py-2 text-sm font-medium text-violets-are-blue transition-colors hover:bg-violets-are-blue hover:text-white">
Generate
</button>
</div>
</div>
</div>
</WrapperModal>
);
}

View File

@@ -21,6 +21,12 @@ export type GetDocsResponse = {
nextCursor: string;
};
export type Prompt = {
name: string;
id: string;
type: string;
};
export type PromptProps = {
prompts: { name: string; id: string; type: string }[];
selectedPrompt: { name: string; id: string; type: string };

View File

@@ -1,6 +1,7 @@
import conversationService from '../api/services/conversationService';
import userService from '../api/services/userService';
import { Doc, GetDocsResponse } from '../models/misc';
import { GetConversationsResult, ConversationSummary } from './types';
//Fetches all JSON objects from the source. We only use the objects with the "model" property in SelectDocsModal.tsx. Hopefully can clean up the source file later.
export async function getDocs(token: string | null): Promise<Doc[] | null> {
@@ -49,23 +50,37 @@ export async function getDocsWithPagination(
}
}
export async function getConversations(token: string | null): Promise<{
data: { name: string; id: string }[] | null;
loading: boolean;
}> {
export async function getConversations(
token: string | null,
): Promise<GetConversationsResult> {
try {
const response = await conversationService.getConversations(token);
const data = await response.json();
const conversations: { name: string; id: string }[] = [];
if (!response.ok) {
console.error('Error fetching conversations:', response.statusText);
return { data: null, loading: false };
}
data.forEach((conversation: object) => {
conversations.push(conversation as { name: string; id: string });
});
const rawData: unknown = await response.json();
if (!Array.isArray(rawData)) {
console.error(
'Invalid data format received from API: Expected an array.',
rawData,
);
return { data: null, loading: false };
}
const conversations: ConversationSummary[] = rawData.map((item: any) => ({
id: item.id,
name: item.name,
agent_id: item.agent_id ?? null,
}));
return { data: conversations, loading: false };
} catch (error) {
console.log(error);
console.error(
'An unexpected error occurred while fetching conversations:',
error,
);
return { data: null, loading: false };
}
}

View File

@@ -1,12 +1,14 @@
import {
PayloadAction,
createListenerMiddleware,
createSlice,
isAnyOf,
PayloadAction,
} from '@reduxjs/toolkit';
import { setLocalApiKey, setLocalRecentDocs } from './preferenceApi';
import { RootState } from '../store';
import { Agent } from '../agents/types';
import { ActiveState, Doc } from '../models/misc';
import { RootState } from '../store';
import { setLocalApiKey, setLocalRecentDocs } from './preferenceApi';
export interface Preference {
apiKey: string;
@@ -22,6 +24,8 @@ export interface Preference {
token: string | null;
modalState: ActiveState;
paginatedDocuments: Doc[] | null;
agents: Agent[] | null;
selectedAgent: Agent | null;
}
const initialState: Preference = {
@@ -46,6 +50,8 @@ const initialState: Preference = {
token: localStorage.getItem('authToken') || null,
modalState: 'INACTIVE',
paginatedDocuments: null,
agents: null,
selectedAgent: null,
};
export const prefSlice = createSlice({
@@ -82,6 +88,12 @@ export const prefSlice = createSlice({
setModalStateDeleteConv: (state, action: PayloadAction<ActiveState>) => {
state.modalState = action.payload;
},
setAgents: (state, action) => {
state.agents = action.payload;
},
setSelectedAgent: (state, action) => {
state.selectedAgent = action.payload;
},
},
});
@@ -96,6 +108,8 @@ export const {
setTokenLimit,
setModalStateDeleteConv,
setPaginatedDocuments,
setAgents,
setSelectedAgent,
} = prefSlice.actions;
export default prefSlice.reducer;
@@ -170,3 +184,6 @@ export const selectTokenLimit = (state: RootState) =>
state.preference.token_limit;
export const selectPaginatedDocuments = (state: RootState) =>
state.preference.paginatedDocuments;
export const selectAgents = (state: RootState) => state.preference.agents;
export const selectSelectedAgent = (state: RootState) =>
state.preference.selectedAgent;

View File

@@ -0,0 +1,10 @@
export type ConversationSummary = {
id: string;
name: string;
agent_id: string | null;
};
export type GetConversationsResult = {
data: ConversationSummary[] | null;
loading: boolean;
};

View File

@@ -7,11 +7,12 @@ import {
Title,
Tooltip,
} from 'chart.js';
import React, { useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { Bar } from 'react-chartjs-2';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { Agent } from '../agents/types';
import userService from '../api/services/userService';
import Dropdown from '../components/Dropdown';
import SkeletonLoader from '../components/SkeletonLoader';
@@ -19,7 +20,6 @@ import { useLoaderState } from '../hooks';
import { selectToken } from '../preferences/preferenceSlice';
import { htmlLegendPlugin } from '../utils/chartUtils';
import { formatDate } from '../utils/dateTimeUtils';
import { APIKeyData } from './types';
import type { ChartData } from 'chart.js';
ChartJS.register(
@@ -31,7 +31,11 @@ ChartJS.register(
Legend,
);
export default function Analytics() {
type AnalyticsProps = {
agentId?: string;
};
export default function Analytics({ agentId }: AnalyticsProps) {
const { t } = useTranslation();
const token = useSelector(selectToken);
@@ -67,8 +71,7 @@ export default function Analytics() {
string,
{ positive: number; negative: number }
> | null>(null);
const [chatbots, setChatbots] = useState<APIKeyData[]>([]);
const [selectedChatbot, setSelectedChatbot] = useState<APIKeyData | null>();
const [agent, setAgent] = useState<Agent>();
const [messagesFilter, setMessagesFilter] = useState<{
label: string;
value: string;
@@ -94,37 +97,33 @@ export default function Analytics() {
const [loadingMessages, setLoadingMessages] = useLoaderState(true);
const [loadingTokens, setLoadingTokens] = useLoaderState(true);
const [loadingFeedback, setLoadingFeedback] = useLoaderState(true);
const [loadingChatbots, setLoadingChatbots] = useLoaderState(true);
const [loadingAgent, setLoadingAgent] = useLoaderState(true);
const fetchChatbots = async () => {
setLoadingChatbots(true);
const fetchAgent = async (agentId: string) => {
setLoadingAgent(true);
try {
const response = await userService.getAPIKeys(token);
if (!response.ok) {
throw new Error('Failed to fetch Chatbots');
}
const chatbots = await response.json();
setChatbots(chatbots);
const response = await userService.getAgent(agentId ?? '', token);
if (!response.ok) throw new Error('Failed to fetch Chatbots');
const agent = await response.json();
setAgent(agent);
} catch (error) {
console.error(error);
} finally {
setLoadingChatbots(false);
setLoadingAgent(false);
}
};
const fetchMessagesData = async (chatbot_id?: string, filter?: string) => {
const fetchMessagesData = async (agent_id?: string, filter?: string) => {
setLoadingMessages(true);
try {
const response = await userService.getMessageAnalytics(
{
api_key_id: chatbot_id,
api_key_id: agent_id,
filter_option: filter,
},
token,
);
if (!response.ok) {
throw new Error('Failed to fetch analytics data');
}
if (!response.ok) throw new Error('Failed to fetch analytics data');
const data = await response.json();
setMessagesData(data.messages);
} catch (error) {
@@ -134,19 +133,17 @@ export default function Analytics() {
}
};
const fetchTokenData = async (chatbot_id?: string, filter?: string) => {
const fetchTokenData = async (agent_id?: string, filter?: string) => {
setLoadingTokens(true);
try {
const response = await userService.getTokenAnalytics(
{
api_key_id: chatbot_id,
api_key_id: agent_id,
filter_option: filter,
},
token,
);
if (!response.ok) {
throw new Error('Failed to fetch analytics data');
}
if (!response.ok) throw new Error('Failed to fetch analytics data');
const data = await response.json();
setTokenUsageData(data.token_usage);
} catch (error) {
@@ -156,19 +153,17 @@ export default function Analytics() {
}
};
const fetchFeedbackData = async (chatbot_id?: string, filter?: string) => {
const fetchFeedbackData = async (agent_id?: string, filter?: string) => {
setLoadingFeedback(true);
try {
const response = await userService.getFeedbackAnalytics(
{
api_key_id: chatbot_id,
api_key_id: agent_id,
filter_option: filter,
},
token,
);
if (!response.ok) {
throw new Error('Failed to fetch analytics data');
}
if (!response.ok) throw new Error('Failed to fetch analytics data');
const data = await response.json();
setFeedbackData(data.feedback);
} catch (error) {
@@ -179,229 +174,182 @@ export default function Analytics() {
};
useEffect(() => {
fetchChatbots();
if (agentId) fetchAgent(agentId);
}, []);
useEffect(() => {
const id = selectedChatbot?.id;
const id = agent?.id;
const filter = messagesFilter;
fetchMessagesData(id, filter?.value);
}, [selectedChatbot, messagesFilter]);
}, [agent, messagesFilter]);
useEffect(() => {
const id = selectedChatbot?.id;
const id = agent?.id;
const filter = tokenUsageFilter;
fetchTokenData(id, filter?.value);
}, [selectedChatbot, tokenUsageFilter]);
}, [agent, tokenUsageFilter]);
useEffect(() => {
const id = selectedChatbot?.id;
const id = agent?.id;
const filter = feedbackFilter;
fetchFeedbackData(id, filter?.value);
}, [selectedChatbot, feedbackFilter]);
}, [agent, feedbackFilter]);
return (
<div className="mt-12">
<div className="flex flex-col items-start">
{loadingChatbots ? (
<SkeletonLoader component="dropdown" />
) : (
<div className="flex flex-col gap-3">
{/* Messages Analytics */}
<div className="mt-8 flex w-full flex-col gap-3 [@media(min-width:1080px)]:flex-row">
<div className="h-[345px] w-full overflow-hidden rounded-2xl border border-silver px-6 py-5 dark:border-silver/40 [@media(min-width:1080px)]:w-1/2">
<div className="flex flex-row items-center justify-start gap-3">
<p className="font-bold text-jet dark:text-bright-gray">
{t('settings.analytics.filterByChatbot')}
{t('settings.analytics.messages')}
</p>
<Dropdown
size="w-[55vw] sm:w-[360px]"
options={[
...chatbots.map((chatbot) => ({
label: chatbot.name,
value: chatbot.id,
})),
{ label: t('settings.analytics.none'), value: '' },
]}
placeholder={t('settings.analytics.selectChatbot')}
onSelect={(chatbot: { label: string; value: string }) => {
setSelectedChatbot(
chatbots.find((item) => item.id === chatbot.value),
);
size="w-[125px]"
options={filterOptions}
placeholder={t('settings.analytics.filterPlaceholder')}
onSelect={(selectedOption: { label: string; value: string }) => {
setMessagesFilter(selectedOption);
}}
selectedValue={
(selectedChatbot && {
label: selectedChatbot.name,
value: selectedChatbot.id,
}) ||
null
}
selectedValue={messagesFilter ?? null}
rounded="3xl"
border="border"
darkBorderColor="dim-gray"
contentSize="text-sm"
/>
</div>
)}
{/* Messages Analytics */}
<div className="mt-8 w-full flex flex-col [@media(min-width:1080px)]:flex-row gap-3">
<div className="h-[345px] [@media(min-width:1080px)]:w-1/2 w-full px-6 py-5 border rounded-2xl border-silver dark:border-silver/40 overflow-hidden">
<div className="flex flex-row items-center justify-start gap-3">
<p className="font-bold text-jet dark:text-bright-gray">
{t('settings.analytics.messages')}
</p>
<Dropdown
size="w-[125px]"
options={filterOptions}
placeholder={t('settings.analytics.filterPlaceholder')}
onSelect={(selectedOption: {
label: string;
value: string;
}) => {
setMessagesFilter(selectedOption);
<div className="relative mt-px h-[245px] w-full">
<div
id="legend-container-1"
className="flex flex-row items-center justify-end"
></div>
{loadingMessages ? (
<SkeletonLoader count={1} component={'analysis'} />
) : (
<AnalyticsChart
data={{
labels: Object.keys(messagesData || {}).map((item) =>
formatDate(item),
),
datasets: [
{
label: t('settings.analytics.messages'),
data: Object.values(messagesData || {}),
backgroundColor: '#7D54D1',
},
],
}}
selectedValue={messagesFilter ?? null}
rounded="3xl"
border="border"
contentSize="text-sm"
legendID="legend-container-1"
maxTicksLimitInX={8}
isStacked={false}
/>
</div>
<div className="mt-px relative h-[245px] w-full">
<div
id="legend-container-1"
className="flex flex-row items-center justify-end"
></div>
{loadingMessages ? (
<SkeletonLoader count={1} component={'analysis'} />
) : (
<AnalyticsChart
data={{
labels: Object.keys(messagesData || {}).map((item) =>
formatDate(item),
),
datasets: [
{
label: t('settings.analytics.messages'),
data: Object.values(messagesData || {}),
backgroundColor: '#7D54D1',
},
],
}}
legendID="legend-container-1"
maxTicksLimitInX={8}
isStacked={false}
/>
)}
</div>
</div>
{/* Token Usage Analytics */}
<div className="h-[345px] [@media(min-width:1080px)]:w-1/2 w-full px-6 py-5 border rounded-2xl border-silver dark:border-silver/40 overflow-hidden">
<div className="flex flex-row items-center justify-start gap-3">
<p className="font-bold text-jet dark:text-bright-gray">
{t('settings.analytics.tokenUsage')}
</p>
<Dropdown
size="w-[125px]"
options={filterOptions}
placeholder={t('settings.analytics.filterPlaceholder')}
onSelect={(selectedOption: {
label: string;
value: string;
}) => {
setTokenUsageFilter(selectedOption);
}}
selectedValue={tokenUsageFilter ?? null}
rounded="3xl"
border="border"
contentSize="text-sm"
/>
</div>
<div className="mt-px relative h-[245px] w-full">
<div
id="legend-container-2"
className="flex flex-row items-center justify-end"
></div>
{loadingTokens ? (
<SkeletonLoader count={1} component={'analysis'} />
) : (
<AnalyticsChart
data={{
labels: Object.keys(tokenUsageData || {}).map((item) =>
formatDate(item),
),
datasets: [
{
label: t('settings.analytics.tokenUsage'),
data: Object.values(tokenUsageData || {}),
backgroundColor: '#7D54D1',
},
],
}}
legendID="legend-container-2"
maxTicksLimitInX={8}
isStacked={false}
/>
)}
</div>
)}
</div>
</div>
{/* Feedback Analytics */}
<div className="mt-8 w-full flex flex-col gap-3">
<div className="h-[345px] w-full px-6 py-5 border rounded-2xl border-silver dark:border-silver/40 overflow-hidden">
<div className="flex flex-row items-center justify-start gap-3">
<p className="font-bold text-jet dark:text-bright-gray">
{t('settings.analytics.userFeedback')}
</p>
<Dropdown
size="w-[125px]"
options={filterOptions}
placeholder={t('settings.analytics.filterPlaceholder')}
onSelect={(selectedOption: {
label: string;
value: string;
}) => {
setFeedbackFilter(selectedOption);
{/* Token Usage Analytics */}
<div className="h-[345px] w-full overflow-hidden rounded-2xl border border-silver px-6 py-5 dark:border-silver/40 [@media(min-width:1080px)]:w-1/2">
<div className="flex flex-row items-center justify-start gap-3">
<p className="font-bold text-jet dark:text-bright-gray">
{t('settings.analytics.tokenUsage')}
</p>
<Dropdown
size="w-[125px]"
options={filterOptions}
placeholder={t('settings.analytics.filterPlaceholder')}
onSelect={(selectedOption: { label: string; value: string }) => {
setTokenUsageFilter(selectedOption);
}}
selectedValue={tokenUsageFilter ?? null}
rounded="3xl"
border="border"
contentSize="text-sm"
/>
</div>
<div className="relative mt-px h-[245px] w-full">
<div
id="legend-container-2"
className="flex flex-row items-center justify-end"
></div>
{loadingTokens ? (
<SkeletonLoader count={1} component={'analysis'} />
) : (
<AnalyticsChart
data={{
labels: Object.keys(tokenUsageData || {}).map((item) =>
formatDate(item),
),
datasets: [
{
label: t('settings.analytics.tokenUsage'),
data: Object.values(tokenUsageData || {}),
backgroundColor: '#7D54D1',
},
],
}}
selectedValue={feedbackFilter ?? null}
rounded="3xl"
border="border"
contentSize="text-sm"
legendID="legend-container-2"
maxTicksLimitInX={8}
isStacked={false}
/>
</div>
<div className="mt-px relative h-[245px] w-full">
<div
id="legend-container-3"
className="flex flex-row items-center justify-end"
></div>
{loadingFeedback ? (
<SkeletonLoader count={1} component={'analysis'} />
) : (
<AnalyticsChart
data={{
labels: Object.keys(feedbackData || {}).map((item) =>
formatDate(item),
),
datasets: [
{
label: t('settings.analytics.positiveFeedback'),
data: Object.values(feedbackData || {}).map(
(item) => item.positive,
),
backgroundColor: '#7D54D1',
},
{
label: t('settings.analytics.negativeFeedback'),
data: Object.values(feedbackData || {}).map(
(item) => item.negative,
),
backgroundColor: '#FF6384',
},
],
}}
legendID="legend-container-3"
maxTicksLimitInX={8}
isStacked={false}
/>
)}
</div>
)}
</div>
</div>
</div>
{/* Feedback Analytics */}
<div className="mt-8 flex w-full flex-col gap-3">
<div className="h-[345px] w-full overflow-hidden rounded-2xl border border-silver px-6 py-5 dark:border-silver/40">
<div className="flex flex-row items-center justify-start gap-3">
<p className="font-bold text-jet dark:text-bright-gray">
{t('settings.analytics.userFeedback')}
</p>
<Dropdown
size="w-[125px]"
options={filterOptions}
placeholder={t('settings.analytics.filterPlaceholder')}
onSelect={(selectedOption: { label: string; value: string }) => {
setFeedbackFilter(selectedOption);
}}
selectedValue={feedbackFilter ?? null}
rounded="3xl"
border="border"
contentSize="text-sm"
/>
</div>
<div className="relative mt-px h-[245px] w-full">
<div
id="legend-container-3"
className="flex flex-row items-center justify-end"
></div>
{loadingFeedback ? (
<SkeletonLoader count={1} component={'analysis'} />
) : (
<AnalyticsChart
data={{
labels: Object.keys(feedbackData || {}).map((item) =>
formatDate(item),
),
datasets: [
{
label: t('settings.analytics.positiveFeedback'),
data: Object.values(feedbackData || {}).map(
(item) => item.positive,
),
backgroundColor: '#7D54D1',
},
{
label: t('settings.analytics.negativeFeedback'),
data: Object.values(feedbackData || {}).map(
(item) => item.negative,
),
backgroundColor: '#FF6384',
},
],
}}
legendID="legend-container-3"
maxTicksLimitInX={8}
isStacked={false}
/>
)}
</div>
</div>
</div>

View File

@@ -5,53 +5,35 @@ import { useSelector } from 'react-redux';
import userService from '../api/services/userService';
import ChevronRight from '../assets/chevron-right.svg';
import CopyButton from '../components/CopyButton';
import Dropdown from '../components/Dropdown';
import SkeletonLoader from '../components/SkeletonLoader';
import { useLoaderState } from '../hooks';
import { selectToken } from '../preferences/preferenceSlice';
import { APIKeyData, LogData } from './types';
import { LogData } from './types';
export default function Logs() {
const { t } = useTranslation();
type LogsProps = {
agentId?: string;
tableHeader?: string;
};
export default function Logs({ agentId, tableHeader }: LogsProps) {
const token = useSelector(selectToken);
const [chatbots, setChatbots] = useState<APIKeyData[]>([]);
const [selectedChatbot, setSelectedChatbot] = useState<APIKeyData | null>();
const [logs, setLogs] = useState<LogData[]>([]);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [loadingChatbots, setLoadingChatbots] = useLoaderState(true);
const [loadingLogs, setLoadingLogs] = useLoaderState(true);
const fetchChatbots = async () => {
setLoadingChatbots(true);
try {
const response = await userService.getAPIKeys(token);
if (!response.ok) {
throw new Error('Failed to fetch Chatbots');
}
const chatbots = await response.json();
setChatbots(chatbots);
} catch (error) {
console.error(error);
} finally {
setLoadingChatbots(false);
}
};
const fetchLogs = async () => {
setLoadingLogs(true);
try {
const response = await userService.getLogs(
{
page: page,
api_key_id: selectedChatbot?.id,
api_key_id: agentId,
page_size: 10,
},
token,
);
if (!response.ok) {
throw new Error('Failed to fetch logs');
}
if (!response.ok) throw new Error('Failed to fetch logs');
const olderLogs = await response.json();
setLogs((prevLogs) => [...prevLogs, ...olderLogs.logs]);
setHasMore(olderLogs.has_more);
@@ -62,62 +44,18 @@ export default function Logs() {
}
};
useEffect(() => {
fetchChatbots();
}, []);
useEffect(() => {
if (hasMore) fetchLogs();
}, [page, selectedChatbot]);
}, [page, agentId]);
return (
<div className="mt-12">
<div className="flex flex-col items-start">
{loadingChatbots ? (
<SkeletonLoader component="dropdown" />
) : (
<div className="flex flex-col gap-3">
<label
id="chatbot-filter-label"
className="font-bold text-jet dark:text-bright-gray"
>
{t('settings.logs.filterByChatbot')}
</label>
<Dropdown
size="w-[55vw] sm:w-[360px]"
options={[
...chatbots.map((chatbot) => ({
label: chatbot.name,
value: chatbot.id,
})),
{ label: t('settings.logs.none'), value: '' },
]}
placeholder={t('settings.logs.selectChatbot')}
onSelect={(chatbot: { label: string; value: string }) => {
setSelectedChatbot(
chatbots.find((item) => item.id === chatbot.value),
);
setLogs([]);
setPage(1);
setHasMore(true);
}}
selectedValue={
(selectedChatbot && {
label: selectedChatbot.name,
value: selectedChatbot.id,
}) ||
null
}
rounded="3xl"
border="border"
darkBorderColor="dim-gray"
/>
</div>
)}
</div>
<div className="mt-8">
<LogsTable logs={logs} setPage={setPage} loading={loadingLogs} />
<LogsTable
logs={logs}
setPage={setPage}
loading={loadingLogs}
tableHeader={tableHeader}
/>
</div>
</div>
);
@@ -127,8 +65,9 @@ type LogsTableProps = {
logs: LogData[];
setPage: React.Dispatch<React.SetStateAction<number>>;
loading: boolean;
tableHeader?: string;
};
function LogsTable({ logs, setPage, loading }: LogsTableProps) {
function LogsTable({ logs, setPage, loading, tableHeader }: LogsTableProps) {
const { t } = useTranslation();
const observerRef = useRef<IntersectionObserver | null>(null);
const [openLogId, setOpenLogId] = useState<string | null>(null);
@@ -171,13 +110,13 @@ function LogsTable({ logs, setPage, loading }: LogsTableProps) {
}, []);
return (
<div className="logs-table rounded-xl h-[55vh] w-full overflow-hidden bg-white dark:bg-black border border-light-silver dark:border-transparent">
<div className="h-8 bg-black/10 dark:bg-[#191919] flex flex-col items-start justify-center">
<div className="logs-table h-[55vh] w-full overflow-hidden rounded-xl border border-light-silver bg-white dark:border-transparent dark:bg-black">
<div className="flex h-8 flex-col items-start justify-center bg-black/10 dark:bg-[#191919]">
<p className="px-3 text-xs dark:text-gray-6000">
{t('settings.logs.tableHeader')}
{tableHeader ? tableHeader : t('settings.logs.tableHeader')}
</p>
</div>
<div className="flex flex-col items-start h-[51vh] overflow-y-auto bg-transparent flex-grow gap-2 p-4">
<div className="flex h-[51vh] flex-grow flex-col items-start gap-2 overflow-y-auto bg-transparent p-4">
{logs?.map((log, index) => {
if (index === logs.length - 1) {
return (
@@ -211,18 +150,18 @@ function Log({
return (
<details
id={log.id}
className="group bg-transparent [&_summary::-webkit-details-marker]:hidden w-full hover:bg-[#F9F9F9] hover:dark:bg-dark-charcoal rounded-xl group-open:opacity-80 [&[open]]:border [&[open]]:border-[#d9d9d9]"
className="group w-full rounded-xl bg-transparent hover:bg-[#F9F9F9] group-open:opacity-80 hover:dark:bg-dark-charcoal [&[open]]:border [&[open]]:border-[#d9d9d9] [&_summary::-webkit-details-marker]:hidden"
onToggle={(e) => {
if ((e.target as HTMLDetailsElement).open) {
onToggle(log.id);
}
}}
>
<summary className="flex flex-row items-start gap-2 text-gray-900 cursor-pointer px-4 py-3 group-open:bg-[#F1F1F1] dark:group-open:bg-[#1B1B1B] group-open:rounded-t-xl p-2">
<summary className="flex cursor-pointer flex-row items-start gap-2 p-2 px-4 py-3 text-gray-900 group-open:rounded-t-xl group-open:bg-[#F1F1F1] dark:group-open:bg-[#1B1B1B]">
<img
src={ChevronRight}
alt="Expand log entry"
className="mt-[3px] w-3 h-3 transition duration-300 group-open:rotate-90"
className="mt-[3px] h-3 w-3 transition duration-300 group-open:rotate-90"
/>
<span className="flex flex-row gap-2">
<h2 className="text-xs text-black/60 dark:text-bright-gray">{`${log.timestamp}`}</h2>
@@ -236,8 +175,8 @@ function Log({
</h2>
</span>
</summary>
<div className="px-4 py-3 group-open:bg-[#F1F1F1] dark:group-open:bg-[#1B1B1B] group-open:rounded-b-xl">
<p className="px-2 leading-relaxed text-gray-700 dark:text-gray-400 text-xs break-words">
<div className="px-4 py-3 group-open:rounded-b-xl group-open:bg-[#F1F1F1] dark:group-open:bg-[#1B1B1B]">
<p className="break-words px-2 text-xs leading-relaxed text-gray-700 dark:text-gray-400">
{JSON.stringify(filteredLog, null, 2)}
</p>
<div className="my-px w-fit">

View File

@@ -1,7 +1,13 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { useLocation, useNavigate, Routes, Route, Navigate } from 'react-router-dom';
import {
Navigate,
Route,
Routes,
useLocation,
useNavigate,
} from 'react-router-dom';
import userService from '../api/services/userService';
import SettingsBar from '../components/SettingsBar';
@@ -10,12 +16,11 @@ import { Doc } from '../models/misc';
import {
selectPaginatedDocuments,
selectSourceDocs,
selectToken,
setPaginatedDocuments,
setSourceDocs,
selectToken,
} from '../preferences/preferenceSlice';
import Analytics from './Analytics';
import APIKeys from './APIKeys';
import Documents from './Documents';
import General from './General';
import Logs from './Logs';
@@ -27,13 +32,16 @@ export default function Settings() {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const [widgetScreenshot, setWidgetScreenshot] = React.useState<File | null>(null);
const [widgetScreenshot, setWidgetScreenshot] = React.useState<File | null>(
null,
);
const getActiveTabFromPath = () => {
const path = location.pathname;
if (path.includes('/settings/documents')) return t('settings.documents.label');
if (path.includes('/settings/apikeys')) return t('settings.apiKeys.label');
if (path.includes('/settings/analytics')) return t('settings.analytics.label');
if (path.includes('/settings/documents'))
return t('settings.documents.label');
if (path.includes('/settings/analytics'))
return t('settings.analytics.label');
if (path.includes('/settings/logs')) return t('settings.logs.label');
if (path.includes('/settings/tools')) return t('settings.tools.label');
if (path.includes('/settings/widgets')) return 'Widgets';
@@ -45,9 +53,10 @@ export default function Settings() {
const handleTabChange = (tab: string) => {
setActiveTab(tab);
if (tab === t('settings.general.label')) navigate('/settings');
else if (tab === t('settings.documents.label')) navigate('/settings/documents');
else if (tab === t('settings.apiKeys.label')) navigate('/settings/apikeys');
else if (tab === t('settings.analytics.label')) navigate('/settings/analytics');
else if (tab === t('settings.documents.label'))
navigate('/settings/documents');
else if (tab === t('settings.analytics.label'))
navigate('/settings/analytics');
else if (tab === t('settings.logs.label')) navigate('/settings/logs');
else if (tab === t('settings.tools.label')) navigate('/settings/tools');
else if (tab === 'Widgets') navigate('/settings/widgets');
@@ -93,29 +102,37 @@ export default function Settings() {
};
return (
<div className="p-4 md:p-12 h-full overflow-auto">
<div className="h-full overflow-auto p-4 md:p-12">
<p className="text-2xl font-bold text-eerie-black dark:text-bright-gray">
{t('settings.label')}
</p>
<SettingsBar activeTab={activeTab} setActiveTab={(tab) => handleTabChange(tab as string)} />
<SettingsBar
activeTab={activeTab}
setActiveTab={(tab) => handleTabChange(tab as string)}
/>
<Routes>
<Route index element={<General />} />
<Route path="documents" element={
<Documents
paginatedDocuments={paginatedDocuments}
handleDeleteDocument={handleDeleteClick}
/>
} />
<Route path="apikeys" element={<APIKeys />} />
<Route
path="documents"
element={
<Documents
paginatedDocuments={paginatedDocuments}
handleDeleteDocument={handleDeleteClick}
/>
}
/>
<Route path="analytics" element={<Analytics />} />
<Route path="logs" element={<Logs />} />
<Route path="tools" element={<Tools />} />
<Route path="widgets" element={
<Widgets
widgetScreenshot={widgetScreenshot}
onWidgetScreenshotChange={updateWidgetScreenshot}
/>
} />
<Route
path="widgets"
element={
<Widgets
widgetScreenshot={widgetScreenshot}
onWidgetScreenshotChange={updateWidgetScreenshot}
/>
}
/>
<Route path="*" element={<Navigate to="/settings" replace />} />
</Routes>
</div>

View File

@@ -1,4 +1,5 @@
import { configureStore } from '@reduxjs/toolkit';
import { conversationSlice } from './conversation/conversationSlice';
import { sharedConversationSlice } from './conversation/sharedConversationSlice';
import {
@@ -40,6 +41,8 @@ const preloadedState: { preference: Preference } = {
],
modalState: 'INACTIVE',
paginatedDocuments: null,
agents: null,
selectedAgent: null,
},
};
const store = configureStore({