diff --git a/application/api/user/routes.py b/application/api/user/routes.py index 91b90d6a..dbe8a0b2 100644 --- a/application/api/user/routes.py +++ b/application/api/user/routes.py @@ -1,14 +1,17 @@ +import datetime import os -import uuid import shutil -from flask import Blueprint, request, jsonify +import uuid from urllib.parse import urlparse + import requests -from pymongo import MongoClient -from bson.objectid import ObjectId 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 @@ -21,6 +24,7 @@ vectors_collection = db["vectors"] 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 = Blueprint("user", __name__) @@ -30,6 +34,27 @@ current_dir = os.path.dirname( ) +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"]) def delete_conversation(): # deletes a conversation from the database @@ -96,6 +121,7 @@ def api_feedback(): "question": question, "answer": answer, "feedback": feedback, + "timestamp": datetime.datetime.now(datetime.timezone.utc), } ) return {"status": "ok"} @@ -697,3 +723,407 @@ 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 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/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index af2fb920..09ddd2f1 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -12,6 +12,9 @@ const endpoints = { SINGLE_PROMPT: (id: string) => `/api/get_single_prompt?id=${id}`, DELETE_PATH: (docPath: string) => `/api/delete_old?path=${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', }, CONVERSATION: { ANSWER: '/api/answer', diff --git a/frontend/src/api/services/userService.ts b/frontend/src/api/services/userService.ts index 193fe6ad..ba9d7bb8 100644 --- a/frontend/src/api/services/userService.ts +++ b/frontend/src/api/services/userService.ts @@ -23,6 +23,12 @@ 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), }; export default userService; diff --git a/frontend/src/components/Dropdown.tsx b/frontend/src/components/Dropdown.tsx index adf17889..c5961aaa 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[] @@ -41,6 +42,7 @@ function Dropdown({ showDelete?: boolean; onDelete?: (value: string) => void; placeholder?: string; + contentSize?: string; }) { const dropdownRef = React.useRef(null); const [isOpen, setIsOpen] = React.useState(false); @@ -84,9 +86,9 @@ function Dropdown({ ) : ( {selectedValue && 'label' in selectedValue ? selectedValue.label @@ -123,7 +125,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/locale/en.json b/frontend/src/locale/en.json index 773768bd..7361d212 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -62,6 +62,9 @@ "key": "API Key", "sourceDoc": "Source Document", "createNew": "Create New" + }, + "analytics": { + "label": "Analytics" } }, "modals": { diff --git a/frontend/src/locale/es.json b/frontend/src/locale/es.json index cb455ab6..293c4117 100644 --- a/frontend/src/locale/es.json +++ b/frontend/src/locale/es.json @@ -62,6 +62,9 @@ "key": "Clave de API", "sourceDoc": "Documento Fuente", "createNew": "Crear Nuevo" + }, + "analytics": { + "label": "Analítica" } }, "modals": { diff --git a/frontend/src/locale/jp.json b/frontend/src/locale/jp.json index 6c870069..99fa85dd 100644 --- a/frontend/src/locale/jp.json +++ b/frontend/src/locale/jp.json @@ -62,6 +62,9 @@ "key": "APIキー", "sourceDoc": "ソースドキュメント", "createNew": "新規作成" + }, + "analytics": { + "label": "分析" } }, "modals": { diff --git a/frontend/src/locale/zh.json b/frontend/src/locale/zh.json index cfe9d180..cbd82056 100644 --- a/frontend/src/locale/zh.json +++ b/frontend/src/locale/zh.json @@ -62,6 +62,9 @@ "key": "API 密钥", "sourceDoc": "源文档", "createNew": "创建新的" + }, + "analytics": { + "label": "分析" } }, "modals": { diff --git a/frontend/src/settings/APIKeys.tsx b/frontend/src/settings/APIKeys.tsx index dc220013..923326f3 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/index.tsx b/frontend/src/settings/index.tsx index 226ebb3b..bffe9f7a 100644 --- a/frontend/src/settings/index.tsx +++ b/frontend/src/settings/index.tsx @@ -11,6 +11,7 @@ import { selectSourceDocs, setSourceDocs, } from '../preferences/preferenceSlice'; +import Analytics from './Analytics'; import APIKeys from './APIKeys'; import Documents from './Documents'; import General from './General'; @@ -23,6 +24,7 @@ export default function Settings() { t('settings.general.label'), t('settings.documents.label'), t('settings.apiKeys.label'), + t('settings.analytics.label'), ]; const [activeTab, setActiveTab] = React.useState(t('settings.general.label')); const [widgetScreenshot, setWidgetScreenshot] = React.useState( @@ -129,6 +131,8 @@ export default function Settings() { ); case t('settings.apiKeys.label'): return ; + case t('settings.analytics.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..7c04dab9 --- /dev/null +++ b/frontend/src/settings/types/index.ts @@ -0,0 +1,8 @@ +export type APIKeyData = { + id: string; + name: string; + key: string; + source: string; + prompt_id: string; + chunks: string; +}; 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; + } +}