diff --git a/application/api/answer/routes.py b/application/api/answer/routes.py index 89a26b1d..da9f2775 100644 --- a/application/api/answer/routes.py +++ b/application/api/answer/routes.py @@ -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" diff --git a/application/api/user/routes.py b/application/api/user/routes.py index 91e343fc..a35275bf 100644 --- a/application/api/user/routes.py +++ b/application/api/user/routes.py @@ -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/", 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, + ) diff --git a/application/retriever/base.py b/application/retriever/base.py index 4a37e810..fd99dbdd 100644 --- a/application/retriever/base.py +++ b/application/retriever/base.py @@ -12,3 +12,7 @@ class BaseRetriever(ABC): @abstractmethod def search(self, *args, **kwargs): pass + + @abstractmethod + def get_params(self): + pass diff --git a/application/retriever/brave_search.py b/application/retriever/brave_search.py index 5d1e1566..29666a57 100644 --- a/application/retriever/brave_search.py +++ b/application/retriever/brave_search.py @@ -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 + } diff --git a/application/retriever/classic_rag.py b/application/retriever/classic_rag.py index 3794fd5e..b87b5852 100644 --- a/application/retriever/classic_rag.py +++ b/application/retriever/classic_rag.py @@ -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 + } diff --git a/application/retriever/duckduck_search.py b/application/retriever/duckduck_search.py index 6d2965f5..d746ecaa 100644 --- a/application/retriever/duckduck_search.py +++ b/application/retriever/duckduck_search.py @@ -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 + } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0f7675fa..d342ab5a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index e45fbd36..7bf983aa 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/public/fonts/IBMPlexMono-Medium.ttf b/frontend/public/fonts/IBMPlexMono-Medium.ttf new file mode 100644 index 00000000..39f178db Binary files /dev/null and b/frontend/public/fonts/IBMPlexMono-Medium.ttf differ diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index c06ac3d2..742056b5 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -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', diff --git a/frontend/src/api/services/userService.ts b/frontend/src/api/services/userService.ts index 193fe6ad..6d228177 100644 --- a/frontend/src/api/services/userService.ts +++ b/frontend/src/api/services/userService.ts @@ -23,6 +23,14 @@ const userService = { apiClient.get(endpoints.USER.DELETE_PATH(docPath)), getTaskStatus: (task_id: string): Promise => apiClient.get(endpoints.USER.TASK_STATUS(task_id)), + getMessageAnalytics: (data: any): Promise => + apiClient.post(endpoints.USER.MESSAGE_ANALYTICS, data), + getTokenAnalytics: (data: any): Promise => + apiClient.post(endpoints.USER.TOKEN_ANALYTICS, data), + getFeedbackAnalytics: (data: any): Promise => + apiClient.post(endpoints.USER.FEEDBACK_ANALYTICS, data), + getLogs: (data: any): Promise => + apiClient.post(endpoints.USER.LOGS, data), }; export default userService; diff --git a/frontend/src/assets/chevron-right.svg b/frontend/src/assets/chevron-right.svg new file mode 100644 index 00000000..1463b6f7 --- /dev/null +++ b/frontend/src/assets/chevron-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/components/CopyButton.tsx b/frontend/src/components/CopyButton.tsx index e28fdcaf..e13f9133 100644 --- a/frontend/src/components/CopyButton.tsx +++ b/frontend/src/components/CopyButton.tsx @@ -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 }) {
{copied ? ( diff --git a/frontend/src/components/Dropdown.tsx b/frontend/src/components/Dropdown.tsx index 660b0c94..3daa3911 100644 --- a/frontend/src/components/Dropdown.tsx +++ b/frontend/src/components/Dropdown.tsx @@ -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(null); const [isOpen, setIsOpen] = React.useState(false); @@ -85,9 +87,9 @@ function Dropdown({ ) : ( {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 diff --git a/frontend/src/components/Input.tsx b/frontend/src/components/Input.tsx index 56ca1d52..17e60190 100644 --- a/frontend/src/components/Input.tsx +++ b/frontend/src/components/Input.tsx @@ -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 ( 3 && (
setIsSidebarOpen(true)} >

{`View ${ diff --git a/frontend/src/index.css b/frontend/src/index.css index 6ae4b762..2009ec9c 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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; +} diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index 773768bd..645703a2 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -62,6 +62,12 @@ "key": "API Key", "sourceDoc": "Source Document", "createNew": "Create New" + }, + "analytics": { + "label": "Analytics" + }, + "logs": { + "label": "Logs" } }, "modals": { diff --git a/frontend/src/locale/es.json b/frontend/src/locale/es.json index cb455ab6..49aa5d53 100644 --- a/frontend/src/locale/es.json +++ b/frontend/src/locale/es.json @@ -62,6 +62,12 @@ "key": "Clave de API", "sourceDoc": "Documento Fuente", "createNew": "Crear Nuevo" + }, + "analytics": { + "label": "Analítica" + }, + "logs": { + "label": "Registros" } }, "modals": { diff --git a/frontend/src/locale/jp.json b/frontend/src/locale/jp.json index 6c870069..9e367330 100644 --- a/frontend/src/locale/jp.json +++ b/frontend/src/locale/jp.json @@ -62,6 +62,12 @@ "key": "APIキー", "sourceDoc": "ソースドキュメント", "createNew": "新規作成" + }, + "analytics": { + "label": "分析" + }, + "logs": { + "label": "ログ" } }, "modals": { diff --git a/frontend/src/locale/zh.json b/frontend/src/locale/zh.json index cfe9d180..81eff996 100644 --- a/frontend/src/locale/zh.json +++ b/frontend/src/locale/zh.json @@ -62,6 +62,12 @@ "key": "API 密钥", "sourceDoc": "源文档", "createNew": "创建新的" + }, + "analytics": { + "label": "分析" + }, + "logs": { + "label": "日志" } }, "modals": { diff --git a/frontend/src/settings/APIKeys.tsx b/frontend/src/settings/APIKeys.tsx index 160f09fc..ebb32268 100644 --- a/frontend/src/settings/APIKeys.tsx +++ b/frontend/src/settings/APIKeys.tsx @@ -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([]); const handleFetchKeys = async () => { try { diff --git a/frontend/src/settings/Analytics.tsx b/frontend/src/settings/Analytics.tsx new file mode 100644 index 00000000..a385c471 --- /dev/null +++ b/frontend/src/settings/Analytics.tsx @@ -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 | null>(null); + const [tokenUsageData, setTokenUsageData] = React.useState | null>(null); + const [feedbackData, setFeedbackData] = React.useState | null>(null); + const [chatbots, setChatbots] = React.useState([]); + const [selectedChatbot, setSelectedChatbot] = + React.useState(); + 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 ( +

+
+
+

+ Filter by 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" + /> +
+
+
+
+

+ Messages +

+ { + setMessagesFilter(selectedOption); + }} + selectedValue={messagesFilter ?? null} + rounded="3xl" + border="border" + contentSize="text-sm" + /> +
+
+
+ + formatDate(item), + ), + datasets: [ + { + label: 'Messages', + data: Object.values(messagesData || {}), + backgroundColor: '#7D54D1', + }, + ], + }} + legendID="legend-container-1" + maxTicksLimitInX={8} + isStacked={false} + /> +
+
+
+
+

+ Token Usage +

+ { + setTokenUsageFilter(selectedOption); + }} + selectedValue={tokenUsageFilter ?? null} + rounded="3xl" + border="border" + contentSize="text-sm" + /> +
+
+
+ + formatDate(item), + ), + datasets: [ + { + label: 'Tokens', + data: Object.values(tokenUsageData || {}), + backgroundColor: '#7D54D1', + }, + ], + }} + legendID="legend-container-2" + maxTicksLimitInX={8} + isStacked={false} + /> +
+
+
+
+
+
+

+ User Feedback +

+ { + setFeedbackFilter(selectedOption); + }} + selectedValue={feedbackFilter ?? null} + rounded="3xl" + border="border" + contentSize="text-sm" + /> +
+
+
+ + 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} + /> +
+
+
+
+
+ ); +} + +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 ; +} diff --git a/frontend/src/settings/General.tsx b/frontend/src/settings/General.tsx index 2d0c466d..e0a24a75 100644 --- a/frontend/src/settings/General.tsx +++ b/frontend/src/settings/General.tsx @@ -89,7 +89,7 @@ export default function General() { changeLanguage(selectedLanguage?.value); }, [selectedLanguage, changeLanguage]); return ( -
+

{t('settings.general.selectTheme')} diff --git a/frontend/src/settings/Logs.tsx b/frontend/src/settings/Logs.tsx new file mode 100644 index 00000000..58ab930d --- /dev/null +++ b/frontend/src/settings/Logs.tsx @@ -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([]); + const [selectedChatbot, setSelectedChatbot] = + React.useState(); + const [logs, setLogs] = React.useState([]); + 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 ( +

+
+
+

+ Filter by 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" + /> +
+
+
+ +
+
+ ); +} + +type LogsTableProps = { + logs: LogData[]; + setPage: React.Dispatch>; +}; + +function LogsTable({ logs, setPage }: LogsTableProps) { + const observerRef = React.useRef(); + 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 ( +
+
+

+ API generated / chatbot conversations +

+
+
+ {logs.map((log, index) => { + if (index === logs.length - 1) { + return ( +
+ +
+ ); + } else return ; + })} +
+
+ ); +} + +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 ( +
+ + chevron-right + +

{`${log.timestamp}`}

+

{`[${log.action}]`}

+

{`${log.question}`}

+
+
+
+

+ {JSON.stringify(filteredLog, null, 2)} +

+
+ +
+
+
+ ); +} diff --git a/frontend/src/settings/index.tsx b/frontend/src/settings/index.tsx index 5bef3b43..8998d6d0 100644 --- a/frontend/src/settings/index.tsx +++ b/frontend/src/settings/index.tsx @@ -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( @@ -128,6 +132,10 @@ export default function Settings() { ); case t('settings.apiKeys.label'): return ; + case t('settings.analytics.label'): + return ; + case t('settings.logs.label'): + return ; default: return null; } diff --git a/frontend/src/settings/types/index.ts b/frontend/src/settings/types/index.ts new file mode 100644 index 00000000..52a58f23 --- /dev/null +++ b/frontend/src/settings/types/index.ts @@ -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[]; + retriever_params: Record; + timestamp: string; +}; diff --git a/frontend/src/upload/Upload.tsx b/frontend/src/upload/Upload.tsx index 049170d1..ccd06a22 100644 --- a/frontend/src/upload/Upload.tsx +++ b/frontend/src/upload/Upload.tsx @@ -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" >
@@ -356,6 +357,7 @@ function Upload({ {activeTab === 'remote' && ( <> @@ -371,6 +373,7 @@ function Upload({ type="text" value={urlName} onChange={(e) => setUrlName(e.target.value)} + borderVariant="thin" >
@@ -382,6 +385,7 @@ function Upload({ type="text" value={url} onChange={(e) => setUrl(e.target.value)} + borderVariant="thin" >
@@ -390,68 +394,83 @@ function Upload({
) : ( - <> - -
- - {t('modals.uploadDoc.reddit.id')} - +
+
+ +
+ + {t('modals.uploadDoc.reddit.id')} + +
- -
- - {t('modals.uploadDoc.reddit.secret')} - +
+ +
+ + {t('modals.uploadDoc.reddit.secret')} + +
- -
- - {t('modals.uploadDoc.reddit.agent')} - +
+ +
+ + {t('modals.uploadDoc.reddit.agent')} + +
- -
- - {t('modals.uploadDoc.reddit.searchQueries')} - +
+ +
+ + {t('modals.uploadDoc.reddit.searchQueries')} + +
- -
- - {t('modals.uploadDoc.reddit.numberOfPosts')} - +
+ +
+ + {t('modals.uploadDoc.reddit.numberOfPosts')} + +
- +
)} )} diff --git a/frontend/src/utils/chartUtils.ts b/frontend/src/utils/chartUtils.ts new file mode 100644 index 00000000..eedfe892 --- /dev/null +++ b/frontend/src/utils/chartUtils.ts @@ -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); + }); + }, +}; diff --git a/frontend/src/utils/dateTimeUtils.ts b/frontend/src/utils/dateTimeUtils.ts new file mode 100644 index 00000000..7f89007c --- /dev/null +++ b/frontend/src/utils/dateTimeUtils.ts @@ -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; + } +}