Merge pull request #1116 from siiddhantt/feat/analytics-and-logs

Feature: Analytics and Logs Dashboard
This commit is contained in:
Alex
2024-09-11 15:10:40 +01:00
committed by GitHub
31 changed files with 1619 additions and 162 deletions

View File

@@ -24,6 +24,7 @@ conversations_collection = db["conversations"]
sources_collection = db["sources"]
prompts_collection = db["prompts"]
api_key_collection = db["api_keys"]
user_logs_collection = db["user_logs"]
answer = Blueprint("answer", __name__)
gpt_model = ""
@@ -37,7 +38,9 @@ if settings.MODEL_NAME: # in case there is particular model name configured
gpt_model = settings.MODEL_NAME
# load the prompts
current_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
current_dir = os.path.dirname(
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
)
with open(os.path.join(current_dir, "prompts", "chat_combine_default.txt"), "r") as f:
chat_combine_template = f.read()
@@ -98,9 +101,12 @@ def get_retriever(source_id: str):
return retriever_name
def is_azure_configured():
return settings.OPENAI_API_BASE and settings.OPENAI_API_VERSION and settings.AZURE_DEPLOYMENT_NAME
return (
settings.OPENAI_API_BASE
and settings.OPENAI_API_VERSION
and settings.AZURE_DEPLOYMENT_NAME
)
def save_conversation(conversation_id, question, response, source_log_docs, llm):
@@ -201,6 +207,21 @@ def complete_stream(
data = json.dumps({"type": "id", "id": str(conversation_id)})
yield f"data: {data}\n\n"
retriever_params = retriever.get_params()
user_logs_collection.insert_one(
{
"action": "stream_answer",
"level": "info",
"user": "local",
"api_key": user_api_key,
"question": question,
"response": response_full,
"sources": source_log_docs,
"retriever_params": retriever_params,
"timestamp": datetime.datetime.now(datetime.timezone.utc),
}
)
data = json.dumps({"type": "end"})
yield f"data: {data}\n\n"
except Exception as e:
@@ -258,7 +279,7 @@ def stream():
user_api_key = data["api_key"]
elif "active_docs" in data:
source = {"active_docs" : data["active_docs"]}
source = {"active_docs": data["active_docs"]}
retriever_name = get_retriever(data["active_docs"]) or retriever_name
user_api_key = None
@@ -266,12 +287,13 @@ def stream():
source = {}
user_api_key = None
current_app.logger.info(f"/stream - request_data: {data}, source: {source}",
extra={"data": json.dumps({"request_data": data, "source": source})}
current_app.logger.info(
f"/stream - request_data: {data}, source: {source}",
extra={"data": json.dumps({"request_data": data, "source": source})},
)
prompt = get_prompt(prompt_id)
retriever = RetrieverCreator.create_retriever(
retriever_name,
question=question,
@@ -304,8 +326,9 @@ def stream():
mimetype="text/event-stream",
)
except Exception as e:
current_app.logger.error(f"/stream - error: {str(e)} - traceback: {traceback.format_exc()}",
extra={"error": str(e), "traceback": traceback.format_exc()}
current_app.logger.error(
f"/stream - error: {str(e)} - traceback: {traceback.format_exc()}",
extra={"error": str(e), "traceback": traceback.format_exc()},
)
message = e.args[0]
status_code = 400
@@ -364,7 +387,7 @@ def api_answer():
retriever_name = data_key["retriever"] or retriever_name
user_api_key = data["api_key"]
elif "active_docs" in data:
source = {"active_docs":data["active_docs"]}
source = {"active_docs": data["active_docs"]}
retriever_name = get_retriever(data["active_docs"]) or retriever_name
user_api_key = None
else:
@@ -373,8 +396,9 @@ def api_answer():
prompt = get_prompt(prompt_id)
current_app.logger.info(f"/api/answer - request_data: {data}, source: {source}",
extra={"data": json.dumps({"request_data": data, "source": source})}
current_app.logger.info(
f"/api/answer - request_data: {data}, source: {source}",
extra={"data": json.dumps({"request_data": data, "source": source})},
)
retriever = RetrieverCreator.create_retriever(
@@ -406,13 +430,30 @@ def api_answer():
result = {"answer": response_full, "sources": source_log_docs}
result["conversation_id"] = str(
save_conversation(conversation_id, question, response_full, source_log_docs, llm)
save_conversation(
conversation_id, question, response_full, source_log_docs, llm
)
)
retriever_params = retriever.get_params()
user_logs_collection.insert_one(
{
"action": "api_answer",
"level": "info",
"user": "local",
"api_key": user_api_key,
"question": question,
"response": response_full,
"sources": source_log_docs,
"retriever_params": retriever_params,
"timestamp": datetime.datetime.now(datetime.timezone.utc),
}
)
return result
except Exception as e:
current_app.logger.error(f"/api/answer - error: {str(e)} - traceback: {traceback.format_exc()}",
extra={"error": str(e), "traceback": traceback.format_exc()}
current_app.logger.error(
f"/api/answer - error: {str(e)} - traceback: {traceback.format_exc()}",
extra={"error": str(e), "traceback": traceback.format_exc()},
)
return bad_request(500, str(e))
@@ -428,10 +469,10 @@ def api_search():
if "api_key" in data:
data_key = get_data_from_api_key(data["api_key"])
chunks = int(data_key["chunks"])
source = {"active_docs":data_key["source"]}
source = {"active_docs": data_key["source"]}
user_api_key = data_key["api_key"]
elif "active_docs" in data:
source = {"active_docs":data["active_docs"]}
source = {"active_docs": data["active_docs"]}
user_api_key = None
else:
source = {}
@@ -445,9 +486,10 @@ def api_search():
token_limit = data["token_limit"]
else:
token_limit = settings.DEFAULT_MAX_HISTORY
current_app.logger.info(f"/api/answer - request_data: {data}, source: {source}",
extra={"data": json.dumps({"request_data": data, "source": source})}
current_app.logger.info(
f"/api/answer - request_data: {data}, source: {source}",
extra={"data": json.dumps({"request_data": data, "source": source})},
)
retriever = RetrieverCreator.create_retriever(
@@ -463,6 +505,20 @@ def api_search():
)
docs = retriever.search()
retriever_params = retriever.get_params()
user_logs_collection.insert_one(
{
"action": "api_search",
"level": "info",
"user": "local",
"api_key": user_api_key,
"question": question,
"sources": docs,
"retriever_params": retriever_params,
"timestamp": datetime.datetime.now(datetime.timezone.utc),
}
)
if data.get("isNoneDoc"):
for doc in docs:
doc["source"] = "None"

View File

@@ -1,12 +1,15 @@
import datetime
import os
import uuid
import shutil
from flask import Blueprint, request, jsonify
from pymongo import MongoClient
from bson.objectid import ObjectId
import uuid
from bson.binary import Binary, UuidRepresentation
from werkzeug.utils import secure_filename
from bson.dbref import DBRef
from bson.objectid import ObjectId
from flask import Blueprint, jsonify, request
from pymongo import MongoClient
from werkzeug.utils import secure_filename
from application.api.user.tasks import ingest, ingest_remote
from application.core.settings import settings
@@ -19,11 +22,36 @@ sources_collection = db["sources"]
prompts_collection = db["prompts"]
feedback_collection = db["feedback"]
api_key_collection = db["api_keys"]
token_usage_collection = db["token_usage"]
shared_conversations_collections = db["shared_conversations"]
user_logs_collection = db["user_logs"]
user = Blueprint("user", __name__)
current_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
current_dir = os.path.dirname(
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
)
def generate_minute_range(start_date, end_date):
return {
(start_date + datetime.timedelta(minutes=i)).strftime("%Y-%m-%d %H:%M:00"): 0
for i in range(int((end_date - start_date).total_seconds() // 60) + 1)
}
def generate_hourly_range(start_date, end_date):
return {
(start_date + datetime.timedelta(hours=i)).strftime("%Y-%m-%d %H:00"): 0
for i in range(int((end_date - start_date).total_seconds() // 3600) + 1)
}
def generate_date_range(start_date, end_date):
return {
(start_date + datetime.timedelta(days=i)).strftime("%Y-%m-%d"): 0
for i in range((end_date - start_date).days + 1)
}
@user.route("/api/delete_conversation", methods=["POST"])
@@ -53,7 +81,9 @@ def get_conversations():
conversations = conversations_collection.find().sort("date", -1).limit(30)
list_conversations = []
for conversation in conversations:
list_conversations.append({"id": str(conversation["_id"]), "name": conversation["name"]})
list_conversations.append(
{"id": str(conversation["_id"]), "name": conversation["name"]}
)
# list_conversations = [{"id": "default", "name": "default"}, {"id": "jeff", "name": "jeff"}]
@@ -84,7 +114,12 @@ def api_feedback():
question = data["question"]
answer = data["answer"]
feedback = data["feedback"]
new_doc = {"question": question, "answer": answer, "feedback": feedback}
new_doc = {
"question": question,
"answer": answer,
"feedback": feedback,
"timestamp": datetime.datetime.now(datetime.timezone.utc),
}
if "api_key" in data:
new_doc["api_key"] = data["api_key"]
feedback_collection.insert_one(new_doc)
@@ -110,24 +145,31 @@ def delete_by_ids():
def delete_old():
"""Delete old indexes."""
import shutil
source_id = request.args.get("source_id")
doc = sources_collection.find_one({
"_id": ObjectId(source_id),
"user": "local",
})
if(doc is None):
return {"status":"not found"},404
doc = sources_collection.find_one(
{
"_id": ObjectId(source_id),
"user": "local",
}
)
if doc is None:
return {"status": "not found"}, 404
if settings.VECTOR_STORE == "faiss":
try:
shutil.rmtree(os.path.join(current_dir, str(doc["_id"])))
except FileNotFoundError:
pass
else:
vetorstore = VectorCreator.create_vectorstore(settings.VECTOR_STORE, source_id=str(doc["_id"]))
vetorstore = VectorCreator.create_vectorstore(
settings.VECTOR_STORE, source_id=str(doc["_id"])
)
vetorstore.delete_index()
sources_collection.delete_one({
"_id": ObjectId(source_id),
})
sources_collection.delete_one(
{
"_id": ObjectId(source_id),
}
)
return {"status": "ok"}
@@ -161,7 +203,9 @@ def upload_file():
file.save(os.path.join(temp_dir, filename))
# Use shutil.make_archive to zip the temp directory
zip_path = shutil.make_archive(base_name=os.path.join(save_dir, job_name), format="zip", root_dir=temp_dir)
zip_path = shutil.make_archive(
base_name=os.path.join(save_dir, job_name), format="zip", root_dir=temp_dir
)
final_filename = os.path.basename(zip_path)
# Clean up the temporary directory after zipping
@@ -203,7 +247,9 @@ def upload_remote():
source_data = request.form["data"]
if source_data:
task = ingest_remote.delay(source_data=source_data, job_name=job_name, user=user, loader=source)
task = ingest_remote.delay(
source_data=source_data, job_name=job_name, user=user, loader=source
)
task_id = task.id
return {"status": "ok", "task_id": task_id}
else:
@@ -248,7 +294,9 @@ def combined_json():
"model": settings.EMBEDDINGS_NAME,
"location": "local",
"tokens": index["tokens"] if ("tokens" in index.keys()) else "",
"retriever": index["retriever"] if ("retriever" in index.keys()) else "classic",
"retriever": (
index["retriever"] if ("retriever" in index.keys()) else "classic"
),
}
)
if "duckduck_search" in settings.RETRIEVERS_ENABLED:
@@ -317,7 +365,9 @@ def get_prompts():
list_prompts.append({"id": "creative", "name": "creative", "type": "public"})
list_prompts.append({"id": "strict", "name": "strict", "type": "public"})
for prompt in prompts:
list_prompts.append({"id": str(prompt["_id"]), "name": prompt["name"], "type": "private"})
list_prompts.append(
{"id": str(prompt["_id"]), "name": prompt["name"], "type": "private"}
)
return jsonify(list_prompts)
@@ -326,15 +376,21 @@ def get_prompts():
def get_single_prompt():
prompt_id = request.args.get("id")
if prompt_id == "default":
with open(os.path.join(current_dir, "prompts", "chat_combine_default.txt"), "r") as f:
with open(
os.path.join(current_dir, "prompts", "chat_combine_default.txt"), "r"
) as f:
chat_combine_template = f.read()
return jsonify({"content": chat_combine_template})
elif prompt_id == "creative":
with open(os.path.join(current_dir, "prompts", "chat_combine_creative.txt"), "r") as f:
with open(
os.path.join(current_dir, "prompts", "chat_combine_creative.txt"), "r"
) as f:
chat_reduce_creative = f.read()
return jsonify({"content": chat_reduce_creative})
elif prompt_id == "strict":
with open(os.path.join(current_dir, "prompts", "chat_combine_strict.txt"), "r") as f:
with open(
os.path.join(current_dir, "prompts", "chat_combine_strict.txt"), "r"
) as f:
chat_reduce_strict = f.read()
return jsonify({"content": chat_reduce_strict})
@@ -363,7 +419,9 @@ def update_prompt_name():
# check if name is null
if name == "":
return {"status": "error"}
prompts_collection.update_one({"_id": ObjectId(id)}, {"$set": {"name": name, "content": content}})
prompts_collection.update_one(
{"_id": ObjectId(id)}, {"$set": {"name": name, "content": content}}
)
return {"status": "ok"}
@@ -373,7 +431,7 @@ def get_api_keys():
keys = api_key_collection.find({"user": user})
list_keys = []
for key in keys:
if "source" in key and isinstance(key["source"],DBRef):
if "source" in key and isinstance(key["source"], DBRef):
source = db.dereference(key["source"])
if source is None:
continue
@@ -383,7 +441,7 @@ def get_api_keys():
source_name = key["retriever"]
else:
continue
list_keys.append(
{
"id": str(key["_id"]),
@@ -443,8 +501,10 @@ def share_conversation():
conversation_id = data["conversation_id"]
isPromptable = request.args.get("isPromptable").lower() == "true"
conversation = conversations_collection.find_one({"_id": ObjectId(conversation_id)})
if(conversation is None):
conversation = conversations_collection.find_one(
{"_id": ObjectId(conversation_id)}
)
if conversation is None:
raise Exception("Conversation does not exist")
current_n_queries = len(conversation["queries"])
@@ -456,24 +516,24 @@ def share_conversation():
chunks = "2" if "chunks" not in data else data["chunks"]
name = conversation["name"] + "(shared)"
new_api_key_data = {
"prompt_id": prompt_id,
"chunks": chunks,
"user": user,
}
new_api_key_data = {
"prompt_id": prompt_id,
"chunks": chunks,
"user": user,
}
if "source" in data and ObjectId.is_valid(data["source"]):
new_api_key_data["source"] = DBRef("sources",ObjectId(data["source"]))
new_api_key_data["source"] = DBRef("sources", ObjectId(data["source"]))
elif "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 = api_key_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(
{
"conversation_id": DBRef("conversations", ObjectId(conversation_id)),
"conversation_id": DBRef(
"conversations", ObjectId(conversation_id)
),
"isPromptable": isPromptable,
"first_n_queries": current_n_queries,
"user": user,
@@ -504,33 +564,39 @@ def share_conversation():
"api_key": api_uuid,
}
)
return jsonify({"success": True, "identifier": str(explicit_binary.as_uuid())})
return jsonify(
{"success": True, "identifier": str(explicit_binary.as_uuid())}
)
else:
api_uuid = str(uuid.uuid4())
new_api_key_data["key"] = api_uuid
new_api_key_data["name"] = name
if "source" in data and ObjectId.is_valid(data["source"]):
new_api_key_data["source"] = DBRef("sources", ObjectId(data["source"]))
new_api_key_data["source"] = DBRef(
"sources", ObjectId(data["source"])
)
if "retriever" in data:
new_api_key_data["retriever"] = data["retriever"]
api_key_collection.insert_one(new_api_key_data)
shared_conversations_collections.insert_one(
{
"uuid": explicit_binary,
"conversation_id": {
"$ref": "conversations",
"$id": ObjectId(conversation_id),
},
"isPromptable": isPromptable,
"first_n_queries": current_n_queries,
"user": user,
"api_key": api_uuid,
}
)
{
"uuid": explicit_binary,
"conversation_id": {
"$ref": "conversations",
"$id": ObjectId(conversation_id),
},
"isPromptable": isPromptable,
"first_n_queries": current_n_queries,
"user": user,
"api_key": api_uuid,
}
)
## Identifier as route parameter in frontend
return (
jsonify({"success": True, "identifier": str(explicit_binary.as_uuid())}),
jsonify(
{"success": True, "identifier": str(explicit_binary.as_uuid())}
),
201,
)
@@ -545,7 +611,9 @@ def share_conversation():
)
if pre_existing is not None:
return (
jsonify({"success": True, "identifier": str(pre_existing["uuid"].as_uuid())}),
jsonify(
{"success": True, "identifier": str(pre_existing["uuid"].as_uuid())}
),
200,
)
else:
@@ -563,7 +631,9 @@ def share_conversation():
)
## Identifier as route parameter in frontend
return (
jsonify({"success": True, "identifier": str(explicit_binary.as_uuid())}),
jsonify(
{"success": True, "identifier": str(explicit_binary.as_uuid())}
),
201,
)
except Exception as err:
@@ -575,10 +645,16 @@ def share_conversation():
@user.route("/api/shared_conversation/<string:identifier>", methods=["GET"])
def get_publicly_shared_conversations(identifier: str):
try:
query_uuid = Binary.from_uuid(uuid.UUID(identifier), UuidRepresentation.STANDARD)
query_uuid = Binary.from_uuid(
uuid.UUID(identifier), UuidRepresentation.STANDARD
)
shared = shared_conversations_collections.find_one({"uuid": query_uuid})
conversation_queries = []
if shared and "conversation_id" in shared and isinstance(shared["conversation_id"], DBRef):
if (
shared
and "conversation_id" in shared
and isinstance(shared["conversation_id"], DBRef)
):
# Resolve the DBRef
conversation_ref = shared["conversation_id"]
conversation = db.dereference(conversation_ref)
@@ -592,7 +668,9 @@ def get_publicly_shared_conversations(identifier: str):
),
404,
)
conversation_queries = conversation["queries"][: (shared["first_n_queries"])]
conversation_queries = conversation["queries"][
: (shared["first_n_queries"])
]
for query in conversation_queries:
query.pop("sources") ## avoid exposing sources
else:
@@ -618,3 +696,466 @@ def get_publicly_shared_conversations(identifier: str):
except Exception as err:
print(err)
return jsonify({"success": False, "error": str(err)}), 400
@user.route("/api/get_message_analytics", methods=["POST"])
def get_message_analytics():
data = request.get_json()
api_key_id = data.get("api_key_id")
filter_option = data.get("filter_option", "last_30_days")
try:
api_key = (
api_key_collection.find_one({"_id": ObjectId(api_key_id)})["key"]
if api_key_id
else None
)
except Exception as err:
print(err)
return jsonify({"success": False, "error": str(err)}), 400
end_date = datetime.datetime.now(datetime.timezone.utc)
if filter_option == "last_hour":
start_date = end_date - datetime.timedelta(hours=1)
group_format = "%Y-%m-%d %H:%M:00"
group_stage = {
"$group": {
"_id": {
"minute": {
"$dateToString": {"format": group_format, "date": "$date"}
}
},
"total_messages": {"$sum": 1},
}
}
elif filter_option == "last_24_hour":
start_date = end_date - datetime.timedelta(hours=24)
group_format = "%Y-%m-%d %H:00"
group_stage = {
"$group": {
"_id": {
"hour": {"$dateToString": {"format": group_format, "date": "$date"}}
},
"total_messages": {"$sum": 1},
}
}
else:
if filter_option in ["last_7_days", "last_15_days", "last_30_days"]:
filter_days = (
6
if filter_option == "last_7_days"
else (14 if filter_option == "last_15_days" else 29)
)
else:
return jsonify({"success": False, "error": "Invalid option"}), 400
start_date = end_date - datetime.timedelta(days=filter_days)
start_date = start_date.replace(hour=0, minute=0, second=0, microsecond=0)
end_date = end_date.replace(hour=23, minute=59, second=59, microsecond=999999)
group_format = "%Y-%m-%d"
group_stage = {
"$group": {
"_id": {
"day": {"$dateToString": {"format": group_format, "date": "$date"}}
},
"total_messages": {"$sum": 1},
}
}
try:
match_stage = {
"$match": {
"date": {"$gte": start_date, "$lte": end_date},
}
}
if api_key:
match_stage["$match"]["api_key"] = api_key
message_data = conversations_collection.aggregate(
[
match_stage,
group_stage,
{"$sort": {"_id": 1}},
]
)
if filter_option == "last_hour":
intervals = generate_minute_range(start_date, end_date)
elif filter_option == "last_24_hour":
intervals = generate_hourly_range(start_date, end_date)
else:
intervals = generate_date_range(start_date, end_date)
daily_messages = {interval: 0 for interval in intervals}
for entry in message_data:
if filter_option == "last_hour":
daily_messages[entry["_id"]["minute"]] = entry["total_messages"]
elif filter_option == "last_24_hour":
daily_messages[entry["_id"]["hour"]] = entry["total_messages"]
else:
daily_messages[entry["_id"]["day"]] = entry["total_messages"]
except Exception as err:
print(err)
return jsonify({"success": False, "error": str(err)}), 400
return jsonify({"success": True, "messages": daily_messages}), 200
@user.route("/api/get_token_analytics", methods=["POST"])
def get_token_analytics():
data = request.get_json()
api_key_id = data.get("api_key_id")
filter_option = data.get("filter_option", "last_30_days")
try:
api_key = (
api_key_collection.find_one({"_id": ObjectId(api_key_id)})["key"]
if api_key_id
else None
)
except Exception as err:
print(err)
return jsonify({"success": False, "error": str(err)}), 400
end_date = datetime.datetime.now(datetime.timezone.utc)
if filter_option == "last_hour":
start_date = end_date - datetime.timedelta(hours=1)
group_format = "%Y-%m-%d %H:%M:00"
group_stage = {
"$group": {
"_id": {
"minute": {
"$dateToString": {"format": group_format, "date": "$timestamp"}
}
},
"total_tokens": {
"$sum": {"$add": ["$prompt_tokens", "$generated_tokens"]}
},
}
}
elif filter_option == "last_24_hour":
start_date = end_date - datetime.timedelta(hours=24)
group_format = "%Y-%m-%d %H:00"
group_stage = {
"$group": {
"_id": {
"hour": {
"$dateToString": {"format": group_format, "date": "$timestamp"}
}
},
"total_tokens": {
"$sum": {"$add": ["$prompt_tokens", "$generated_tokens"]}
},
}
}
else:
if filter_option in ["last_7_days", "last_15_days", "last_30_days"]:
filter_days = (
6
if filter_option == "last_7_days"
else (14 if filter_option == "last_15_days" else 29)
)
else:
return jsonify({"success": False, "error": "Invalid option"}), 400
start_date = end_date - datetime.timedelta(days=filter_days)
start_date = start_date.replace(hour=0, minute=0, second=0, microsecond=0)
end_date = end_date.replace(hour=23, minute=59, second=59, microsecond=999999)
group_format = "%Y-%m-%d"
group_stage = {
"$group": {
"_id": {
"day": {
"$dateToString": {"format": group_format, "date": "$timestamp"}
}
},
"total_tokens": {
"$sum": {"$add": ["$prompt_tokens", "$generated_tokens"]}
},
}
}
try:
match_stage = {
"$match": {
"timestamp": {"$gte": start_date, "$lte": end_date},
}
}
if api_key:
match_stage["$match"]["api_key"] = api_key
token_usage_data = token_usage_collection.aggregate(
[
match_stage,
group_stage,
{"$sort": {"_id": 1}},
]
)
if filter_option == "last_hour":
intervals = generate_minute_range(start_date, end_date)
elif filter_option == "last_24_hour":
intervals = generate_hourly_range(start_date, end_date)
else:
intervals = generate_date_range(start_date, end_date)
daily_token_usage = {interval: 0 for interval in intervals}
for entry in token_usage_data:
if filter_option == "last_hour":
daily_token_usage[entry["_id"]["minute"]] = entry["total_tokens"]
elif filter_option == "last_24_hour":
daily_token_usage[entry["_id"]["hour"]] = entry["total_tokens"]
else:
daily_token_usage[entry["_id"]["day"]] = entry["total_tokens"]
except Exception as err:
print(err)
return jsonify({"success": False, "error": str(err)}), 400
return jsonify({"success": True, "token_usage": daily_token_usage}), 200
@user.route("/api/get_feedback_analytics", methods=["POST"])
def get_feedback_analytics():
data = request.get_json()
api_key_id = data.get("api_key_id")
filter_option = data.get("filter_option", "last_30_days")
try:
api_key = (
api_key_collection.find_one({"_id": ObjectId(api_key_id)})["key"]
if api_key_id
else None
)
except Exception as err:
print(err)
return jsonify({"success": False, "error": str(err)}), 400
end_date = datetime.datetime.now(datetime.timezone.utc)
if filter_option == "last_hour":
start_date = end_date - datetime.timedelta(hours=1)
group_format = "%Y-%m-%d %H:%M:00"
group_stage_1 = {
"$group": {
"_id": {
"minute": {
"$dateToString": {"format": group_format, "date": "$timestamp"}
},
"feedback": "$feedback",
},
"count": {"$sum": 1},
}
}
group_stage_2 = {
"$group": {
"_id": "$_id.minute",
"likes": {
"$sum": {
"$cond": [
{"$eq": ["$_id.feedback", "LIKE"]},
"$count",
0,
]
}
},
"dislikes": {
"$sum": {
"$cond": [
{"$eq": ["$_id.feedback", "DISLIKE"]},
"$count",
0,
]
}
},
}
}
elif filter_option == "last_24_hour":
start_date = end_date - datetime.timedelta(hours=24)
group_format = "%Y-%m-%d %H:00"
group_stage_1 = {
"$group": {
"_id": {
"hour": {
"$dateToString": {"format": group_format, "date": "$timestamp"}
},
"feedback": "$feedback",
},
"count": {"$sum": 1},
}
}
group_stage_2 = {
"$group": {
"_id": "$_id.hour",
"likes": {
"$sum": {
"$cond": [
{"$eq": ["$_id.feedback", "LIKE"]},
"$count",
0,
]
}
},
"dislikes": {
"$sum": {
"$cond": [
{"$eq": ["$_id.feedback", "DISLIKE"]},
"$count",
0,
]
}
},
}
}
else:
if filter_option in ["last_7_days", "last_15_days", "last_30_days"]:
filter_days = (
6
if filter_option == "last_7_days"
else (14 if filter_option == "last_15_days" else 29)
)
else:
return jsonify({"success": False, "error": "Invalid option"}), 400
start_date = end_date - datetime.timedelta(days=filter_days)
start_date = start_date.replace(hour=0, minute=0, second=0, microsecond=0)
end_date = end_date.replace(hour=23, minute=59, second=59, microsecond=999999)
group_format = "%Y-%m-%d"
group_stage_1 = {
"$group": {
"_id": {
"day": {
"$dateToString": {"format": group_format, "date": "$timestamp"}
},
"feedback": "$feedback",
},
"count": {"$sum": 1},
}
}
group_stage_2 = {
"$group": {
"_id": "$_id.day",
"likes": {
"$sum": {
"$cond": [
{"$eq": ["$_id.feedback", "LIKE"]},
"$count",
0,
]
}
},
"dislikes": {
"$sum": {
"$cond": [
{"$eq": ["$_id.feedback", "DISLIKE"]},
"$count",
0,
]
}
},
}
}
try:
match_stage = {
"$match": {
"timestamp": {"$gte": start_date, "$lte": end_date},
}
}
if api_key:
match_stage["$match"]["api_key"] = api_key
feedback_data = feedback_collection.aggregate(
[
match_stage,
group_stage_1,
group_stage_2,
{"$sort": {"_id": 1}},
]
)
if filter_option == "last_hour":
intervals = generate_minute_range(start_date, end_date)
elif filter_option == "last_24_hour":
intervals = generate_hourly_range(start_date, end_date)
else:
intervals = generate_date_range(start_date, end_date)
daily_feedback = {
interval: {"positive": 0, "negative": 0} for interval in intervals
}
for entry in feedback_data:
daily_feedback[entry["_id"]] = {
"positive": entry["likes"],
"negative": entry["dislikes"],
}
except Exception as err:
print(err)
return jsonify({"success": False, "error": str(err)}), 400
return jsonify({"success": True, "feedback": daily_feedback}), 200
@user.route("/api/get_user_logs", methods=["POST"])
def get_user_logs():
data = request.get_json()
page = int(data.get("page", 1))
api_key_id = data.get("api_key_id")
page_size = int(data.get("page_size", 10))
skip = (page - 1) * page_size
try:
api_key = (
api_key_collection.find_one({"_id": ObjectId(api_key_id)})["key"]
if api_key_id
else None
)
except Exception as err:
print(err)
return jsonify({"success": False, "error": str(err)}), 400
query = {}
if api_key:
query = {"api_key": api_key}
items_cursor = (
user_logs_collection.find(query)
.sort("timestamp", -1)
.skip(skip)
.limit(page_size + 1)
)
items = list(items_cursor)
results = []
for item in items[:page_size]:
results.append(
{
"id": str(item.get("_id")),
"action": item.get("action"),
"level": item.get("level"),
"user": item.get("user"),
"question": item.get("question"),
"sources": item.get("sources"),
"retriever_params": item.get("retriever_params"),
"timestamp": item.get("timestamp"),
}
)
has_more = len(items) > page_size
return (
jsonify(
{
"success": True,
"logs": results,
"page": page,
"page_size": page_size,
"has_more": has_more,
}
),
200,
)

View File

@@ -12,3 +12,7 @@ class BaseRetriever(ABC):
@abstractmethod
def search(self, *args, **kwargs):
pass
@abstractmethod
def get_params(self):
pass

View File

@@ -101,3 +101,15 @@ class BraveRetSearch(BaseRetriever):
def search(self):
return self._get_data()
def get_params(self):
return {
"question": self.question,
"source": self.source,
"chat_history": self.chat_history,
"prompt": self.prompt,
"chunks": self.chunks,
"token_limit": self.token_limit,
"gpt_model": self.gpt_model,
"user_api_key": self.user_api_key
}

View File

@@ -45,13 +45,12 @@ class ClassicRAG(BaseRetriever):
settings.VECTOR_STORE, self.vectorstore, settings.EMBEDDINGS_KEY
)
docs_temp = docsearch.search(self.question, k=self.chunks)
print(docs_temp)
docs = [
{
"title": (
i.metadata["title"].split("/")[-1]
if i.metadata
else i.page_content
),
"title": i.metadata.get(
"title", i.metadata.get("post_title", i.page_content)
).split("/")[-1],
"text": i.page_content,
"source": (
i.metadata.get("source")
@@ -105,3 +104,15 @@ class ClassicRAG(BaseRetriever):
def search(self):
return self._get_data()
def get_params(self):
return {
"question": self.question,
"source": self.vectorstore,
"chat_history": self.chat_history,
"prompt": self.prompt,
"chunks": self.chunks,
"token_limit": self.token_limit,
"gpt_model": self.gpt_model,
"user_api_key": self.user_api_key
}

View File

@@ -118,3 +118,15 @@ class DuckDuckSearch(BaseRetriever):
def search(self):
return self._get_data()
def get_params(self):
return {
"question": self.question,
"source": self.source,
"chat_history": self.chat_history,
"prompt": self.prompt,
"chunks": self.chunks,
"token_limit": self.token_limit,
"gpt_model": self.gpt_model,
"user_api_key": self.user_api_key
}

View File

@@ -14,6 +14,7 @@
"i18next-browser-languagedetector": "^8.0.0",
"prop-types": "^15.8.1",
"react": "^18.2.0",
"react-chartjs-2": "^5.2.0",
"react-copy-to-clipboard": "^5.1.0",
"react-dom": "^18.3.1",
"react-dropzone": "^14.2.3",
@@ -858,6 +859,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@kurkle/color": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.2.tgz",
"integrity": "sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==",
"peer": true
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -2503,6 +2510,18 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/chart.js": {
"version": "4.4.4",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.4.tgz",
"integrity": "sha512-emICKGBABnxhMjUjlYRR12PmOXhJ2eJjEHL2/dZlWjxRAZT1D8xplLFq5M0tMQK8ja+wBS/tuVEJB5C6r7VxJA==",
"peer": true,
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/chokidar": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
@@ -7253,6 +7272,15 @@
"node": ">=0.10.0"
}
},
"node_modules/react-chartjs-2": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.2.0.tgz",
"integrity": "sha512-98iN5aguJyVSxp5U3CblRLH67J8gkfyGNbiK3c+l1QI/G4irHMPQw44aEPmjVag+YKTyQ260NcF82GTQ3bdscA==",
"peerDependencies": {
"chart.js": "^4.1.1",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/react-copy-to-clipboard": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/react-copy-to-clipboard/-/react-copy-to-clipboard-5.1.0.tgz",

View File

@@ -25,6 +25,7 @@
"i18next-browser-languagedetector": "^8.0.0",
"prop-types": "^15.8.1",
"react": "^18.2.0",
"react-chartjs-2": "^5.2.0",
"react-copy-to-clipboard": "^5.1.0",
"react-dom": "^18.3.1",
"react-dropzone": "^14.2.3",

Binary file not shown.

View File

@@ -12,6 +12,10 @@ const endpoints = {
SINGLE_PROMPT: (id: string) => `/api/get_single_prompt?id=${id}`,
DELETE_PATH: (docPath: string) => `/api/delete_old?source_id=${docPath}`,
TASK_STATUS: (task_id: string) => `/api/task_status?task_id=${task_id}`,
MESSAGE_ANALYTICS: '/api/get_message_analytics',
TOKEN_ANALYTICS: '/api/get_token_analytics',
FEEDBACK_ANALYTICS: '/api/get_feedback_analytics',
LOGS: `/api/get_user_logs`,
},
CONVERSATION: {
ANSWER: '/api/answer',

View File

@@ -23,6 +23,14 @@ const userService = {
apiClient.get(endpoints.USER.DELETE_PATH(docPath)),
getTaskStatus: (task_id: string): Promise<any> =>
apiClient.get(endpoints.USER.TASK_STATUS(task_id)),
getMessageAnalytics: (data: any): Promise<any> =>
apiClient.post(endpoints.USER.MESSAGE_ANALYTICS, data),
getTokenAnalytics: (data: any): Promise<any> =>
apiClient.post(endpoints.USER.TOKEN_ANALYTICS, data),
getFeedbackAnalytics: (data: any): Promise<any> =>
apiClient.post(endpoints.USER.FEEDBACK_ANALYTICS, data),
getLogs: (data: any): Promise<any> =>
apiClient.post(endpoints.USER.LOGS, data),
};
export default userService;

View File

@@ -0,0 +1,3 @@
<svg width="7" height="12" viewBox="0 0 7 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.29154 4.88202L1.70154 0.29202C1.60896 0.199438 1.49905 0.125998 1.37808 0.0758932C1.25712 0.0257882 1.12747 -2.37536e-07 0.99654 -2.44235e-07C0.86561 -2.50934e-07 0.735961 0.0257882 0.614997 0.0758931C0.494033 0.125998 0.384122 0.199438 0.29154 0.29202C0.198958 0.384602 0.125519 0.494513 0.0754137 0.615477C0.0253086 0.736441 -0.00048069 0.86609 -0.000480695 0.99702C-0.000480701 1.12795 0.0253086 1.2576 0.0754136 1.37856C0.125519 1.49953 0.198958 1.60944 0.29154 1.70202L4.17154 5.59202L0.29154 9.47202C0.198958 9.5646 0.125518 9.67451 0.0754133 9.79548C0.0253082 9.91644 -0.000481091 10.0461 -0.000481097 10.177C-0.000481102 10.3079 0.0253082 10.4376 0.0754132 10.5586C0.125518 10.6795 0.198958 10.7894 0.29154 10.882C0.384121 10.9746 0.494032 11.048 0.614996 11.0981C0.73596 11.1483 0.865609 11.174 0.99654 11.174C1.12747 11.174 1.25712 11.1483 1.37808 11.0981C1.49905 11.048 1.60896 10.9746 1.70154 10.882L6.29154 6.29202C6.38424 6.19951 6.45779 6.08962 6.50797 5.96864C6.55815 5.84767 6.58398 5.71799 6.58398 5.58702C6.58398 5.45605 6.55815 5.32637 6.50797 5.2054C6.45779 5.08442 6.38424 4.97453 6.29154 4.88202Z" fill="#666666"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1,16 +1,24 @@
import { useState } from 'react';
import Copy from './../assets/copy.svg?react';
import CheckMark from './../assets/checkmark.svg?react';
import copy from 'copy-to-clipboard';
import { useState } from 'react';
export default function CoppyButton({ text }: { text: string }) {
import CheckMark from '../assets/checkmark.svg?react';
import Copy from '../assets/copy.svg?react';
export default function CoppyButton({
text,
colorLight,
colorDark,
}: {
text: string;
colorLight?: string;
colorDark?: string;
}) {
const [copied, setCopied] = useState(false);
const [isCopyHovered, setIsCopyHovered] = useState(false);
const handleCopyClick = (text: string) => {
copy(text);
setCopied(true);
// Reset copied to false after a few seconds
setTimeout(() => {
setCopied(false);
}, 3000);
@@ -20,8 +28,8 @@ export default function CoppyButton({ text }: { text: string }) {
<div
className={`flex items-center justify-center rounded-full p-2 ${
isCopyHovered
? 'bg-[#EEEEEE] dark:bg-purple-taupe'
: 'bg-[#ffffff] dark:bg-transparent'
? `bg-[#EEEEEE] dark:bg-purple-taupe`
: `bg-[${colorLight ? colorLight : '#FFFFFF'}] dark:bg-[${colorDark ? colorDark : 'transparent'}]`
}`}
>
{copied ? (

View File

@@ -16,6 +16,7 @@ function Dropdown({
showDelete,
onDelete,
placeholder,
contentSize = 'text-base',
}: {
options:
| string[]
@@ -42,6 +43,7 @@ function Dropdown({
showDelete?: boolean;
onDelete?: (value: string) => void;
placeholder?: string;
contentSize?: string;
}) {
const dropdownRef = React.useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = React.useState(false);
@@ -85,9 +87,9 @@ function Dropdown({
</span>
) : (
<span
className={`overflow-hidden text-ellipsis dark:text-bright-gray ${
className={`truncate overflow-hidden dark:text-bright-gray ${
!selectedValue && 'text-silver dark:text-gray-400'
}`}
} ${contentSize}`}
>
{selectedValue && 'label' in selectedValue
? selectedValue.label
@@ -124,7 +126,7 @@ function Dropdown({
onSelect(option);
setIsOpen(false);
}}
className="ml-5 flex-1 overflow-hidden overflow-ellipsis whitespace-nowrap py-3 dark:text-light-gray"
className={`ml-5 flex-1 overflow-hidden overflow-ellipsis whitespace-nowrap py-3 dark:text-light-gray ${contentSize}`}
>
{typeof option === 'string'
? option

View File

@@ -10,6 +10,7 @@ const Input = ({
maxLength,
className,
colorVariant = 'silver',
borderVariant = 'thick',
children,
onChange,
onPaste,
@@ -20,10 +21,13 @@ const Input = ({
jet: 'border-jet',
gray: 'border-gray-5000 dark:text-silver',
};
const borderStyles = {
thin: 'border',
thick: 'border-2',
};
return (
<input
className={`h-[42px] w-full rounded-full border-2 px-3 outline-none dark:bg-transparent dark:text-white ${className} ${colorStyles[colorVariant]}`}
className={`h-[42px] w-full rounded-full px-3 py-1 outline-none dark:bg-transparent dark:text-white ${className} ${colorStyles[colorVariant]} ${borderStyles[borderVariant]}`}
type={type}
id={id}
name={name}

View File

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

View File

@@ -180,7 +180,7 @@ const ConversationBubble = forwardRef<
))}
{(sources?.length ?? 0) > 3 && (
<div
className="flex h-24 cursor-pointer flex-col-reverse rounded-[20px] bg-gray-1000 p-4 text-purple-30 hover:bg-[#F1F1F1] hover:text-[#6D3ECC] dark:bg-gun-metal dark:hover:bg-[#2C2E3C] dark:hover:text-[#8C67D7]"
className="flex h-28 cursor-pointer flex-col-reverse rounded-[20px] bg-gray-1000 p-4 text-purple-30 hover:bg-[#F1F1F1] hover:text-[#6D3ECC] dark:bg-gun-metal dark:hover:bg-[#2C2E3C] dark:hover:text-[#8C67D7]"
onClick={() => setIsSidebarOpen(true)}
>
<p className="ellipsis-text h-22 text-xs">{`View ${

View File

@@ -420,10 +420,36 @@ template {
src: url('/fonts/Inter-Variable.ttf');
}
@font-face {
font-family: 'IBMPlexMono-Medium';
font-weight: 500;
src: url('/fonts/IBMPlexMono-Medium.ttf');
}
::-webkit-scrollbar {
width: 0;
}
input:-webkit-autofill {
-webkit-box-shadow: 0 0 0 50px white inset;
}
input:-webkit-autofill:focus {
-webkit-box-shadow: 0 0 0 50px white inset;
}
@media (prefers-color-scheme: dark) {
input:-webkit-autofill {
-webkit-box-shadow: 0 0 0 50px rgb(68, 70, 84) inset;
-webkit-text-fill-color: white;
}
input:-webkit-autofill:focus {
-webkit-box-shadow: 0 0 0 50px rgb(68, 70, 84) inset;
-webkit-text-fill-color: white;
}
}
.inputbox-style {
resize: none;
padding-left: 36px;
@@ -441,3 +467,7 @@ template {
-webkit-box-orient: vertical;
text-overflow: ellipsis;
}
.logs-table {
font-family: 'IBMPlexMono-Medium', system-ui;
}

View File

@@ -62,6 +62,12 @@
"key": "API Key",
"sourceDoc": "Source Document",
"createNew": "Create New"
},
"analytics": {
"label": "Analytics"
},
"logs": {
"label": "Logs"
}
},
"modals": {

View File

@@ -62,6 +62,12 @@
"key": "Clave de API",
"sourceDoc": "Documento Fuente",
"createNew": "Crear Nuevo"
},
"analytics": {
"label": "Analítica"
},
"logs": {
"label": "Registros"
}
},
"modals": {

View File

@@ -62,6 +62,12 @@
"key": "APIキー",
"sourceDoc": "ソースドキュメント",
"createNew": "新規作成"
},
"analytics": {
"label": "分析"
},
"logs": {
"label": "ログ"
}
},
"modals": {

View File

@@ -62,6 +62,12 @@
"key": "API 密钥",
"sourceDoc": "源文档",
"createNew": "创建新的"
},
"analytics": {
"label": "分析"
},
"logs": {
"label": "日志"
}
},
"modals": {

View File

@@ -5,15 +5,14 @@ import userService from '../api/services/userService';
import Trash from '../assets/trash.svg';
import CreateAPIKeyModal from '../modals/CreateAPIKeyModal';
import SaveAPIKeyModal from '../modals/SaveAPIKeyModal';
import { APIKeyData } from './types';
export default function APIKeys() {
const { t } = useTranslation();
const [isCreateModalOpen, setCreateModal] = React.useState(false);
const [isSaveKeyModalOpen, setSaveKeyModal] = React.useState(false);
const [newKey, setNewKey] = React.useState('');
const [apiKeys, setApiKeys] = React.useState<
{ name: string; key: string; source: string; id: string }[]
>([]);
const [apiKeys, setApiKeys] = React.useState<APIKeyData[]>([]);
const handleFetchKeys = async () => {
try {

View File

@@ -0,0 +1,390 @@
import {
BarElement,
CategoryScale,
Chart as ChartJS,
Legend,
LinearScale,
Title,
Tooltip,
} from 'chart.js';
import React from 'react';
import { Bar } from 'react-chartjs-2';
import userService from '../api/services/userService';
import Dropdown from '../components/Dropdown';
import { htmlLegendPlugin } from '../utils/chartUtils';
import { formatDate } from '../utils/dateTimeUtils';
import { APIKeyData } from './types';
import type { ChartData } from 'chart.js';
ChartJS.register(
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend,
);
const filterOptions = [
{ label: 'Hour', value: 'last_hour' },
{ label: '24 Hours', value: 'last_24_hour' },
{ label: '7 Days', value: 'last_7_days' },
{ label: '15 Days', value: 'last_15_days' },
{ label: '30 Days', value: 'last_30_days' },
];
export default function Analytics() {
const [messagesData, setMessagesData] = React.useState<Record<
string,
number
> | null>(null);
const [tokenUsageData, setTokenUsageData] = React.useState<Record<
string,
number
> | null>(null);
const [feedbackData, setFeedbackData] = React.useState<Record<
string,
{
positive: number;
negative: number;
}
> | null>(null);
const [chatbots, setChatbots] = React.useState<APIKeyData[]>([]);
const [selectedChatbot, setSelectedChatbot] =
React.useState<APIKeyData | null>();
const [messagesFilter, setMessagesFilter] = React.useState<{
label: string;
value: string;
}>({ label: '30 Days', value: 'last_30_days' });
const [tokenUsageFilter, setTokenUsageFilter] = React.useState<{
label: string;
value: string;
}>({ label: '30 Days', value: 'last_30_days' });
const [feedbackFilter, setFeedbackFilter] = React.useState<{
label: string;
value: string;
}>({ label: '30 Days', value: 'last_30_days' });
const fetchChatbots = async () => {
try {
const response = await userService.getAPIKeys();
if (!response.ok) {
throw new Error('Failed to fetch Chatbots');
}
const chatbots = await response.json();
setChatbots(chatbots);
} catch (error) {
console.error(error);
}
};
const fetchMessagesData = async (chatbot_id?: string, filter?: string) => {
try {
const response = await userService.getMessageAnalytics({
api_key_id: chatbot_id,
filter_option: filter,
});
if (!response.ok) {
throw new Error('Failed to fetch analytics data');
}
const data = await response.json();
setMessagesData(data.messages);
} catch (error) {
console.error(error);
}
};
const fetchTokenData = async (chatbot_id?: string, filter?: string) => {
try {
const response = await userService.getTokenAnalytics({
api_key_id: chatbot_id,
filter_option: filter,
});
if (!response.ok) {
throw new Error('Failed to fetch analytics data');
}
const data = await response.json();
setTokenUsageData(data.token_usage);
} catch (error) {
console.error(error);
}
};
const fetchFeedbackData = async (chatbot_id?: string, filter?: string) => {
try {
const response = await userService.getFeedbackAnalytics({
api_key_id: chatbot_id,
filter_option: filter,
});
if (!response.ok) {
throw new Error('Failed to fetch analytics data');
}
const data = await response.json();
setFeedbackData(data.feedback);
} catch (error) {
console.error(error);
}
};
React.useEffect(() => {
fetchChatbots();
}, []);
React.useEffect(() => {
const id = selectedChatbot?.id;
const filter = messagesFilter;
fetchMessagesData(id, filter?.value);
}, [selectedChatbot, messagesFilter]);
React.useEffect(() => {
const id = selectedChatbot?.id;
const filter = tokenUsageFilter;
fetchTokenData(id, filter?.value);
}, [selectedChatbot, tokenUsageFilter]);
React.useEffect(() => {
const id = selectedChatbot?.id;
const filter = feedbackFilter;
fetchFeedbackData(id, filter?.value);
}, [selectedChatbot, feedbackFilter]);
return (
<div className="mt-12">
<div className="flex flex-col items-start">
<div className="flex flex-col gap-3">
<p className="font-bold text-jet dark:text-bright-gray">
Filter by chatbot
</p>
<Dropdown
size="w-[55vw] sm:w-[360px]"
options={[
...chatbots.map((chatbot) => ({
label: chatbot.name,
value: chatbot.id,
})),
{ label: 'None', value: '' },
]}
placeholder="Select chatbot"
onSelect={(chatbot: { label: string; value: string }) => {
setSelectedChatbot(
chatbots.find((item) => item.id === chatbot.value),
);
}}
selectedValue={
(selectedChatbot && {
label: selectedChatbot.name,
value: selectedChatbot.id,
}) ||
null
}
rounded="3xl"
border="border"
/>
</div>
<div className="mt-8 w-full flex flex-col sm:flex-row gap-3">
<div className="h-[345px] sm:w-1/2 w-full px-6 py-5 border rounded-2xl border-silver 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">
Messages
</p>
<Dropdown
size="w-[125px]"
options={filterOptions}
placeholder="Filter"
onSelect={(selectedOption: {
label: string;
value: string;
}) => {
setMessagesFilter(selectedOption);
}}
selectedValue={messagesFilter ?? null}
rounded="3xl"
border="border"
contentSize="text-sm"
/>
</div>
<div className="mt-px relative h-[245px] w-full">
<div
id="legend-container-1"
className="flex flex-row items-center justify-end"
></div>
<AnalyticsChart
data={{
labels: Object.keys(messagesData || {}).map((item) =>
formatDate(item),
),
datasets: [
{
label: 'Messages',
data: Object.values(messagesData || {}),
backgroundColor: '#7D54D1',
},
],
}}
legendID="legend-container-1"
maxTicksLimitInX={8}
isStacked={false}
/>
</div>
</div>
<div className="h-[345px] sm:w-1/2 w-full px-6 py-5 border rounded-2xl border-silver 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">
Token Usage
</p>
<Dropdown
size="w-[125px]"
options={filterOptions}
placeholder="Filter"
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>
<AnalyticsChart
data={{
labels: Object.keys(tokenUsageData || {}).map((item) =>
formatDate(item),
),
datasets: [
{
label: 'Tokens',
data: Object.values(tokenUsageData || {}),
backgroundColor: '#7D54D1',
},
],
}}
legendID="legend-container-2"
maxTicksLimitInX={8}
isStacked={false}
/>
</div>
</div>
</div>
<div className="mt-8 w-full">
<div className="h-[345px] w-full px-6 py-5 border rounded-2xl border-silver 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">
User Feedback
</p>
<Dropdown
size="w-[125px]"
options={filterOptions}
placeholder="Filter"
onSelect={(selectedOption: {
label: string;
value: string;
}) => {
setFeedbackFilter(selectedOption);
}}
selectedValue={feedbackFilter ?? null}
rounded="3xl"
border="border"
contentSize="text-sm"
/>
</div>
<div className="mt-px relative h-[245px] w-full">
<div
id="legend-container-3"
className="flex flex-row items-center justify-end"
></div>
<AnalyticsChart
data={{
labels: Object.keys(feedbackData || {}).map((item) =>
formatDate(item),
),
datasets: [
{
label: 'Positive',
data: Object.values(feedbackData || {}).map(
(item) => item.positive,
),
backgroundColor: '#8BD154',
},
{
label: 'Negative',
data: Object.values(feedbackData || {}).map(
(item) => item.negative,
),
backgroundColor: '#D15454',
},
],
}}
legendID="legend-container-3"
maxTicksLimitInX={10}
isStacked={true}
/>
</div>
</div>
</div>
</div>
</div>
);
}
type AnalyticsChartProps = {
data: ChartData<'bar'>;
legendID: string;
maxTicksLimitInX: number;
isStacked: boolean;
};
function AnalyticsChart({
data,
legendID,
maxTicksLimitInX,
isStacked,
}: AnalyticsChartProps) {
const options = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false,
},
htmlLegend: {
containerID: legendID,
},
},
scales: {
x: {
grid: {
lineWidth: 0.2,
color: '#C4C4C4',
},
border: {
width: 0.2,
color: '#C4C4C4',
},
ticks: {
maxTicksLimit: maxTicksLimitInX,
},
stacked: isStacked,
},
y: {
grid: {
lineWidth: 0.2,
color: '#C4C4C4',
},
border: {
width: 0.2,
color: '#C4C4C4',
},
stacked: isStacked,
},
},
};
return <Bar options={options} plugins={[htmlLegendPlugin]} data={data} />;
}

View File

@@ -89,7 +89,7 @@ export default function General() {
changeLanguage(selectedLanguage?.value);
}, [selectedLanguage, changeLanguage]);
return (
<div className="mt-[59px]">
<div className="mt-12">
<div className="mb-5">
<p className="font-bold text-jet dark:text-bright-gray">
{t('settings.general.selectTheme')}

View File

@@ -0,0 +1,175 @@
import React from 'react';
import userService from '../api/services/userService';
import ChevronRight from '../assets/chevron-right.svg';
import Dropdown from '../components/Dropdown';
import { APIKeyData, LogData } from './types';
import CoppyButton from '../components/CopyButton';
export default function Logs() {
const [chatbots, setChatbots] = React.useState<APIKeyData[]>([]);
const [selectedChatbot, setSelectedChatbot] =
React.useState<APIKeyData | null>();
const [logs, setLogs] = React.useState<LogData[]>([]);
const [page, setPage] = React.useState(1);
const [hasMore, setHasMore] = React.useState(true);
const fetchChatbots = async () => {
try {
const response = await userService.getAPIKeys();
if (!response.ok) {
throw new Error('Failed to fetch Chatbots');
}
const chatbots = await response.json();
setChatbots(chatbots);
} catch (error) {
console.error(error);
}
};
const fetchLogs = async () => {
try {
const response = await userService.getLogs({
page: page,
api_key_id: selectedChatbot?.id,
page_size: 10,
});
if (!response.ok) {
throw new Error('Failed to fetch logs');
}
const olderLogs = await response.json();
setLogs([...logs, ...olderLogs.logs]);
setHasMore(olderLogs.has_more);
} catch (error) {
console.error(error);
}
};
React.useEffect(() => {
fetchChatbots();
}, []);
React.useEffect(() => {
if (hasMore) fetchLogs();
}, [page, selectedChatbot]);
return (
<div className="mt-12">
<div className="flex flex-col items-start">
<div className="flex flex-col gap-3">
<p className="font-bold text-jet dark:text-bright-gray">
Filter by chatbot
</p>
<Dropdown
size="w-[55vw] sm:w-[360px]"
options={[
...chatbots.map((chatbot) => ({
label: chatbot.name,
value: chatbot.id,
})),
{ label: 'None', value: '' },
]}
placeholder="Select chatbot"
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"
/>
</div>
</div>
<div className="mt-8">
<LogsTable logs={logs} setPage={setPage} />
</div>
</div>
);
}
type LogsTableProps = {
logs: LogData[];
setPage: React.Dispatch<React.SetStateAction<number>>;
};
function LogsTable({ logs, setPage }: LogsTableProps) {
const observerRef = React.useRef<any>();
const firstObserver = React.useCallback((node: HTMLDivElement) => {
if (observerRef.current) {
observerRef.current = new IntersectionObserver((enteries) => {
if (enteries[0].isIntersecting) setPage((prev) => prev + 1);
});
}
if (node && observerRef.current) observerRef.current.observe(node);
}, []);
return (
<div className="logs-table border rounded-2xl h-[55vh] w-full overflow-hidden border-silver dark:border-silver/40">
<div className="h-8 bg-black/10 dark:bg-chinese-black flex flex-col items-start justify-center">
<p className="px-3 text-xs dark:text-gray-6000">
API generated / chatbot conversations
</p>
</div>
<div
ref={observerRef}
className="flex flex-col items-start h-[51vh] overflow-y-auto bg-transparent flex-grow gap-px"
>
{logs.map((log, index) => {
if (index === logs.length - 1) {
return (
<div ref={firstObserver} key={index}>
<Log log={log} />
</div>
);
} else return <Log key={index} log={log} />;
})}
</div>
</div>
);
}
function Log({ log }: { log: LogData }) {
const logLevelColor = {
info: 'text-green-500',
error: 'text-red-500',
warning: 'text-yellow-500',
};
const { id, action, timestamp, ...filteredLog } = log;
return (
<details className="group bg-transparent [&_summary::-webkit-details-marker]:hidden w-full hover:bg-[#F9F9F9] hover:dark:bg-dark-charcoal">
<summary className="flex flex-row items-center gap-2 text-gray-900 cursor-pointer p-2 group-open:bg-[#F9F9F9] dark:group-open:bg-dark-charcoal">
<img
src={ChevronRight}
alt="chevron-right"
className="w-3 h-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>
<h2 className="text-xs text-[#913400] dark:text-[#DF5200]">{`[${log.action}]`}</h2>
<h2
className={`text-xs ${logLevelColor[log.level]}`}
>{`${log.question}`}</h2>
</span>
</summary>
<div className="px-4 group-open:bg-[#F9F9F9] dark:group-open:bg-dark-charcoal">
<p className="px-2 leading-relaxed text-gray-700 dark:text-gray-400 text-xs">
{JSON.stringify(filteredLog, null, 2)}
</p>
<div className="my-px w-8">
<CoppyButton
text={JSON.stringify(filteredLog)}
colorLight="transparent"
/>
</div>
</div>
</details>
);
}

View File

@@ -11,9 +11,11 @@ import {
selectSourceDocs,
setSourceDocs,
} from '../preferences/preferenceSlice';
import Analytics from './Analytics';
import APIKeys from './APIKeys';
import Documents from './Documents';
import General from './General';
import Logs from './Logs';
import Widgets from './Widgets';
export default function Settings() {
@@ -23,6 +25,8 @@ export default function Settings() {
t('settings.general.label'),
t('settings.documents.label'),
t('settings.apiKeys.label'),
t('settings.analytics.label'),
t('settings.logs.label'),
];
const [activeTab, setActiveTab] = React.useState(t('settings.general.label'));
const [widgetScreenshot, setWidgetScreenshot] = React.useState<File | null>(
@@ -128,6 +132,10 @@ export default function Settings() {
);
case t('settings.apiKeys.label'):
return <APIKeys />;
case t('settings.analytics.label'):
return <Analytics />;
case t('settings.logs.label'):
return <Logs />;
default:
return null;
}

View File

@@ -0,0 +1,20 @@
export type APIKeyData = {
id: string;
name: string;
key: string;
source: string;
prompt_id: string;
chunks: string;
};
export type LogData = {
id: string;
action: string;
level: 'info' | 'error' | 'warning';
user: string;
question: string;
response: string;
sources: Record<string, any>[];
retriever_params: Record<string, any>;
timestamp: string;
};

View File

@@ -275,7 +275,7 @@ function Upload({
} else
setRedditData({
...redditData,
[name]: value,
[name]: name === 'number_posts' ? parseInt(value) : value,
});
};
@@ -321,6 +321,7 @@ function Upload({
colorVariant="gray"
value={docName}
onChange={(e) => setDocName(e.target.value)}
borderVariant="thin"
></Input>
<div className="relative bottom-12 left-2 mt-[-20px]">
<span className="bg-white px-2 text-xs text-gray-4000 dark:bg-outer-space dark:text-silver">
@@ -356,6 +357,7 @@ function Upload({
{activeTab === 'remote' && (
<>
<Dropdown
border="border"
options={urlOptions}
selectedValue={urlType}
onSelect={(value: { label: string; value: string }) =>
@@ -371,6 +373,7 @@ function Upload({
type="text"
value={urlName}
onChange={(e) => setUrlName(e.target.value)}
borderVariant="thin"
></Input>
<div className="relative bottom-12 left-2 mt-[-20px]">
<span className="bg-white px-2 text-xs text-gray-4000 dark:bg-outer-space dark:text-silver">
@@ -382,6 +385,7 @@ function Upload({
type="text"
value={url}
onChange={(e) => setUrl(e.target.value)}
borderVariant="thin"
></Input>
<div className="relative bottom-12 left-2 mt-[-20px]">
<span className="bg-white px-2 text-xs text-gray-4000 dark:bg-outer-space dark:text-silver">
@@ -390,68 +394,83 @@ function Upload({
</div>
</>
) : (
<>
<Input
placeholder="Enter client ID"
type="text"
name="client_id"
value={redditData.client_id}
onChange={handleChange}
></Input>
<div className="relative bottom-12 left-2 mt-[-20px]">
<span className="bg-white px-2 text-xs text-gray-4000 dark:bg-outer-space dark:text-silver">
{t('modals.uploadDoc.reddit.id')}
</span>
<div className="flex flex-col gap-1 mt-2">
<div>
<Input
placeholder="Enter client ID"
type="text"
name="client_id"
value={redditData.client_id}
onChange={handleChange}
borderVariant="thin"
></Input>
<div className="relative bottom-[52px] left-2">
<span className="bg-white px-2 text-xs text-gray-4000 dark:bg-outer-space dark:text-silver">
{t('modals.uploadDoc.reddit.id')}
</span>
</div>
</div>
<Input
placeholder="Enter client secret"
type="text"
name="client_secret"
value={redditData.client_secret}
onChange={handleChange}
></Input>
<div className="relative bottom-12 left-2 mt-[-20px]">
<span className="bg-white px-2 text-xs text-gray-4000 dark:bg-outer-space dark:text-silver">
{t('modals.uploadDoc.reddit.secret')}
</span>
<div>
<Input
placeholder="Enter client secret"
type="text"
name="client_secret"
value={redditData.client_secret}
onChange={handleChange}
borderVariant="thin"
></Input>
<div className="relative bottom-[52px] left-2">
<span className="bg-white px-2 text-xs text-gray-4000 dark:bg-outer-space dark:text-silver">
{t('modals.uploadDoc.reddit.secret')}
</span>
</div>
</div>
<Input
placeholder="Enter user agent"
type="text"
name="user_agent"
value={redditData.user_agent}
onChange={handleChange}
></Input>
<div className="relative bottom-12 left-2 mt-[-20px]">
<span className="bg-white px-2 text-xs text-gray-4000 dark:bg-outer-space dark:text-silver">
{t('modals.uploadDoc.reddit.agent')}
</span>
<div>
<Input
placeholder="Enter user agent"
type="text"
name="user_agent"
value={redditData.user_agent}
onChange={handleChange}
borderVariant="thin"
></Input>
<div className="relative bottom-[52px] left-2">
<span className="bg-white px-2 text-xs text-gray-4000 dark:bg-outer-space dark:text-silver">
{t('modals.uploadDoc.reddit.agent')}
</span>
</div>
</div>
<Input
placeholder="Enter search queries"
type="text"
name="search_queries"
value={redditData.search_queries}
onChange={handleChange}
></Input>
<div className="relative bottom-12 left-2 mt-[-20px]">
<span className="bg-white px-2 text-xs text-gray-4000 dark:bg-outer-space dark:text-silver">
{t('modals.uploadDoc.reddit.searchQueries')}
</span>
<div>
<Input
placeholder="Enter search queries"
type="text"
name="search_queries"
value={redditData.search_queries}
onChange={handleChange}
borderVariant="thin"
></Input>
<div className="relative bottom-[52px] left-2">
<span className="bg-white px-2 text-xs text-gray-4000 dark:bg-outer-space dark:text-silver">
{t('modals.uploadDoc.reddit.searchQueries')}
</span>
</div>
</div>
<Input
placeholder="Enter number of posts"
type="number"
name="number_posts"
value={redditData.number_posts}
onChange={handleChange}
></Input>
<div className="relative bottom-12 left-2 mt-[-20px]">
<span className="bg-white px-2 text-xs text-gray-4000 dark:bg-outer-space dark:text-silver">
{t('modals.uploadDoc.reddit.numberOfPosts')}
</span>
<div>
<Input
placeholder="Enter number of posts"
type="number"
name="number_posts"
value={redditData.number_posts}
onChange={handleChange}
borderVariant="thin"
></Input>
<div className="relative bottom-[52px] left-2">
<span className="bg-white px-2 text-xs text-gray-4000 dark:bg-outer-space dark:text-silver">
{t('modals.uploadDoc.reddit.numberOfPosts')}
</span>
</div>
</div>
</>
</div>
)}
</>
)}

View File

@@ -0,0 +1,77 @@
import { Chart as ChartJS } from 'chart.js';
const getOrCreateLegendList = (
chart: ChartJS,
id: string,
): HTMLUListElement => {
const legendContainer = document.getElementById(id);
let listContainer = legendContainer?.querySelector('ul') as HTMLUListElement;
if (!listContainer) {
listContainer = document.createElement('ul');
listContainer.style.display = 'flex';
listContainer.style.flexDirection = 'row';
listContainer.style.margin = '0';
listContainer.style.padding = '0';
legendContainer?.appendChild(listContainer);
}
return listContainer;
};
export const htmlLegendPlugin = {
id: 'htmlLegend',
afterUpdate(chart: ChartJS, args: any, options: { containerID: string }) {
const ul = getOrCreateLegendList(chart, options.containerID);
while (ul.firstChild) {
ul.firstChild.remove();
}
const items =
chart.options.plugins?.legend?.labels?.generateLabels?.(chart) || [];
items.forEach((item: any) => {
const li = document.createElement('li');
li.style.alignItems = 'center';
li.style.cursor = 'pointer';
li.style.display = 'flex';
li.style.flexDirection = 'row';
li.style.marginLeft = '10px';
li.onclick = () => {
chart.setDatasetVisibility(
item.datasetIndex,
!chart.isDatasetVisible(item.datasetIndex),
);
chart.update();
};
const boxSpan = document.createElement('span');
boxSpan.style.background = item.fillStyle;
boxSpan.style.borderColor = item.strokeStyle;
boxSpan.style.borderWidth = item.lineWidth + 'px';
boxSpan.style.display = 'inline-block';
boxSpan.style.flexShrink = '0';
boxSpan.style.height = '10px';
boxSpan.style.marginRight = '10px';
boxSpan.style.width = '10px';
boxSpan.style.borderRadius = '10px';
const textContainer = document.createElement('p');
textContainer.style.fontSize = '12px';
textContainer.style.color = item.fontColor;
textContainer.style.margin = '0';
textContainer.style.padding = '0';
textContainer.style.textDecoration = item.hidden ? 'line-through' : '';
const text = document.createTextNode(item.text);
textContainer.appendChild(text);
li.appendChild(boxSpan);
li.appendChild(textContainer);
ul.appendChild(li);
});
},
};

View File

@@ -0,0 +1,20 @@
export function formatDate(dateString: string): string {
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(dateString)) {
const dateTime = new Date(dateString);
return dateTime.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
});
} else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/.test(dateString)) {
const dateTime = new Date(dateString);
return dateTime.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
});
} else if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
} else {
return dateString;
}
}