feat: analytics dashboard with respective endpoints

This commit is contained in:
Siddhant Rai
2024-09-07 14:44:35 +05:30
parent 28e06fa684
commit ac930d5504
17 changed files with 992 additions and 12 deletions

View File

@@ -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

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",

View File

@@ -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',

View File

@@ -23,6 +23,12 @@ 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),
};
export default userService;

View File

@@ -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<HTMLDivElement>(null);
const [isOpen, setIsOpen] = React.useState(false);
@@ -84,9 +86,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
@@ -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

View File

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

View File

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

View File

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

View File

@@ -62,6 +62,9 @@
"key": "API 密钥",
"sourceDoc": "源文档",
"createNew": "创建新的"
},
"analytics": {
"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

@@ -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<File | null>(
@@ -129,6 +131,8 @@ export default function Settings() {
);
case t('settings.apiKeys.label'):
return <APIKeys />;
case t('settings.analytics.label'):
return <Analytics />;
default:
return null;
}

View File

@@ -0,0 +1,8 @@
export type APIKeyData = {
id: string;
name: string;
key: string;
source: string;
prompt_id: string;
chunks: string;
};

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;
}
}