mirror of
https://github.com/arc53/DocsGPT.git
synced 2025-11-30 00:53:14 +00:00
Merge: branch main from upstream
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -23,6 +23,14 @@ const userService = {
|
||||
apiClient.get(endpoints.USER.DELETE_PATH(docPath)),
|
||||
getTaskStatus: (task_id: string): Promise<any> =>
|
||||
apiClient.get(endpoints.USER.TASK_STATUS(task_id)),
|
||||
getMessageAnalytics: (data: any): Promise<any> =>
|
||||
apiClient.post(endpoints.USER.MESSAGE_ANALYTICS, data),
|
||||
getTokenAnalytics: (data: any): Promise<any> =>
|
||||
apiClient.post(endpoints.USER.TOKEN_ANALYTICS, data),
|
||||
getFeedbackAnalytics: (data: any): Promise<any> =>
|
||||
apiClient.post(endpoints.USER.FEEDBACK_ANALYTICS, data),
|
||||
getLogs: (data: any): Promise<any> =>
|
||||
apiClient.post(endpoints.USER.LOGS, data),
|
||||
};
|
||||
|
||||
export default userService;
|
||||
|
||||
3
frontend/src/assets/chevron-right.svg
Normal file
3
frontend/src/assets/chevron-right.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="7" height="12" viewBox="0 0 7 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.29154 4.88202L1.70154 0.29202C1.60896 0.199438 1.49905 0.125998 1.37808 0.0758932C1.25712 0.0257882 1.12747 -2.37536e-07 0.99654 -2.44235e-07C0.86561 -2.50934e-07 0.735961 0.0257882 0.614997 0.0758931C0.494033 0.125998 0.384122 0.199438 0.29154 0.29202C0.198958 0.384602 0.125519 0.494513 0.0754137 0.615477C0.0253086 0.736441 -0.00048069 0.86609 -0.000480695 0.99702C-0.000480701 1.12795 0.0253086 1.2576 0.0754136 1.37856C0.125519 1.49953 0.198958 1.60944 0.29154 1.70202L4.17154 5.59202L0.29154 9.47202C0.198958 9.5646 0.125518 9.67451 0.0754133 9.79548C0.0253082 9.91644 -0.000481091 10.0461 -0.000481097 10.177C-0.000481102 10.3079 0.0253082 10.4376 0.0754132 10.5586C0.125518 10.6795 0.198958 10.7894 0.29154 10.882C0.384121 10.9746 0.494032 11.048 0.614996 11.0981C0.73596 11.1483 0.865609 11.174 0.99654 11.174C1.12747 11.174 1.25712 11.1483 1.37808 11.0981C1.49905 11.048 1.60896 10.9746 1.70154 10.882L6.29154 6.29202C6.38424 6.19951 6.45779 6.08962 6.50797 5.96864C6.55815 5.84767 6.58398 5.71799 6.58398 5.58702C6.58398 5.45605 6.55815 5.32637 6.50797 5.2054C6.45779 5.08442 6.38424 4.97453 6.29154 4.88202Z" fill="#666666"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -1,16 +1,24 @@
|
||||
import { useState } from 'react';
|
||||
import Copy from './../assets/copy.svg?react';
|
||||
import CheckMark from './../assets/checkmark.svg?react';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function CoppyButton({ text }: { text: string }) {
|
||||
import CheckMark from '../assets/checkmark.svg?react';
|
||||
import Copy from '../assets/copy.svg?react';
|
||||
|
||||
export default function CoppyButton({
|
||||
text,
|
||||
colorLight,
|
||||
colorDark,
|
||||
}: {
|
||||
text: string;
|
||||
colorLight?: string;
|
||||
colorDark?: string;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [isCopyHovered, setIsCopyHovered] = useState(false);
|
||||
|
||||
const handleCopyClick = (text: string) => {
|
||||
copy(text);
|
||||
setCopied(true);
|
||||
// Reset copied to false after a few seconds
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 3000);
|
||||
@@ -20,8 +28,8 @@ export default function CoppyButton({ text }: { text: string }) {
|
||||
<div
|
||||
className={`flex items-center justify-center rounded-full p-2 ${
|
||||
isCopyHovered
|
||||
? 'bg-[#EEEEEE] dark:bg-purple-taupe'
|
||||
: 'bg-[#ffffff] dark:bg-transparent'
|
||||
? `bg-[#EEEEEE] dark:bg-purple-taupe`
|
||||
: `bg-[${colorLight ? colorLight : '#FFFFFF'}] dark:bg-[${colorDark ? colorDark : 'transparent'}]`
|
||||
}`}
|
||||
>
|
||||
{copied ? (
|
||||
|
||||
@@ -16,6 +16,7 @@ function Dropdown({
|
||||
showDelete,
|
||||
onDelete,
|
||||
placeholder,
|
||||
contentSize = 'text-base',
|
||||
}: {
|
||||
options:
|
||||
| string[]
|
||||
@@ -42,6 +43,7 @@ function Dropdown({
|
||||
showDelete?: boolean;
|
||||
onDelete?: (value: string) => void;
|
||||
placeholder?: string;
|
||||
contentSize?: string;
|
||||
}) {
|
||||
const dropdownRef = React.useRef<HTMLDivElement>(null);
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
@@ -85,9 +87,9 @@ function Dropdown({
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className={`overflow-hidden text-ellipsis dark:text-bright-gray ${
|
||||
className={`truncate overflow-hidden dark:text-bright-gray ${
|
||||
!selectedValue && 'text-silver dark:text-gray-400'
|
||||
}`}
|
||||
} ${contentSize}`}
|
||||
>
|
||||
{selectedValue && 'label' in selectedValue
|
||||
? selectedValue.label
|
||||
@@ -124,7 +126,7 @@ function Dropdown({
|
||||
onSelect(option);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className="ml-5 flex-1 overflow-hidden overflow-ellipsis whitespace-nowrap py-3 dark:text-light-gray"
|
||||
className={`ml-5 flex-1 overflow-hidden overflow-ellipsis whitespace-nowrap py-3 dark:text-light-gray ${contentSize}`}
|
||||
>
|
||||
{typeof option === 'string'
|
||||
? option
|
||||
|
||||
@@ -10,6 +10,7 @@ const Input = ({
|
||||
maxLength,
|
||||
className,
|
||||
colorVariant = 'silver',
|
||||
borderVariant = 'thick',
|
||||
children,
|
||||
onChange,
|
||||
onPaste,
|
||||
@@ -20,10 +21,13 @@ const Input = ({
|
||||
jet: 'border-jet',
|
||||
gray: 'border-gray-5000 dark:text-silver',
|
||||
};
|
||||
|
||||
const borderStyles = {
|
||||
thin: 'border',
|
||||
thick: 'border-2',
|
||||
};
|
||||
return (
|
||||
<input
|
||||
className={`h-[42px] w-full rounded-full border-2 px-3 outline-none dark:bg-transparent dark:text-white ${className} ${colorStyles[colorVariant]}`}
|
||||
className={`h-[42px] w-full rounded-full px-3 py-1 outline-none dark:bg-transparent dark:text-white ${className} ${colorStyles[colorVariant]} ${borderStyles[borderVariant]}`}
|
||||
type={type}
|
||||
id={id}
|
||||
name={name}
|
||||
|
||||
@@ -2,6 +2,7 @@ export type InputProps = {
|
||||
type: 'text' | 'number';
|
||||
value: string | string[] | number;
|
||||
colorVariant?: 'silver' | 'jet' | 'gray';
|
||||
borderVariant?: 'thin' | 'thick';
|
||||
isAutoFocused?: boolean;
|
||||
id?: string;
|
||||
maxLength?: number;
|
||||
|
||||
@@ -180,7 +180,7 @@ const ConversationBubble = forwardRef<
|
||||
))}
|
||||
{(sources?.length ?? 0) > 3 && (
|
||||
<div
|
||||
className="flex h-24 cursor-pointer flex-col-reverse rounded-[20px] bg-gray-1000 p-4 text-purple-30 hover:bg-[#F1F1F1] hover:text-[#6D3ECC] dark:bg-gun-metal dark:hover:bg-[#2C2E3C] dark:hover:text-[#8C67D7]"
|
||||
className="flex h-28 cursor-pointer flex-col-reverse rounded-[20px] bg-gray-1000 p-4 text-purple-30 hover:bg-[#F1F1F1] hover:text-[#6D3ECC] dark:bg-gun-metal dark:hover:bg-[#2C2E3C] dark:hover:text-[#8C67D7]"
|
||||
onClick={() => setIsSidebarOpen(true)}
|
||||
>
|
||||
<p className="ellipsis-text h-22 text-xs">{`View ${
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -62,6 +62,12 @@
|
||||
"key": "API Key",
|
||||
"sourceDoc": "Source Document",
|
||||
"createNew": "Create New"
|
||||
},
|
||||
"analytics": {
|
||||
"label": "Analytics"
|
||||
},
|
||||
"logs": {
|
||||
"label": "Logs"
|
||||
}
|
||||
},
|
||||
"modals": {
|
||||
|
||||
@@ -62,6 +62,12 @@
|
||||
"key": "Clave de API",
|
||||
"sourceDoc": "Documento Fuente",
|
||||
"createNew": "Crear Nuevo"
|
||||
},
|
||||
"analytics": {
|
||||
"label": "Analítica"
|
||||
},
|
||||
"logs": {
|
||||
"label": "Registros"
|
||||
}
|
||||
},
|
||||
"modals": {
|
||||
|
||||
@@ -62,6 +62,12 @@
|
||||
"key": "APIキー",
|
||||
"sourceDoc": "ソースドキュメント",
|
||||
"createNew": "新規作成"
|
||||
},
|
||||
"analytics": {
|
||||
"label": "分析"
|
||||
},
|
||||
"logs": {
|
||||
"label": "ログ"
|
||||
}
|
||||
},
|
||||
"modals": {
|
||||
|
||||
@@ -62,6 +62,12 @@
|
||||
"key": "API 密钥",
|
||||
"sourceDoc": "源文档",
|
||||
"createNew": "创建新的"
|
||||
},
|
||||
"analytics": {
|
||||
"label": "分析"
|
||||
},
|
||||
"logs": {
|
||||
"label": "日志"
|
||||
}
|
||||
},
|
||||
"modals": {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
390
frontend/src/settings/Analytics.tsx
Normal file
390
frontend/src/settings/Analytics.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -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')}
|
||||
|
||||
175
frontend/src/settings/Logs.tsx
Normal file
175
frontend/src/settings/Logs.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import React from 'react';
|
||||
|
||||
import userService from '../api/services/userService';
|
||||
import ChevronRight from '../assets/chevron-right.svg';
|
||||
import Dropdown from '../components/Dropdown';
|
||||
import { APIKeyData, LogData } from './types';
|
||||
import CoppyButton from '../components/CopyButton';
|
||||
|
||||
export default function Logs() {
|
||||
const [chatbots, setChatbots] = React.useState<APIKeyData[]>([]);
|
||||
const [selectedChatbot, setSelectedChatbot] =
|
||||
React.useState<APIKeyData | null>();
|
||||
const [logs, setLogs] = React.useState<LogData[]>([]);
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [hasMore, setHasMore] = React.useState(true);
|
||||
|
||||
const fetchChatbots = async () => {
|
||||
try {
|
||||
const response = await userService.getAPIKeys();
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch Chatbots');
|
||||
}
|
||||
const chatbots = await response.json();
|
||||
setChatbots(chatbots);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchLogs = async () => {
|
||||
try {
|
||||
const response = await userService.getLogs({
|
||||
page: page,
|
||||
api_key_id: selectedChatbot?.id,
|
||||
page_size: 10,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch logs');
|
||||
}
|
||||
const olderLogs = await response.json();
|
||||
setLogs([...logs, ...olderLogs.logs]);
|
||||
setHasMore(olderLogs.has_more);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchChatbots();
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hasMore) fetchLogs();
|
||||
}, [page, selectedChatbot]);
|
||||
return (
|
||||
<div className="mt-12">
|
||||
<div className="flex flex-col items-start">
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="font-bold text-jet dark:text-bright-gray">
|
||||
Filter by chatbot
|
||||
</p>
|
||||
<Dropdown
|
||||
size="w-[55vw] sm:w-[360px]"
|
||||
options={[
|
||||
...chatbots.map((chatbot) => ({
|
||||
label: chatbot.name,
|
||||
value: chatbot.id,
|
||||
})),
|
||||
{ label: 'None', value: '' },
|
||||
]}
|
||||
placeholder="Select chatbot"
|
||||
onSelect={(chatbot: { label: string; value: string }) => {
|
||||
setSelectedChatbot(
|
||||
chatbots.find((item) => item.id === chatbot.value),
|
||||
);
|
||||
setLogs([]);
|
||||
setPage(1);
|
||||
setHasMore(true);
|
||||
}}
|
||||
selectedValue={
|
||||
(selectedChatbot && {
|
||||
label: selectedChatbot.name,
|
||||
value: selectedChatbot.id,
|
||||
}) ||
|
||||
null
|
||||
}
|
||||
rounded="3xl"
|
||||
border="border"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<LogsTable logs={logs} setPage={setPage} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type LogsTableProps = {
|
||||
logs: LogData[];
|
||||
setPage: React.Dispatch<React.SetStateAction<number>>;
|
||||
};
|
||||
|
||||
function LogsTable({ logs, setPage }: LogsTableProps) {
|
||||
const observerRef = React.useRef<any>();
|
||||
const firstObserver = React.useCallback((node: HTMLDivElement) => {
|
||||
if (observerRef.current) {
|
||||
observerRef.current = new IntersectionObserver((enteries) => {
|
||||
if (enteries[0].isIntersecting) setPage((prev) => prev + 1);
|
||||
});
|
||||
}
|
||||
if (node && observerRef.current) observerRef.current.observe(node);
|
||||
}, []);
|
||||
return (
|
||||
<div className="logs-table border rounded-2xl h-[55vh] w-full overflow-hidden border-silver dark:border-silver/40">
|
||||
<div className="h-8 bg-black/10 dark:bg-chinese-black flex flex-col items-start justify-center">
|
||||
<p className="px-3 text-xs dark:text-gray-6000">
|
||||
API generated / chatbot conversations
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
ref={observerRef}
|
||||
className="flex flex-col items-start h-[51vh] overflow-y-auto bg-transparent flex-grow gap-px"
|
||||
>
|
||||
{logs.map((log, index) => {
|
||||
if (index === logs.length - 1) {
|
||||
return (
|
||||
<div ref={firstObserver} key={index}>
|
||||
<Log log={log} />
|
||||
</div>
|
||||
);
|
||||
} else return <Log key={index} log={log} />;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Log({ log }: { log: LogData }) {
|
||||
const logLevelColor = {
|
||||
info: 'text-green-500',
|
||||
error: 'text-red-500',
|
||||
warning: 'text-yellow-500',
|
||||
};
|
||||
const { id, action, timestamp, ...filteredLog } = log;
|
||||
return (
|
||||
<details className="group bg-transparent [&_summary::-webkit-details-marker]:hidden w-full hover:bg-[#F9F9F9] hover:dark:bg-dark-charcoal">
|
||||
<summary className="flex flex-row items-center gap-2 text-gray-900 cursor-pointer p-2 group-open:bg-[#F9F9F9] dark:group-open:bg-dark-charcoal">
|
||||
<img
|
||||
src={ChevronRight}
|
||||
alt="chevron-right"
|
||||
className="w-3 h-3 transition duration-300 group-open:rotate-90"
|
||||
/>
|
||||
<span className="flex flex-row gap-2">
|
||||
<h2 className="text-xs text-black/60 dark:text-bright-gray">{`${log.timestamp}`}</h2>
|
||||
<h2 className="text-xs text-[#913400] dark:text-[#DF5200]">{`[${log.action}]`}</h2>
|
||||
<h2
|
||||
className={`text-xs ${logLevelColor[log.level]}`}
|
||||
>{`${log.question}`}</h2>
|
||||
</span>
|
||||
</summary>
|
||||
<div className="px-4 group-open:bg-[#F9F9F9] dark:group-open:bg-dark-charcoal">
|
||||
<p className="px-2 leading-relaxed text-gray-700 dark:text-gray-400 text-xs">
|
||||
{JSON.stringify(filteredLog, null, 2)}
|
||||
</p>
|
||||
<div className="my-px w-8">
|
||||
<CoppyButton
|
||||
text={JSON.stringify(filteredLog)}
|
||||
colorLight="transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -11,9 +11,11 @@ import {
|
||||
selectSourceDocs,
|
||||
setSourceDocs,
|
||||
} from '../preferences/preferenceSlice';
|
||||
import Analytics from './Analytics';
|
||||
import APIKeys from './APIKeys';
|
||||
import Documents from './Documents';
|
||||
import General from './General';
|
||||
import Logs from './Logs';
|
||||
import Widgets from './Widgets';
|
||||
|
||||
export default function Settings() {
|
||||
@@ -23,6 +25,8 @@ export default function Settings() {
|
||||
t('settings.general.label'),
|
||||
t('settings.documents.label'),
|
||||
t('settings.apiKeys.label'),
|
||||
t('settings.analytics.label'),
|
||||
t('settings.logs.label'),
|
||||
];
|
||||
const [activeTab, setActiveTab] = React.useState(t('settings.general.label'));
|
||||
const [widgetScreenshot, setWidgetScreenshot] = React.useState<File | null>(
|
||||
@@ -128,6 +132,10 @@ export default function Settings() {
|
||||
);
|
||||
case t('settings.apiKeys.label'):
|
||||
return <APIKeys />;
|
||||
case t('settings.analytics.label'):
|
||||
return <Analytics />;
|
||||
case t('settings.logs.label'):
|
||||
return <Logs />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
20
frontend/src/settings/types/index.ts
Normal file
20
frontend/src/settings/types/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export type APIKeyData = {
|
||||
id: string;
|
||||
name: string;
|
||||
key: string;
|
||||
source: string;
|
||||
prompt_id: string;
|
||||
chunks: string;
|
||||
};
|
||||
|
||||
export type LogData = {
|
||||
id: string;
|
||||
action: string;
|
||||
level: 'info' | 'error' | 'warning';
|
||||
user: string;
|
||||
question: string;
|
||||
response: string;
|
||||
sources: Record<string, any>[];
|
||||
retriever_params: Record<string, any>;
|
||||
timestamp: string;
|
||||
};
|
||||
@@ -275,7 +275,7 @@ function Upload({
|
||||
} else
|
||||
setRedditData({
|
||||
...redditData,
|
||||
[name]: value,
|
||||
[name]: name === 'number_posts' ? parseInt(value) : value,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -321,6 +321,7 @@ function Upload({
|
||||
colorVariant="gray"
|
||||
value={docName}
|
||||
onChange={(e) => setDocName(e.target.value)}
|
||||
borderVariant="thin"
|
||||
></Input>
|
||||
<div className="relative bottom-12 left-2 mt-[-20px]">
|
||||
<span className="bg-white px-2 text-xs text-gray-4000 dark:bg-outer-space dark:text-silver">
|
||||
@@ -356,6 +357,7 @@ function Upload({
|
||||
{activeTab === 'remote' && (
|
||||
<>
|
||||
<Dropdown
|
||||
border="border"
|
||||
options={urlOptions}
|
||||
selectedValue={urlType}
|
||||
onSelect={(value: { label: string; value: string }) =>
|
||||
@@ -371,6 +373,7 @@ function Upload({
|
||||
type="text"
|
||||
value={urlName}
|
||||
onChange={(e) => setUrlName(e.target.value)}
|
||||
borderVariant="thin"
|
||||
></Input>
|
||||
<div className="relative bottom-12 left-2 mt-[-20px]">
|
||||
<span className="bg-white px-2 text-xs text-gray-4000 dark:bg-outer-space dark:text-silver">
|
||||
@@ -382,6 +385,7 @@ function Upload({
|
||||
type="text"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
borderVariant="thin"
|
||||
></Input>
|
||||
<div className="relative bottom-12 left-2 mt-[-20px]">
|
||||
<span className="bg-white px-2 text-xs text-gray-4000 dark:bg-outer-space dark:text-silver">
|
||||
@@ -390,68 +394,83 @@ function Upload({
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
placeholder="Enter client ID"
|
||||
type="text"
|
||||
name="client_id"
|
||||
value={redditData.client_id}
|
||||
onChange={handleChange}
|
||||
></Input>
|
||||
<div className="relative bottom-12 left-2 mt-[-20px]">
|
||||
<span className="bg-white px-2 text-xs text-gray-4000 dark:bg-outer-space dark:text-silver">
|
||||
{t('modals.uploadDoc.reddit.id')}
|
||||
</span>
|
||||
<div className="flex flex-col gap-1 mt-2">
|
||||
<div>
|
||||
<Input
|
||||
placeholder="Enter client ID"
|
||||
type="text"
|
||||
name="client_id"
|
||||
value={redditData.client_id}
|
||||
onChange={handleChange}
|
||||
borderVariant="thin"
|
||||
></Input>
|
||||
<div className="relative bottom-[52px] left-2">
|
||||
<span className="bg-white px-2 text-xs text-gray-4000 dark:bg-outer-space dark:text-silver">
|
||||
{t('modals.uploadDoc.reddit.id')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
placeholder="Enter client secret"
|
||||
type="text"
|
||||
name="client_secret"
|
||||
value={redditData.client_secret}
|
||||
onChange={handleChange}
|
||||
></Input>
|
||||
<div className="relative bottom-12 left-2 mt-[-20px]">
|
||||
<span className="bg-white px-2 text-xs text-gray-4000 dark:bg-outer-space dark:text-silver">
|
||||
{t('modals.uploadDoc.reddit.secret')}
|
||||
</span>
|
||||
<div>
|
||||
<Input
|
||||
placeholder="Enter client secret"
|
||||
type="text"
|
||||
name="client_secret"
|
||||
value={redditData.client_secret}
|
||||
onChange={handleChange}
|
||||
borderVariant="thin"
|
||||
></Input>
|
||||
<div className="relative bottom-[52px] left-2">
|
||||
<span className="bg-white px-2 text-xs text-gray-4000 dark:bg-outer-space dark:text-silver">
|
||||
{t('modals.uploadDoc.reddit.secret')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
placeholder="Enter user agent"
|
||||
type="text"
|
||||
name="user_agent"
|
||||
value={redditData.user_agent}
|
||||
onChange={handleChange}
|
||||
></Input>
|
||||
<div className="relative bottom-12 left-2 mt-[-20px]">
|
||||
<span className="bg-white px-2 text-xs text-gray-4000 dark:bg-outer-space dark:text-silver">
|
||||
{t('modals.uploadDoc.reddit.agent')}
|
||||
</span>
|
||||
<div>
|
||||
<Input
|
||||
placeholder="Enter user agent"
|
||||
type="text"
|
||||
name="user_agent"
|
||||
value={redditData.user_agent}
|
||||
onChange={handleChange}
|
||||
borderVariant="thin"
|
||||
></Input>
|
||||
<div className="relative bottom-[52px] left-2">
|
||||
<span className="bg-white px-2 text-xs text-gray-4000 dark:bg-outer-space dark:text-silver">
|
||||
{t('modals.uploadDoc.reddit.agent')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
placeholder="Enter search queries"
|
||||
type="text"
|
||||
name="search_queries"
|
||||
value={redditData.search_queries}
|
||||
onChange={handleChange}
|
||||
></Input>
|
||||
<div className="relative bottom-12 left-2 mt-[-20px]">
|
||||
<span className="bg-white px-2 text-xs text-gray-4000 dark:bg-outer-space dark:text-silver">
|
||||
{t('modals.uploadDoc.reddit.searchQueries')}
|
||||
</span>
|
||||
<div>
|
||||
<Input
|
||||
placeholder="Enter search queries"
|
||||
type="text"
|
||||
name="search_queries"
|
||||
value={redditData.search_queries}
|
||||
onChange={handleChange}
|
||||
borderVariant="thin"
|
||||
></Input>
|
||||
<div className="relative bottom-[52px] left-2">
|
||||
<span className="bg-white px-2 text-xs text-gray-4000 dark:bg-outer-space dark:text-silver">
|
||||
{t('modals.uploadDoc.reddit.searchQueries')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
placeholder="Enter number of posts"
|
||||
type="number"
|
||||
name="number_posts"
|
||||
value={redditData.number_posts}
|
||||
onChange={handleChange}
|
||||
></Input>
|
||||
<div className="relative bottom-12 left-2 mt-[-20px]">
|
||||
<span className="bg-white px-2 text-xs text-gray-4000 dark:bg-outer-space dark:text-silver">
|
||||
{t('modals.uploadDoc.reddit.numberOfPosts')}
|
||||
</span>
|
||||
<div>
|
||||
<Input
|
||||
placeholder="Enter number of posts"
|
||||
type="number"
|
||||
name="number_posts"
|
||||
value={redditData.number_posts}
|
||||
onChange={handleChange}
|
||||
borderVariant="thin"
|
||||
></Input>
|
||||
<div className="relative bottom-[52px] left-2">
|
||||
<span className="bg-white px-2 text-xs text-gray-4000 dark:bg-outer-space dark:text-silver">
|
||||
{t('modals.uploadDoc.reddit.numberOfPosts')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
77
frontend/src/utils/chartUtils.ts
Normal file
77
frontend/src/utils/chartUtils.ts
Normal 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);
|
||||
});
|
||||
},
|
||||
};
|
||||
20
frontend/src/utils/dateTimeUtils.ts
Normal file
20
frontend/src/utils/dateTimeUtils.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user