mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-09-01 03:27:01 +00:00
💡 feat: add DB-backed admin insights (#14898)
* feat: add Mongo-backed admin insights * feat: gate insights with environment variable * fix: tighten insights access and activity metrics * fix: preserve insights date selections * perf: parallelize insights search aggregation * test: wait for MCP conflict recovery * test: satisfy strict MCP recovery typing * fix: disable insights pagination while loading * fix: localize insights range shortcuts * fix: bound insights search input
This commit is contained in:
parent
389cfebea1
commit
006e421cd2
47 changed files with 3447 additions and 51 deletions
746
client/src/components/Insights/InsightsView.tsx
Normal file
746
client/src/components/Insights/InsightsView.tsx
Normal file
|
|
@ -0,0 +1,746 @@
|
|||
import { useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AlertCircle, Info, Search } from 'lucide-react';
|
||||
import { Button, Input, Spinner, TooltipAnchor, useMediaQuery } from '@librechat/client';
|
||||
import {
|
||||
INSIGHTS_MAX_RANGE_DAYS,
|
||||
INSIGHTS_SEARCH_MAX_LENGTH,
|
||||
INSIGHTS_SEARCH_MIN_LENGTH,
|
||||
SystemRoles,
|
||||
} from 'librechat-data-provider';
|
||||
import type {
|
||||
InsightsRange,
|
||||
TInsightsChurnedUser,
|
||||
TInsightsConversation,
|
||||
TInsightsParams,
|
||||
TInsightsUser,
|
||||
} from 'librechat-data-provider';
|
||||
import type { TranslationKeys } from '~/hooks';
|
||||
import { useGetStartupConfig, useInsightsAccessQuery, useInsightsQuery } from '~/data-provider';
|
||||
import { useAuthContext, useDocumentTitle, useLocalize } from '~/hooks';
|
||||
import OpenSidebar from '~/components/Chat/Menus/OpenSidebar';
|
||||
import { LocalizedDateRangePicker } from '~/components/ui';
|
||||
import { getRollingDateRange } from './dateRange';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
type ShortcutRange = Exclude<InsightsRange, 'custom'>;
|
||||
type Localize = ReturnType<typeof useLocalize>;
|
||||
type SparklinePoint = { date: string; value: number };
|
||||
type CustomDateRange = { startDate: Date; endDate: Date };
|
||||
|
||||
type KpiCardData = {
|
||||
id: 'conversations' | 'users' | 'messages' | 'tokens';
|
||||
title: string;
|
||||
value: number;
|
||||
sparkline: SparklinePoint[];
|
||||
};
|
||||
|
||||
const ranges: Array<{ value: ShortcutRange; labelKey: TranslationKeys; days: number }> = [
|
||||
{ value: '24h', labelKey: 'com_insights_range_24_hours', days: 1 },
|
||||
{ value: '7d', labelKey: 'com_insights_range_7_days', days: 7 },
|
||||
{ value: '30d', labelKey: 'com_insights_range_30_days', days: 30 },
|
||||
];
|
||||
const dateRangeSelectionDelayMs = 350;
|
||||
const searchDelayMs = 350;
|
||||
|
||||
function formatValue(value: number, locale: string) {
|
||||
return new Intl.NumberFormat(locale, {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function formatExactValue(value: number, locale: string) {
|
||||
return new Intl.NumberFormat(locale).format(value);
|
||||
}
|
||||
|
||||
function formatDate(value: string, locale: string) {
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function formatRecentChatDate(value: string, locale: string) {
|
||||
const date = new Date(value);
|
||||
const now = new Date();
|
||||
const elapsedMinutes = Math.floor((now.getTime() - date.getTime()) / 60_000);
|
||||
const relativeTime = new Intl.RelativeTimeFormat(locale, { numeric: 'auto', style: 'narrow' });
|
||||
|
||||
if (elapsedMinutes < 1) {
|
||||
return relativeTime.format(0, 'minute');
|
||||
}
|
||||
if (elapsedMinutes < 60) {
|
||||
return relativeTime.format(-elapsedMinutes, 'minute');
|
||||
}
|
||||
if (elapsedMinutes < 24 * 60) {
|
||||
return relativeTime.format(-Math.floor(elapsedMinutes / 60), 'hour');
|
||||
}
|
||||
if (elapsedMinutes < 7 * 24 * 60) {
|
||||
return relativeTime.format(-Math.floor(elapsedMinutes / (24 * 60)), 'day');
|
||||
}
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
...(date.getFullYear() === now.getFullYear() ? {} : { year: 'numeric' }),
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function displayUserName(name: string, localize: Localize) {
|
||||
return name || localize('com_insights_unknown_user');
|
||||
}
|
||||
|
||||
function responseStatus(error: unknown) {
|
||||
return (error as { response?: { status?: number } } | undefined)?.response?.status;
|
||||
}
|
||||
|
||||
function getShortcutDateRange(range: ShortcutRange) {
|
||||
const days = ranges.find((item) => item.value === range)?.days ?? 7;
|
||||
return getRollingDateRange(new Date(), days);
|
||||
}
|
||||
|
||||
function Panel({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
'min-w-0 rounded-lg border border-border-light bg-surface-primary p-5',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="flex min-h-40 items-center justify-center text-sm text-text-secondary">
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingState({ message }: { message: string }) {
|
||||
return (
|
||||
<Panel className="flex min-h-52 flex-col items-center justify-center gap-3">
|
||||
<Spinner className="size-7 text-text-secondary" />
|
||||
<span className="text-sm text-text-secondary">{message}</span>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function Sparkline({
|
||||
values,
|
||||
label,
|
||||
locale,
|
||||
}: {
|
||||
values: SparklinePoint[];
|
||||
label: string;
|
||||
locale: string;
|
||||
}) {
|
||||
const patternId = useId().replace(/:/g, '');
|
||||
const [activeIndex, setActiveIndex] = useState<number>();
|
||||
const points = useMemo(() => {
|
||||
if (values.length < 2) {
|
||||
return [];
|
||||
}
|
||||
const maximum = Math.max(1, ...values.map((point) => point.value));
|
||||
return values.map((point, index) => ({
|
||||
...point,
|
||||
x: (index / (values.length - 1)) * 300,
|
||||
y: 52 - (point.value / maximum) * 46,
|
||||
}));
|
||||
}, [values]);
|
||||
|
||||
if (points.length < 2) {
|
||||
return <div className="h-14" />;
|
||||
}
|
||||
|
||||
const line = points.map((point) => `${point.x},${point.y}`).join(' ');
|
||||
const area = `M 0 56 L ${points.map((point) => `${point.x} ${point.y}`).join(' L ')} L 300 56 Z`;
|
||||
const activePoint = activeIndex == null ? undefined : points[activeIndex];
|
||||
const formatter = new Intl.DateTimeFormat(locale, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative mt-2 h-14 w-full text-status-info outline-none"
|
||||
role="img"
|
||||
tabIndex={0}
|
||||
aria-label={label}
|
||||
onFocus={() => setActiveIndex(points.length - 1)}
|
||||
onBlur={() => setActiveIndex(undefined)}
|
||||
onMouseLeave={() => setActiveIndex(undefined)}
|
||||
onMouseMove={(event) => {
|
||||
const bounds = event.currentTarget.getBoundingClientRect();
|
||||
const ratio = Math.max(0, Math.min(1, (event.clientX - bounds.left) / bounds.width));
|
||||
setActiveIndex(Math.round(ratio * (points.length - 1)));
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
className="h-full w-full overflow-visible"
|
||||
viewBox="0 0 300 56"
|
||||
preserveAspectRatio="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<pattern id={patternId} width="6" height="6" patternUnits="userSpaceOnUse">
|
||||
<path
|
||||
d="M-1 1 L1 -1 M0 6 L6 0 M5 7 L7 5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="0.8"
|
||||
opacity="0.22"
|
||||
/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<path d={area} fill={`url(#${patternId})`} />
|
||||
<polyline
|
||||
points={line}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</svg>
|
||||
{activePoint && (
|
||||
<>
|
||||
<span
|
||||
className="pointer-events-none absolute top-0 h-full w-px bg-border-medium"
|
||||
style={{ left: `${(activePoint.x / 300) * 100}%` }}
|
||||
/>
|
||||
<span
|
||||
className="pointer-events-none absolute size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-status-info ring-2 ring-surface-primary"
|
||||
style={{
|
||||
left: `${(activePoint.x / 300) * 100}%`,
|
||||
top: `${(activePoint.y / 56) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className="pointer-events-none absolute bottom-full mb-2 -translate-x-1/2 whitespace-nowrap rounded-lg border border-border-light bg-surface-primary px-2.5 py-1.5 text-xs text-text-primary shadow-lg"
|
||||
style={{ left: `${Math.max(14, Math.min(86, (activePoint.x / 300) * 100))}%` }}
|
||||
>
|
||||
{formatter.format(new Date(activePoint.date))}{' '}
|
||||
<strong>{formatExactValue(activePoint.value, locale)}</strong>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KpiCard({ card, locale }: { card: KpiCardData; locale: string }) {
|
||||
const localize = useLocalize();
|
||||
return (
|
||||
<Panel>
|
||||
<h2 className="text-lg font-normal text-text-secondary">{card.title}</h2>
|
||||
<div className="mt-3 text-4xl font-semibold tabular-nums leading-none text-text-primary">
|
||||
{formatValue(card.value, locale)}
|
||||
</div>
|
||||
<Sparkline
|
||||
values={card.sparkline}
|
||||
label={localize('com_insights_sparkline_accessibility', { label: card.title })}
|
||||
locale={locale}
|
||||
/>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function TablePanel({ title, children }: { title: React.ReactNode; children: React.ReactNode }) {
|
||||
return (
|
||||
<Panel className="overflow-hidden">
|
||||
<h2 className="mb-3 text-base font-semibold text-text-primary">{title}</h2>
|
||||
{children}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function UserCell({ name, email, localize }: { name: string; email: string; localize: Localize }) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-text-primary">{displayUserName(name, localize)}</div>
|
||||
<div className="truncate text-xs text-text-secondary">{email}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TopUsersTable({
|
||||
rows,
|
||||
localize,
|
||||
locale,
|
||||
}: {
|
||||
rows: TInsightsUser[];
|
||||
localize: Localize;
|
||||
locale: string;
|
||||
}) {
|
||||
return (
|
||||
<TablePanel title={localize('com_insights_top_users')}>
|
||||
{rows.length === 0 ? (
|
||||
<EmptyState message={localize('com_insights_no_data')} />
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[420px] text-left text-sm">
|
||||
<thead className="border-b border-border-medium text-xs text-text-secondary">
|
||||
<tr>
|
||||
<th className="px-2 py-2 font-medium">{localize('com_insights_user')}</th>
|
||||
<th className="px-2 py-2 text-right font-medium">
|
||||
{localize('com_insights_messages')}
|
||||
</th>
|
||||
<th className="px-2 py-2 text-right font-medium">
|
||||
{localize('com_insights_chats')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border-light">
|
||||
{rows.map((entry) => (
|
||||
<tr key={entry.userId} className="hover:bg-surface-hover">
|
||||
<td className="px-2 py-3">
|
||||
<UserCell {...entry} localize={localize} />
|
||||
</td>
|
||||
<td className="px-2 py-3 text-right tabular-nums">
|
||||
{formatExactValue(entry.messages, locale)}
|
||||
</td>
|
||||
<td className="px-2 py-3 text-right tabular-nums">
|
||||
{formatExactValue(entry.conversations, locale)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</TablePanel>
|
||||
);
|
||||
}
|
||||
|
||||
function ChurnedUsersTable({
|
||||
rows,
|
||||
localize,
|
||||
locale,
|
||||
}: {
|
||||
rows: TInsightsChurnedUser[];
|
||||
localize: Localize;
|
||||
locale: string;
|
||||
}) {
|
||||
const definition = localize('com_insights_churned_users_definition');
|
||||
return (
|
||||
<TablePanel
|
||||
title={
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{localize('com_insights_churned_users')}
|
||||
<TooltipAnchor
|
||||
description={definition}
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-label={definition}
|
||||
className="text-text-secondary hover:text-text-primary"
|
||||
>
|
||||
<Info className="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{rows.length === 0 ? (
|
||||
<EmptyState message={localize('com_insights_no_data')} />
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[560px] table-fixed text-left text-sm">
|
||||
<thead className="border-b border-border-medium text-xs text-text-secondary">
|
||||
<tr>
|
||||
<th className="w-[34%] px-2 py-2 font-medium">{localize('com_insights_user')}</th>
|
||||
<th className="w-[12%] px-2 py-2 text-right font-medium">
|
||||
{localize('com_insights_messages')}
|
||||
</th>
|
||||
<th className="w-[12%] px-2 py-2 text-right font-medium">
|
||||
{localize('com_insights_chats')}
|
||||
</th>
|
||||
<th className="w-[21%] px-2 py-2 text-right font-medium">
|
||||
{localize('com_insights_first_seen')}
|
||||
</th>
|
||||
<th className="w-[21%] px-2 py-2 text-right font-medium">
|
||||
{localize('com_insights_last_seen')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border-light">
|
||||
{rows.map((entry) => (
|
||||
<tr key={entry.userId} className="hover:bg-surface-hover">
|
||||
<td className="overflow-hidden px-2 py-3">
|
||||
<UserCell {...entry} localize={localize} />
|
||||
</td>
|
||||
<td className="px-2 py-3 text-right tabular-nums">
|
||||
{formatExactValue(entry.messages, locale)}
|
||||
</td>
|
||||
<td className="px-2 py-3 text-right tabular-nums">
|
||||
{formatExactValue(entry.conversations, locale)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-2 py-3 text-right text-text-secondary">
|
||||
{formatDate(entry.firstSeen, locale)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-2 py-3 text-right text-text-secondary">
|
||||
{formatDate(entry.lastSeen, locale)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</TablePanel>
|
||||
);
|
||||
}
|
||||
|
||||
function LatestConversations({
|
||||
rows,
|
||||
search,
|
||||
activeSearch,
|
||||
page,
|
||||
pages,
|
||||
isFetching,
|
||||
setPage,
|
||||
setSearch,
|
||||
localize,
|
||||
locale,
|
||||
}: {
|
||||
rows: TInsightsConversation[];
|
||||
search: string;
|
||||
activeSearch: string;
|
||||
page: number;
|
||||
pages: number;
|
||||
isFetching: boolean;
|
||||
setPage: React.Dispatch<React.SetStateAction<number>>;
|
||||
setSearch: React.Dispatch<React.SetStateAction<string>>;
|
||||
localize: Localize;
|
||||
locale: string;
|
||||
}) {
|
||||
return (
|
||||
<Panel className="overflow-hidden">
|
||||
<div className="mb-3 flex min-w-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-base font-semibold">
|
||||
{localize('com_insights_latest_conversations')}
|
||||
</h2>
|
||||
{isFetching && <Spinner className="size-4 text-text-secondary" />}
|
||||
</div>
|
||||
<div className="relative w-full sm:max-w-md">
|
||||
<Search
|
||||
className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-text-secondary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Input
|
||||
aria-label={localize('com_insights_search_placeholder')}
|
||||
className="pl-9"
|
||||
maxLength={INSIGHTS_SEARCH_MAX_LENGTH}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder={localize('com_insights_search_placeholder')}
|
||||
value={search}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[760px] table-fixed text-left text-sm">
|
||||
<thead className="border-b border-border-medium text-xs text-text-secondary">
|
||||
<tr>
|
||||
<th className="w-[120px] px-2 py-2 font-medium">{localize('com_insights_date')}</th>
|
||||
<th className="w-[192px] px-2 py-2 font-medium">{localize('com_insights_user')}</th>
|
||||
<th className="px-2 py-2 font-medium">{localize('com_insights_first_message')}</th>
|
||||
<th className="w-20 px-2 py-2 text-right font-medium">
|
||||
{localize('com_insights_messages')}
|
||||
</th>
|
||||
<th className="w-20 px-2 py-2 text-right font-medium">
|
||||
{localize('com_insights_total_tokens')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border-light">
|
||||
{rows.map((conversation) => (
|
||||
<tr
|
||||
key={`${conversation.conversationId}:${conversation.userId}`}
|
||||
className="hover:bg-surface-hover"
|
||||
>
|
||||
<td className="whitespace-nowrap px-2 py-3 text-text-secondary">
|
||||
{formatRecentChatDate(conversation.date, locale)}
|
||||
</td>
|
||||
<td className="px-2 py-3">
|
||||
<UserCell {...conversation} localize={localize} />
|
||||
</td>
|
||||
<td className="max-w-xl px-2 py-3">
|
||||
<span className="line-clamp-2">
|
||||
{conversation.firstMessage || localize('com_insights_no_message')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-3 text-right tabular-nums">
|
||||
{formatExactValue(conversation.messages, locale)}
|
||||
</td>
|
||||
<td className="px-2 py-3 text-right tabular-nums">
|
||||
{formatValue(conversation.totalTokens, locale)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{rows.length === 0 && (
|
||||
<EmptyState
|
||||
message={
|
||||
activeSearch
|
||||
? localize('com_insights_no_search_results')
|
||||
: localize('com_insights_no_data')
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div className="mt-3 flex items-center justify-between gap-3 border-t border-border-light pt-3 text-sm text-text-secondary">
|
||||
<span>{localize('com_insights_page_of', { page, pages })}</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isFetching || page <= 1}
|
||||
onClick={() => setPage((value) => Math.max(1, value - 1))}
|
||||
>
|
||||
{localize('com_ui_prev')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isFetching || page >= pages}
|
||||
onClick={() => setPage((value) => value + 1)}
|
||||
>
|
||||
{localize('com_ui_next')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
export default function InsightsView() {
|
||||
const localize = useLocalize();
|
||||
const { i18n } = useTranslation();
|
||||
const locale = i18n.resolvedLanguage ?? i18n.language ?? 'en';
|
||||
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
||||
const { user } = useAuthContext();
|
||||
const { data: startupConfig, isLoading: configLoading } = useGetStartupConfig();
|
||||
const [range, setRange] = useState<ShortcutRange>('7d');
|
||||
const [customDateRange, setCustomDateRange] = useState<CustomDateRange>();
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const dateRangeSelectionTimeout = useRef<number>();
|
||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||
const insightsFeatureEnabled = startupConfig?.insightsEnabled === true;
|
||||
const shouldCheckAccess = user?.role === SystemRoles.ADMIN && insightsFeatureEnabled;
|
||||
const access = useInsightsAccessQuery(user?.id, {
|
||||
enabled: shouldCheckAccess,
|
||||
});
|
||||
const isAllowed = insightsFeatureEnabled && access.data?.access === true;
|
||||
const insightsParams = useMemo<TInsightsParams>(() => {
|
||||
const params: TInsightsParams = { page, pageSize: 10, search, timeZone };
|
||||
if (customDateRange) {
|
||||
return {
|
||||
...params,
|
||||
range: 'custom',
|
||||
fromTimestamp: customDateRange.startDate.toISOString(),
|
||||
toTimestamp: customDateRange.endDate.toISOString(),
|
||||
};
|
||||
}
|
||||
return { ...params, range };
|
||||
}, [customDateRange, page, range, search, timeZone]);
|
||||
const displayDateRange = useMemo(
|
||||
() => customDateRange ?? getShortcutDateRange(range),
|
||||
[customDateRange, range],
|
||||
);
|
||||
const insights = useInsightsQuery(insightsParams, { enabled: isAllowed });
|
||||
const data = insights.data;
|
||||
|
||||
useDocumentTitle(`${localize('com_insights_title')} | LibreChat`);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (dateRangeSelectionTimeout.current != null) {
|
||||
window.clearTimeout(dateRangeSelectionTimeout.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const trimmedSearch = searchInput.trim().slice(0, INSIGHTS_SEARCH_MAX_LENGTH);
|
||||
const nextSearch = trimmedSearch.length >= INSIGHTS_SEARCH_MIN_LENGTH ? trimmedSearch : '';
|
||||
if (nextSearch === search) {
|
||||
return;
|
||||
}
|
||||
const timeout = window.setTimeout(() => {
|
||||
setSearch(nextSearch);
|
||||
setPage(1);
|
||||
}, searchDelayMs);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [search, searchInput]);
|
||||
|
||||
const kpiCards = useMemo<KpiCardData[]>(() => {
|
||||
if (!data) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
id: 'conversations',
|
||||
title: localize('com_insights_total_conversations'),
|
||||
value: data.summary.totalConversations,
|
||||
sparkline: data.daily.map((row) => ({ date: row.date, value: row.conversations })),
|
||||
},
|
||||
{
|
||||
id: 'users',
|
||||
title: localize('com_insights_total_users'),
|
||||
value: data.summary.totalUsers,
|
||||
sparkline: data.daily.map((row) => ({ date: row.date, value: row.users })),
|
||||
},
|
||||
{
|
||||
id: 'messages',
|
||||
title: localize('com_insights_messages'),
|
||||
value: data.summary.totalMessages,
|
||||
sparkline: data.daily.map((row) => ({ date: row.date, value: row.messages })),
|
||||
},
|
||||
{
|
||||
id: 'tokens',
|
||||
title: localize('com_insights_total_tokens'),
|
||||
value: data.summary.totalTokens,
|
||||
sparkline: data.daily.map((row) => ({ date: row.date, value: row.totalTokens })),
|
||||
},
|
||||
];
|
||||
}, [data, localize]);
|
||||
|
||||
const handleSelectDateRange = (startDate: Date, endDate: Date) => {
|
||||
if (dateRangeSelectionTimeout.current != null) {
|
||||
window.clearTimeout(dateRangeSelectionTimeout.current);
|
||||
}
|
||||
dateRangeSelectionTimeout.current = window.setTimeout(() => {
|
||||
setCustomDateRange({ startDate: new Date(startDate), endDate: new Date(endDate) });
|
||||
setPage(1);
|
||||
dateRangeSelectionTimeout.current = undefined;
|
||||
}, dateRangeSelectionDelayMs);
|
||||
};
|
||||
|
||||
if (configLoading || (shouldCheckAccess && access.isLoading)) {
|
||||
return (
|
||||
<div className="h-full w-full bg-presentation p-4">
|
||||
<LoadingState message={localize('com_insights_loading')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const accessStatus = responseStatus(access.error);
|
||||
if (access.isError && accessStatus !== 403 && accessStatus !== 404) {
|
||||
return (
|
||||
<div className="h-full w-full bg-presentation p-4">
|
||||
<Panel className="flex items-center gap-2">
|
||||
<AlertCircle className="size-4 text-status-error" />
|
||||
<span className="text-sm">{localize('com_insights_load_error')}</span>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!isAllowed) {
|
||||
return <Navigate to="/c/new" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full min-w-0 flex-col bg-presentation text-text-primary">
|
||||
<header className="z-20 flex min-h-14 w-full flex-shrink-0 flex-col gap-3 border-b border-border-light bg-presentation px-4 py-3 sm:px-5 md:flex-row md:items-center md:justify-between md:px-6 lg:px-8">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
{isSmallScreen && <OpenSidebar />}
|
||||
<h1 className="text-base font-semibold">{localize('com_insights_title')}</h1>
|
||||
</div>
|
||||
<div className="flex max-w-full flex-wrap items-center gap-2 md:flex-nowrap">
|
||||
<div className="inline-flex rounded-lg border border-border-light p-0.5">
|
||||
{ranges.map((item) => (
|
||||
<Button
|
||||
key={item.value}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-pressed={!customDateRange && range === item.value}
|
||||
className={cn(
|
||||
'h-8 rounded-md px-3',
|
||||
!customDateRange && range === item.value && 'bg-surface-active-alt',
|
||||
)}
|
||||
onClick={() => {
|
||||
if (dateRangeSelectionTimeout.current != null) {
|
||||
window.clearTimeout(dateRangeSelectionTimeout.current);
|
||||
dateRangeSelectionTimeout.current = undefined;
|
||||
}
|
||||
setRange(item.value);
|
||||
setCustomDateRange(undefined);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
{localize(item.labelKey)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className="w-full min-w-0 sm:w-[340px]">
|
||||
<LocalizedDateRangePicker
|
||||
endDate={displayDateRange.endDate}
|
||||
futureDatesDisabled
|
||||
labels={{
|
||||
apply: localize('com_ui_done'),
|
||||
cancel: localize('com_ui_cancel'),
|
||||
endDate: localize('com_insights_end_date'),
|
||||
invalidRange: localize('com_insights_invalid_date_range', {
|
||||
days: INSIGHTS_MAX_RANGE_DAYS,
|
||||
}),
|
||||
startDate: localize('com_insights_start_date'),
|
||||
}}
|
||||
locale={locale}
|
||||
maxRangeLength={INSIGHTS_MAX_RANGE_DAYS}
|
||||
onSelectDateRange={handleSelectDateRange}
|
||||
placeholder={localize('com_insights_date_range_placeholder')}
|
||||
startDate={displayDateRange.startDate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden">
|
||||
<div className="flex w-full min-w-0 flex-col gap-5 px-4 py-4 sm:px-5 md:px-6 lg:px-8">
|
||||
{insights.isLoading && <LoadingState message={localize('com_insights_loading')} />}
|
||||
{insights.isError && (
|
||||
<Panel className="flex items-center gap-2">
|
||||
<AlertCircle className="size-4 text-status-error" />
|
||||
<span className="text-sm">{localize('com_insights_load_error')}</span>
|
||||
</Panel>
|
||||
)}
|
||||
{data && (
|
||||
<>
|
||||
<div className="grid w-full grid-cols-[repeat(auto-fit,minmax(min(100%,220px),1fr))] gap-3">
|
||||
{kpiCards.map((card) => (
|
||||
<KpiCard key={card.id} card={card} locale={locale} />
|
||||
))}
|
||||
</div>
|
||||
<div className="grid w-full grid-cols-[repeat(auto-fit,minmax(min(100%,580px),1fr))] gap-3">
|
||||
<TopUsersTable rows={data.topUsers} localize={localize} locale={locale} />
|
||||
<ChurnedUsersTable rows={data.churnedUsers} localize={localize} locale={locale} />
|
||||
</div>
|
||||
<LatestConversations
|
||||
rows={data.latest.conversations}
|
||||
search={searchInput}
|
||||
activeSearch={search}
|
||||
page={data.latest.page}
|
||||
pages={data.latest.pages}
|
||||
isFetching={insights.isFetching}
|
||||
setPage={setPage}
|
||||
setSearch={setSearchInput}
|
||||
localize={localize}
|
||||
locale={locale}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
client/src/components/Insights/dateRange.spec.ts
Normal file
12
client/src/components/Insights/dateRange.spec.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { getRollingDateRange } from './dateRange';
|
||||
|
||||
describe('Insights date ranges', () => {
|
||||
it('builds shortcut bounds from the same rolling duration as the query', () => {
|
||||
const endDate = new Date(2026, 7, 31, 15, 30);
|
||||
|
||||
const range = getRollingDateRange(endDate, 30);
|
||||
|
||||
expect(range.endDate.getTime() - range.startDate.getTime()).toBe(30 * 24 * 60 * 60 * 1000);
|
||||
expect(range.endDate).toEqual(endDate);
|
||||
});
|
||||
});
|
||||
7
client/src/components/Insights/dateRange.ts
Normal file
7
client/src/components/Insights/dateRange.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
const millisecondsPerDay = 24 * 60 * 60 * 1000;
|
||||
|
||||
export function getRollingDateRange(endDate: Date, days: number) {
|
||||
const rangeEnd = new Date(endDate);
|
||||
const rangeStart = new Date(endDate.getTime() - days * millisecondsPerDay);
|
||||
return { startDate: rangeStart, endDate: rangeEnd };
|
||||
}
|
||||
1
client/src/components/Insights/index.ts
Normal file
1
client/src/components/Insights/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from './InsightsView';
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { memo, useCallback, lazy, Suspense } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { SquarePen } from 'lucide-react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Skeleton, Sidebar, Button, TooltipAnchor } from '@librechat/client';
|
||||
import type { NavLink } from '~/common';
|
||||
import { useShortcutAriaKey, useShortcutHint } from '~/hooks/useKeyboardShortcuts';
|
||||
|
|
@ -58,6 +59,8 @@ const NavIconButton = memo(function NavIconButton({
|
|||
setActive,
|
||||
onExpand,
|
||||
onCollapse,
|
||||
onNavigate,
|
||||
onLeaveInsights,
|
||||
}: {
|
||||
link: NavLink;
|
||||
isActive: boolean;
|
||||
|
|
@ -65,6 +68,8 @@ const NavIconButton = memo(function NavIconButton({
|
|||
setActive: (id: string) => void;
|
||||
onExpand?: () => void;
|
||||
onCollapse?: () => void;
|
||||
onNavigate?: () => void;
|
||||
onLeaveInsights?: () => void;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
|
||||
|
|
@ -72,6 +77,7 @@ const NavIconButton = memo(function NavIconButton({
|
|||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (link.onClick) {
|
||||
link.onClick(e);
|
||||
onNavigate?.();
|
||||
return;
|
||||
}
|
||||
if (isActive && expanded) {
|
||||
|
|
@ -83,9 +89,11 @@ const NavIconButton = memo(function NavIconButton({
|
|||
}
|
||||
if (!expanded) {
|
||||
onExpand?.();
|
||||
} else {
|
||||
onLeaveInsights?.();
|
||||
}
|
||||
},
|
||||
[link, isActive, setActive, expanded, onExpand, onCollapse],
|
||||
[link, isActive, setActive, expanded, onExpand, onCollapse, onNavigate, onLeaveInsights],
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
@ -117,15 +125,21 @@ function ExpandedPanel({
|
|||
expanded = true,
|
||||
onCollapse,
|
||||
onExpand,
|
||||
onNavigate,
|
||||
onLeaveInsights,
|
||||
}: {
|
||||
links: NavLink[];
|
||||
expanded?: boolean;
|
||||
onCollapse?: () => void;
|
||||
onExpand?: () => void;
|
||||
onNavigate?: () => void;
|
||||
onLeaveInsights?: () => void;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const location = useLocation();
|
||||
const { active, setActive } = useActivePanel();
|
||||
const effectiveActive = resolveActivePanel(active, links);
|
||||
const isInsightsRoute = location.pathname.startsWith('/insights');
|
||||
|
||||
const toggleLabel = expanded ? 'com_nav_close_sidebar' : 'com_nav_open_sidebar';
|
||||
const toggleClick = expanded ? onCollapse : onExpand;
|
||||
|
|
@ -160,11 +174,17 @@ function ExpandedPanel({
|
|||
<NavIconButton
|
||||
key={link.id}
|
||||
link={link}
|
||||
isActive={link.id === effectiveActive}
|
||||
isActive={
|
||||
link.id === 'insights'
|
||||
? isInsightsRoute
|
||||
: !isInsightsRoute && link.id === effectiveActive
|
||||
}
|
||||
expanded={expanded ?? true}
|
||||
setActive={setActive}
|
||||
onExpand={onExpand}
|
||||
onCollapse={onCollapse}
|
||||
onNavigate={onNavigate}
|
||||
onLeaveInsights={isInsightsRoute ? onLeaveInsights : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ function Sidebar({
|
|||
expanded,
|
||||
onCollapse,
|
||||
onExpand,
|
||||
onLeaveInsights,
|
||||
onResizeStart,
|
||||
onResizeKeyboard,
|
||||
}: {
|
||||
|
|
@ -16,6 +17,7 @@ function Sidebar({
|
|||
expanded: boolean;
|
||||
onCollapse: () => void;
|
||||
onExpand: () => void;
|
||||
onLeaveInsights: () => void;
|
||||
onResizeStart: (e: React.MouseEvent) => void;
|
||||
onResizeKeyboard: (direction: 'shrink' | 'grow') => void;
|
||||
}) {
|
||||
|
|
@ -27,6 +29,7 @@ function Sidebar({
|
|||
expanded={expanded}
|
||||
onCollapse={onCollapse}
|
||||
onExpand={onExpand}
|
||||
onLeaveInsights={onLeaveInsights}
|
||||
/>
|
||||
<nav
|
||||
className={cn(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useCallback, useState, useEffect, useRef, memo } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { ChatFormValues } from '~/common';
|
||||
import {
|
||||
|
|
@ -44,6 +45,8 @@ function SidebarChatProvider({ children }: { children: ReactNode }) {
|
|||
|
||||
function UnifiedSidebar() {
|
||||
const localize = useLocalize();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { isSmallScreen, expanded } = useSidebarState();
|
||||
const { setSidebarOpen } = useSidebarToggle();
|
||||
const [sidebarWidth, setSidebarWidth] = useState(getInitialWidth);
|
||||
|
|
@ -51,6 +54,8 @@ function UnifiedSidebar() {
|
|||
const resizeHandlers = useRef<{ move: (e: MouseEvent) => void; up: () => void } | null>(null);
|
||||
|
||||
const links = useUnifiedSidebarLinks();
|
||||
const isInsightsRoute = location.pathname.startsWith('/insights');
|
||||
const panelExpanded = expanded && !isInsightsRoute;
|
||||
|
||||
const handleCollapse = useCallback(
|
||||
(afterSlide?: () => void) => {
|
||||
|
|
@ -63,6 +68,17 @@ function UnifiedSidebar() {
|
|||
setSidebarOpen(true);
|
||||
}, [setSidebarOpen]);
|
||||
|
||||
const handleLeaveInsights = useCallback(() => {
|
||||
navigate('/c/new');
|
||||
}, [navigate]);
|
||||
|
||||
const handlePanelExpand = useCallback(() => {
|
||||
if (isInsightsRoute) {
|
||||
handleLeaveInsights();
|
||||
}
|
||||
handleExpand();
|
||||
}, [handleExpand, handleLeaveInsights, isInsightsRoute]);
|
||||
|
||||
const handleResizeStart = useCallback(() => {
|
||||
setIsResizing(true);
|
||||
document.body.style.userSelect = 'none';
|
||||
|
|
@ -163,14 +179,24 @@ function UnifiedSidebar() {
|
|||
>
|
||||
<SidebarChatProvider>
|
||||
<ActivePanelProvider>
|
||||
<MobileHeader links={links} expanded={expanded} onClose={handleCollapse} />
|
||||
<MobileHeader
|
||||
links={links}
|
||||
expanded={expanded}
|
||||
onClose={handleCollapse}
|
||||
onLeaveInsights={handleLeaveInsights}
|
||||
routeActiveId={isInsightsRoute ? 'insights' : undefined}
|
||||
/>
|
||||
<nav
|
||||
id="chat-history-nav"
|
||||
className="min-h-0 flex-1 overflow-hidden bg-surface-primary-alt"
|
||||
>
|
||||
<SidePanelNav links={links} />
|
||||
</nav>
|
||||
<MobileShortcutTargets links={links} />
|
||||
<MobileShortcutTargets
|
||||
links={links}
|
||||
onLeaveInsights={handleLeaveInsights}
|
||||
routeActiveId={isInsightsRoute ? 'insights' : undefined}
|
||||
/>
|
||||
<MobileBottomBar links={links} onNewChat={handleCollapse} />
|
||||
</ActivePanelProvider>
|
||||
</SidebarChatProvider>
|
||||
|
|
@ -184,9 +210,9 @@ function UnifiedSidebar() {
|
|||
<aside
|
||||
className="relative flex h-full flex-shrink-0 overflow-hidden"
|
||||
style={{
|
||||
width: expanded ? sidebarWidth : COLLAPSED_WIDTH,
|
||||
minWidth: expanded ? EXPANDED_MIN : COLLAPSED_WIDTH,
|
||||
maxWidth: expanded ? '40%' : COLLAPSED_WIDTH,
|
||||
width: panelExpanded ? sidebarWidth : COLLAPSED_WIDTH,
|
||||
minWidth: panelExpanded ? EXPANDED_MIN : COLLAPSED_WIDTH,
|
||||
maxWidth: panelExpanded ? '40%' : COLLAPSED_WIDTH,
|
||||
transition: isResizing
|
||||
? 'none'
|
||||
: `width ${TRANSITION_MS}ms ${EASING}, min-width ${TRANSITION_MS}ms ${EASING}, max-width ${TRANSITION_MS}ms ${EASING}`,
|
||||
|
|
@ -195,9 +221,10 @@ function UnifiedSidebar() {
|
|||
>
|
||||
<Sidebar
|
||||
links={links}
|
||||
expanded={expanded}
|
||||
expanded={panelExpanded}
|
||||
onCollapse={handleCollapse}
|
||||
onExpand={handleExpand}
|
||||
onExpand={handlePanelExpand}
|
||||
onLeaveInsights={handleLeaveInsights}
|
||||
onResizeStart={handleResizeStart}
|
||||
onResizeKeyboard={handleResizeKeyboard}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
import React from 'react';
|
||||
import { RecoilRoot } from 'recoil';
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { MessagesSquare, NotebookPen } from 'lucide-react';
|
||||
import { render, fireEvent, screen } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import type { MutableSnapshot } from 'recoil';
|
||||
import { ActivePanelProvider, DEFAULT_PANEL } from '~/Providers/ActivePanelContext';
|
||||
import type { NavLink } from '~/common';
|
||||
import { ActivePanelProvider, DEFAULT_PANEL } from '~/Providers';
|
||||
|
||||
const mockNewConversation = jest.fn();
|
||||
const mockClearMessagesCache = jest.fn();
|
||||
|
|
@ -86,7 +88,7 @@ jest.mock('~/components/Nav/AccountSettings', () => ({
|
|||
import ExpandedPanel from '../ExpandedPanel';
|
||||
import store from '~/store';
|
||||
|
||||
const createLinks = () => [
|
||||
const createLinks = (): NavLink[] => [
|
||||
{
|
||||
title: 'com_ui_chat_history' as const,
|
||||
icon: MessagesSquare,
|
||||
|
|
@ -103,14 +105,18 @@ const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { r
|
|||
|
||||
function renderPanel({
|
||||
expanded = true,
|
||||
links = createLinks(),
|
||||
onCollapse = jest.fn(),
|
||||
onExpand = jest.fn(),
|
||||
onNavigate,
|
||||
initialPanel = DEFAULT_PANEL,
|
||||
initializeState,
|
||||
}: {
|
||||
expanded?: boolean;
|
||||
links?: NavLink[];
|
||||
onCollapse?: jest.Mock;
|
||||
onExpand?: jest.Mock;
|
||||
onNavigate?: jest.Mock;
|
||||
initialPanel?: string;
|
||||
initializeState?: (snapshot: MutableSnapshot) => void;
|
||||
} = {}) {
|
||||
|
|
@ -119,18 +125,21 @@ function renderPanel({
|
|||
}
|
||||
|
||||
const result = render(
|
||||
<QueryClientProvider client={createQueryClient()}>
|
||||
<RecoilRoot initializeState={initializeState}>
|
||||
<ActivePanelProvider>
|
||||
<ExpandedPanel
|
||||
links={createLinks()}
|
||||
expanded={expanded}
|
||||
onCollapse={onCollapse}
|
||||
onExpand={onExpand}
|
||||
/>
|
||||
</ActivePanelProvider>
|
||||
</RecoilRoot>
|
||||
</QueryClientProvider>,
|
||||
<MemoryRouter>
|
||||
<QueryClientProvider client={createQueryClient()}>
|
||||
<RecoilRoot initializeState={initializeState}>
|
||||
<ActivePanelProvider>
|
||||
<ExpandedPanel
|
||||
links={links}
|
||||
expanded={expanded}
|
||||
onCollapse={onCollapse}
|
||||
onExpand={onExpand}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
</ActivePanelProvider>
|
||||
</RecoilRoot>
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
return { ...result, onCollapse, onExpand };
|
||||
|
|
@ -172,6 +181,26 @@ describe('ExpandedPanel', () => {
|
|||
expect(onExpand).toHaveBeenCalledTimes(1);
|
||||
expect(localStorage.getItem('side:active-panel')).toBe('prompts');
|
||||
});
|
||||
|
||||
it('notifies mobile navigation after a route link is selected', () => {
|
||||
const onClick = jest.fn();
|
||||
const onNavigate = jest.fn();
|
||||
const links = [
|
||||
...createLinks(),
|
||||
{
|
||||
title: 'com_insights_navigation' as const,
|
||||
icon: NotebookPen,
|
||||
id: 'insights',
|
||||
onClick,
|
||||
},
|
||||
];
|
||||
|
||||
renderPanel({ links, onNavigate });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'com_insights_navigation' }));
|
||||
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
expect(onNavigate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NewChatButton panel switch', () => {
|
||||
|
|
|
|||
|
|
@ -20,10 +20,14 @@ function Header({
|
|||
links,
|
||||
expanded,
|
||||
onClose,
|
||||
onLeaveInsights,
|
||||
routeActiveId,
|
||||
}: {
|
||||
links: NavLink[];
|
||||
expanded: boolean;
|
||||
onClose: () => void;
|
||||
onLeaveInsights?: () => void;
|
||||
routeActiveId?: string;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const toggleSidebarAriaKey = useShortcutAriaKey('toggleSidebar');
|
||||
|
|
@ -64,7 +68,12 @@ function Header({
|
|||
>
|
||||
<Sidebar className="icon-md" aria-hidden="true" />
|
||||
</Button>
|
||||
<Switcher links={links} />
|
||||
<Switcher
|
||||
links={links}
|
||||
onLeaveInsights={onLeaveInsights}
|
||||
onNavigate={onClose}
|
||||
routeActiveId={routeActiveId}
|
||||
/>
|
||||
<Suspense fallback={<Skeleton className="size-9 rounded-lg" />}>
|
||||
<AccountSettings collapsed />
|
||||
</Suspense>
|
||||
|
|
|
|||
|
|
@ -16,9 +16,17 @@ import { useActivePanel, resolveActivePanel } from '~/Providers';
|
|||
* Only available links render, so a shortcut for a panel this endpoint does not
|
||||
* offer still correctly does nothing.
|
||||
*/
|
||||
function ShortcutTargets({ links }: { links: NavLink[] }) {
|
||||
function ShortcutTargets({
|
||||
links,
|
||||
onLeaveInsights,
|
||||
routeActiveId,
|
||||
}: {
|
||||
links: NavLink[];
|
||||
onLeaveInsights?: () => void;
|
||||
routeActiveId?: string;
|
||||
}) {
|
||||
const { active, setActive } = useActivePanel();
|
||||
const activeId = resolveActivePanel(active, links);
|
||||
const activeId = routeActiveId ?? resolveActivePanel(active, links);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -30,7 +38,16 @@ function ShortcutTargets({ links }: { links: NavLink[] }) {
|
|||
tabIndex={-1}
|
||||
data-testid={`nav-panel-${link.id}`}
|
||||
aria-pressed={link.id === activeId}
|
||||
onClick={() => setActive(link.id)}
|
||||
onClick={() => {
|
||||
if (link.onClick) {
|
||||
link.onClick();
|
||||
return;
|
||||
}
|
||||
setActive(link.id);
|
||||
if (routeActiveId) {
|
||||
onLeaveInsights?.();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -13,13 +13,23 @@ import { cn } from '~/utils';
|
|||
* you leave it. Replaces the icon rail's ten unlabelled glyphs with labelled
|
||||
* rows, and costs no standing width.
|
||||
*/
|
||||
function Switcher({ links }: { links: NavLink[] }) {
|
||||
function Switcher({
|
||||
links,
|
||||
onLeaveInsights,
|
||||
onNavigate,
|
||||
routeActiveId,
|
||||
}: {
|
||||
links: NavLink[];
|
||||
onLeaveInsights?: () => void;
|
||||
onNavigate?: () => void;
|
||||
routeActiveId?: string;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const menuId = useId();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { active, setActive } = useActivePanel();
|
||||
|
||||
const activeId = resolveActivePanel(active, links);
|
||||
const activeId = routeActiveId ?? resolveActivePanel(active, links);
|
||||
const activeLink = links.find((link) => link.id === activeId);
|
||||
|
||||
const items = useMemo<t.MenuItemProps[]>(
|
||||
|
|
@ -30,9 +40,19 @@ function Switcher({ links }: { links: NavLink[] }) {
|
|||
ariaChecked: link.id === activeId,
|
||||
className: link.id === activeId ? 'bg-surface-active-alt' : undefined,
|
||||
icon: <link.icon className="size-5 text-text-primary" aria-hidden="true" />,
|
||||
onClick: () => setActive(link.id),
|
||||
onClick: () => {
|
||||
if (link.onClick) {
|
||||
link.onClick();
|
||||
onNavigate?.();
|
||||
return;
|
||||
}
|
||||
setActive(link.id);
|
||||
if (routeActiveId) {
|
||||
onLeaveInsights?.();
|
||||
}
|
||||
},
|
||||
})),
|
||||
[links, activeId, localize, setActive],
|
||||
[links, activeId, localize, onLeaveInsights, onNavigate, routeActiveId, setActive],
|
||||
);
|
||||
|
||||
if (!activeLink) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { MessagesSquare, NotebookPen } from 'lucide-react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { BarChart3, MessagesSquare, NotebookPen } from 'lucide-react';
|
||||
import type { NavLink } from '~/common';
|
||||
import { ActivePanelProvider } from '~/Providers/ActivePanelContext';
|
||||
import ShortcutTargets from '../ShortcutTargets';
|
||||
|
|
@ -9,10 +9,22 @@ const links = [
|
|||
{ id: 'prompts', title: 'com_ui_prompts', icon: NotebookPen, Component: () => null },
|
||||
] as unknown as NavLink[];
|
||||
|
||||
const renderTargets = () =>
|
||||
const renderTargets = ({
|
||||
targetLinks = links,
|
||||
onLeaveInsights,
|
||||
routeActiveId,
|
||||
}: {
|
||||
targetLinks?: NavLink[];
|
||||
onLeaveInsights?: () => void;
|
||||
routeActiveId?: string;
|
||||
} = {}) =>
|
||||
render(
|
||||
<ActivePanelProvider>
|
||||
<ShortcutTargets links={links} />
|
||||
<ShortcutTargets
|
||||
links={targetLinks}
|
||||
onLeaveInsights={onLeaveInsights}
|
||||
routeActiveId={routeActiveId}
|
||||
/>
|
||||
</ActivePanelProvider>,
|
||||
);
|
||||
|
||||
|
|
@ -47,6 +59,35 @@ describe('ShortcutTargets', () => {
|
|||
expect(localStorage.getItem('side:active-panel')).toBe('prompts');
|
||||
});
|
||||
|
||||
it('uses a route link callback without changing the active panel', () => {
|
||||
const onClick = jest.fn();
|
||||
const targetLinks = [
|
||||
...links,
|
||||
{
|
||||
id: 'insights',
|
||||
title: 'com_insights_navigation',
|
||||
icon: BarChart3,
|
||||
onClick,
|
||||
},
|
||||
] as unknown as NavLink[];
|
||||
|
||||
renderTargets({ targetLinks });
|
||||
fireEvent.click(screen.getByTestId('nav-panel-insights'));
|
||||
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
expect(localStorage.getItem('side:active-panel')).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves a route when a panel target is selected', () => {
|
||||
const onLeaveInsights = jest.fn();
|
||||
|
||||
renderTargets({ onLeaveInsights, routeActiveId: 'insights' });
|
||||
fireEvent.click(screen.getByTestId('nav-panel-prompts'));
|
||||
|
||||
expect(onLeaveInsights).toHaveBeenCalledTimes(1);
|
||||
expect(localStorage.getItem('side:active-panel')).toBe('prompts');
|
||||
});
|
||||
|
||||
it('offers no target for a panel this endpoint does not have', () => {
|
||||
renderTargets();
|
||||
|
||||
|
|
|
|||
207
client/src/components/ui/LocalizedDateRangePicker.tsx
Normal file
207
client/src/components/ui/LocalizedDateRangePicker.tsx
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Button } from '@librechat/client';
|
||||
import * as Popover from '@radix-ui/react-popover';
|
||||
import { CalendarDays, ChevronDown } from 'lucide-react';
|
||||
|
||||
type DateRangePickerLabels = {
|
||||
apply: string;
|
||||
cancel: string;
|
||||
endDate: string;
|
||||
invalidRange: string;
|
||||
startDate: string;
|
||||
};
|
||||
|
||||
type LocalizedDateRangePickerProps = {
|
||||
endDate?: Date;
|
||||
futureDatesDisabled?: boolean;
|
||||
labels: DateRangePickerLabels;
|
||||
locale: string;
|
||||
maxRangeLength?: number;
|
||||
onSelectDateRange: (startDate: Date, endDate: Date) => void;
|
||||
placeholder: string;
|
||||
startDate?: Date;
|
||||
};
|
||||
|
||||
const millisecondsPerDay = 24 * 60 * 60 * 1000;
|
||||
|
||||
function toDateInputValue(date?: Date) {
|
||||
if (!date) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function parseDateInput(value: string, endOfDay = false) {
|
||||
const [year, month, day] = value.split('-').map(Number);
|
||||
if (!year || !month || !day) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return endOfDay
|
||||
? new Date(year, month - 1, day, 23, 59, 59, 999)
|
||||
: new Date(year, month - 1, day);
|
||||
}
|
||||
|
||||
function addCalendarDays(value: string, days: number) {
|
||||
const date = parseDateInput(value);
|
||||
if (!date) {
|
||||
return '';
|
||||
}
|
||||
date.setDate(date.getDate() + days);
|
||||
return toDateInputValue(date);
|
||||
}
|
||||
|
||||
function calendarDayDifference(startDate: Date, endDate: Date) {
|
||||
const start = Date.UTC(startDate.getFullYear(), startDate.getMonth(), startDate.getDate());
|
||||
const end = Date.UTC(endDate.getFullYear(), endDate.getMonth(), endDate.getDate());
|
||||
return Math.round((end - start) / millisecondsPerDay);
|
||||
}
|
||||
|
||||
function earliestDate(...dates: Array<string | undefined>) {
|
||||
return dates.filter((date): date is string => Boolean(date)).sort()[0];
|
||||
}
|
||||
|
||||
export default function LocalizedDateRangePicker({
|
||||
endDate,
|
||||
futureDatesDisabled = false,
|
||||
labels,
|
||||
locale,
|
||||
maxRangeLength = -1,
|
||||
onSelectDateRange,
|
||||
placeholder,
|
||||
startDate,
|
||||
}: LocalizedDateRangePickerProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [pendingStartDate, setPendingStartDate] = useState(() => toDateInputValue(startDate));
|
||||
const [pendingEndDate, setPendingEndDate] = useState(() => toDateInputValue(endDate));
|
||||
const today = toDateInputValue(new Date());
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setPendingStartDate(toDateInputValue(startDate));
|
||||
setPendingEndDate(toDateInputValue(endDate));
|
||||
}
|
||||
}, [endDate, isOpen, startDate]);
|
||||
|
||||
const formattedRange = useMemo(() => {
|
||||
if (!startDate || !endDate) {
|
||||
return placeholder;
|
||||
}
|
||||
|
||||
const formatter = new Intl.DateTimeFormat(locale, {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
return `${formatter.format(startDate)} - ${formatter.format(endDate)}`;
|
||||
}, [endDate, locale, placeholder, startDate]);
|
||||
|
||||
const parsedStartDate = parseDateInput(pendingStartDate);
|
||||
const parsedEndDate = parseDateInput(pendingEndDate, true);
|
||||
const rangeLength =
|
||||
parsedStartDate && parsedEndDate
|
||||
? calendarDayDifference(parsedStartDate, parsedEndDate)
|
||||
: undefined;
|
||||
const matchesSelectedRange =
|
||||
pendingStartDate === toDateInputValue(startDate) &&
|
||||
pendingEndDate === toDateInputValue(endDate);
|
||||
const selectedRangeDuration =
|
||||
startDate && endDate ? endDate.getTime() - startDate.getTime() : undefined;
|
||||
const selectedRangeIsValid =
|
||||
matchesSelectedRange &&
|
||||
selectedRangeDuration != null &&
|
||||
selectedRangeDuration >= 0 &&
|
||||
(maxRangeLength < 0 || selectedRangeDuration <= maxRangeLength * millisecondsPerDay);
|
||||
const isValidRange =
|
||||
rangeLength != null &&
|
||||
rangeLength >= 0 &&
|
||||
(maxRangeLength < 0 || rangeLength < maxRangeLength || selectedRangeIsValid) &&
|
||||
(!futureDatesDisabled || pendingEndDate <= today);
|
||||
const latestEndDate =
|
||||
maxRangeLength > 0 && pendingStartDate
|
||||
? addCalendarDays(pendingStartDate, maxRangeLength - 1)
|
||||
: undefined;
|
||||
const endDateMax = earliestDate(latestEndDate, futureDatesDisabled ? today : undefined);
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (open) {
|
||||
setPendingStartDate(toDateInputValue(startDate));
|
||||
setPendingEndDate(toDateInputValue(endDate));
|
||||
}
|
||||
setIsOpen(open);
|
||||
};
|
||||
|
||||
const handleApply = () => {
|
||||
if (!parsedStartDate || !parsedEndDate || !isValidRange) {
|
||||
return;
|
||||
}
|
||||
if (matchesSelectedRange) {
|
||||
setIsOpen(false);
|
||||
return;
|
||||
}
|
||||
onSelectDateRange(parsedStartDate, parsedEndDate);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover.Root open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<Popover.Trigger asChild>
|
||||
<Button variant="outline" className="w-full min-w-0 justify-start px-3">
|
||||
<CalendarDays className="size-4 shrink-0" aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1 truncate text-left">{formattedRange}</span>
|
||||
<ChevronDown className="size-4 shrink-0 text-text-secondary" aria-hidden="true" />
|
||||
</Button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
align="end"
|
||||
sideOffset={6}
|
||||
className="z-50 w-[320px] max-w-[calc(100vw-2rem)] rounded-lg border border-border-light bg-surface-primary p-4 text-text-primary shadow-lg"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<label className="flex min-w-0 flex-col gap-1.5">
|
||||
<span className="text-sm">{labels.startDate}</span>
|
||||
<input
|
||||
type="date"
|
||||
lang={locale}
|
||||
max={futureDatesDisabled ? today : undefined}
|
||||
value={pendingStartDate}
|
||||
onChange={(event) => setPendingStartDate(event.target.value)}
|
||||
className="h-9 min-w-0 rounded-md border border-border-medium bg-surface-primary px-2 text-sm text-text-primary outline-none focus:border-border-heavy"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex min-w-0 flex-col gap-1.5">
|
||||
<span className="text-sm">{labels.endDate}</span>
|
||||
<input
|
||||
type="date"
|
||||
lang={locale}
|
||||
min={pendingStartDate || undefined}
|
||||
max={endDateMax}
|
||||
value={pendingEndDate}
|
||||
onChange={(event) => setPendingEndDate(event.target.value)}
|
||||
className="h-9 min-w-0 rounded-md border border-border-medium bg-surface-primary px-2 text-sm text-text-primary outline-none focus:border-border-heavy"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{!isValidRange && pendingStartDate && pendingEndDate && (
|
||||
<span className="text-sm text-status-error">{labels.invalidRange}</span>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setIsOpen(false)}>
|
||||
{labels.cancel}
|
||||
</Button>
|
||||
<Button size="sm" disabled={!isValidRange} onClick={handleApply}>
|
||||
{labels.apply}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
import React from 'react';
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import LocalizedDateRangePicker from '../LocalizedDateRangePicker';
|
||||
|
||||
const labels = {
|
||||
apply: 'Apply',
|
||||
cancel: 'Cancel',
|
||||
endDate: 'End date',
|
||||
invalidRange: 'Invalid range',
|
||||
startDate: 'Start date',
|
||||
};
|
||||
const germanDateFormatter = new Intl.DateTimeFormat('de-DE', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
const triggerName = `${germanDateFormatter.format(new Date(2026, 2, 4))} - ${germanDateFormatter.format(new Date(2026, 2, 9))}`;
|
||||
|
||||
function renderPicker(onSelectDateRange = jest.fn()) {
|
||||
render(
|
||||
<LocalizedDateRangePicker
|
||||
endDate={new Date(2026, 2, 9, 17)}
|
||||
futureDatesDisabled={false}
|
||||
labels={labels}
|
||||
locale="de-DE"
|
||||
maxRangeLength={10}
|
||||
onSelectDateRange={onSelectDateRange}
|
||||
placeholder="Select dates"
|
||||
startDate={new Date(2026, 2, 4, 9)}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('LocalizedDateRangePicker', () => {
|
||||
it('formats the range with the active locale and exposes localized labels', () => {
|
||||
renderPicker();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: triggerName }));
|
||||
|
||||
expect(screen.getByLabelText(labels.startDate)).toHaveAttribute('lang', 'de-DE');
|
||||
expect(screen.getByLabelText(labels.endDate)).toHaveAttribute('lang', 'de-DE');
|
||||
expect(screen.getByRole('button', { name: labels.apply })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: labels.cancel })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('validates the range and returns complete local calendar days', () => {
|
||||
const onSelectDateRange = jest.fn();
|
||||
renderPicker(onSelectDateRange);
|
||||
fireEvent.click(screen.getByRole('button', { name: triggerName }));
|
||||
|
||||
fireEvent.change(screen.getByLabelText(labels.endDate), { target: { value: '2026-03-14' } });
|
||||
expect(screen.getByText(labels.invalidRange)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: labels.apply })).toBeDisabled();
|
||||
|
||||
fireEvent.change(screen.getByLabelText(labels.endDate), { target: { value: '2026-03-10' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: labels.apply }));
|
||||
|
||||
const [startDate, endDate] = onSelectDateRange.mock.calls[0] as [Date, Date];
|
||||
expect([startDate.getFullYear(), startDate.getMonth(), startDate.getDate()]).toEqual([
|
||||
2026, 2, 4,
|
||||
]);
|
||||
expect([startDate.getHours(), startDate.getMinutes()]).toEqual([0, 0]);
|
||||
expect([endDate.getFullYear(), endDate.getMonth(), endDate.getDate()]).toEqual([2026, 2, 10]);
|
||||
expect([endDate.getHours(), endDate.getMinutes(), endDate.getSeconds()]).toEqual([23, 59, 59]);
|
||||
});
|
||||
|
||||
it('does not convert an unchanged rolling shortcut to a custom range', () => {
|
||||
const onSelectDateRange = jest.fn();
|
||||
const startDate = new Date(2026, 7, 1, 15, 30);
|
||||
const endDate = new Date(2026, 7, 31, 15, 30);
|
||||
const rangeName = `${germanDateFormatter.format(startDate)} - ${germanDateFormatter.format(endDate)}`;
|
||||
render(
|
||||
<LocalizedDateRangePicker
|
||||
endDate={endDate}
|
||||
futureDatesDisabled={false}
|
||||
labels={labels}
|
||||
locale="de-DE"
|
||||
maxRangeLength={30}
|
||||
onSelectDateRange={onSelectDateRange}
|
||||
placeholder="Select dates"
|
||||
startDate={startDate}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: rangeName }));
|
||||
fireEvent.click(screen.getByRole('button', { name: labels.apply }));
|
||||
|
||||
expect(onSelectDateRange).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole('button', { name: labels.apply })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -4,4 +4,5 @@ export { default as PanelFooter } from './PanelFooter';
|
|||
export { default as PanelContent } from './PanelContent';
|
||||
export { default as TermsAndConditionsModal } from './TermsAndConditionsModal';
|
||||
export { default as AdminSettingsDialog } from './AdminSettingsDialog';
|
||||
export { default as LocalizedDateRangePicker } from './LocalizedDateRangePicker';
|
||||
export type { PermissionConfig, AdminSettingsDialogProps } from './AdminSettingsDialog';
|
||||
|
|
|
|||
1
client/src/data-provider/Insights/index.ts
Normal file
1
client/src/data-provider/Insights/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './queries';
|
||||
33
client/src/data-provider/Insights/queries.ts
Normal file
33
client/src/data-provider/Insights/queries.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { QueryKeys, dataService } from 'librechat-data-provider';
|
||||
import type {
|
||||
TInsightsAccessResponse,
|
||||
TInsightsParams,
|
||||
TInsightsResponse,
|
||||
} from 'librechat-data-provider';
|
||||
import type { QueryObserverResult, UseQueryOptions } from '@tanstack/react-query';
|
||||
|
||||
export const useInsightsQuery = (
|
||||
params: TInsightsParams,
|
||||
config?: UseQueryOptions<TInsightsResponse>,
|
||||
): QueryObserverResult<TInsightsResponse> =>
|
||||
useQuery<TInsightsResponse>([QueryKeys.insights, params], () => dataService.getInsights(params), {
|
||||
keepPreviousData: true,
|
||||
refetchOnWindowFocus: false,
|
||||
...config,
|
||||
});
|
||||
|
||||
export const useInsightsAccessQuery = (
|
||||
userId?: string,
|
||||
config?: UseQueryOptions<TInsightsAccessResponse>,
|
||||
): QueryObserverResult<TInsightsAccessResponse> =>
|
||||
useQuery<TInsightsAccessResponse>(
|
||||
[QueryKeys.insightsAccess, userId ?? 'anonymous'],
|
||||
() => dataService.getInsightsAccess(),
|
||||
{
|
||||
retry: false,
|
||||
staleTime: 60_000,
|
||||
refetchOnWindowFocus: false,
|
||||
...config,
|
||||
},
|
||||
);
|
||||
|
|
@ -4,6 +4,7 @@ export * from './Endpoints';
|
|||
export * from './Skills';
|
||||
export * from './Files';
|
||||
export * from './Langfuse';
|
||||
export * from './Insights';
|
||||
/* Memories */
|
||||
export * from './Memories';
|
||||
export * from './Messages';
|
||||
|
|
|
|||
|
|
@ -1,18 +1,23 @@
|
|||
import { useMemo } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { MessagesSquare } from 'lucide-react';
|
||||
import { BarChart3, MessagesSquare } from 'lucide-react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useUserKeyQuery } from 'librechat-data-provider/react-query';
|
||||
import { getConfigDefaults, getEndpointField } from 'librechat-data-provider';
|
||||
import { getConfigDefaults, getEndpointField, SystemRoles } from 'librechat-data-provider';
|
||||
import type { TEndpointsConfig } from 'librechat-data-provider';
|
||||
import type { NavLink } from '~/common';
|
||||
import { useGetEndpointsQuery, useGetStartupConfig, useInsightsAccessQuery } from '~/data-provider';
|
||||
import ConversationsSection from '~/components/UnifiedSidebar/ConversationsSection';
|
||||
import { useGetEndpointsQuery, useGetStartupConfig } from '~/data-provider';
|
||||
import useSideNavLinks from '~/hooks/Nav/useSideNavLinks';
|
||||
import { useAuthContext } from '~/hooks';
|
||||
import store from '~/store';
|
||||
|
||||
const defaultInterface = getConfigDefaults().interface;
|
||||
|
||||
export default function useUnifiedSidebarLinks() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { user } = useAuthContext();
|
||||
/** Selector instead of the full conversation atom: the links only depend on
|
||||
* the endpoint, so parameter edits and other conversation writes stay out. */
|
||||
const endpoint = useRecoilValue(store.conversationEndpointByIndex(0)) ?? undefined;
|
||||
|
|
@ -23,6 +28,10 @@ export default function useUnifiedSidebarLinks() {
|
|||
() => startupConfig?.interface ?? defaultInterface,
|
||||
[startupConfig],
|
||||
);
|
||||
const insightsFeatureEnabled = startupConfig?.insightsEnabled === true;
|
||||
const { data: insightsAccess } = useInsightsAccessQuery(user?.id, {
|
||||
enabled: user?.role === SystemRoles.ADMIN && insightsFeatureEnabled,
|
||||
});
|
||||
|
||||
const endpointType = useMemo(
|
||||
() => getEndpointField(endpointsConfig, endpoint, 'type'),
|
||||
|
|
@ -59,8 +68,27 @@ export default function useUnifiedSidebarLinks() {
|
|||
Component: ConversationsSection,
|
||||
};
|
||||
|
||||
return [conversationLink, ...sideNavLinks];
|
||||
}, [sideNavLinks]);
|
||||
if (!insightsFeatureEnabled || insightsAccess?.access !== true) {
|
||||
return [conversationLink, ...sideNavLinks];
|
||||
}
|
||||
|
||||
const insightsLink: NavLink = {
|
||||
title: 'com_insights_navigation',
|
||||
label: '',
|
||||
icon: BarChart3,
|
||||
id: 'insights',
|
||||
onClick: () => {
|
||||
if (!location.pathname.startsWith('/insights')) {
|
||||
navigate('/insights');
|
||||
}
|
||||
},
|
||||
};
|
||||
const mcpIndex = sideNavLinks.findIndex((link) => link.id === 'mcp-builder');
|
||||
const nextLinks = [...sideNavLinks];
|
||||
nextLinks.splice(mcpIndex >= 0 ? mcpIndex + 1 : nextLinks.length, 0, insightsLink);
|
||||
|
||||
return [conversationLink, ...nextLinks];
|
||||
}, [insightsAccess?.access, insightsFeatureEnabled, location.pathname, navigate, sideNavLinks]);
|
||||
|
||||
return links;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2307,5 +2307,37 @@
|
|||
"com_ui_zoom_in": "Zoom in",
|
||||
"com_ui_zoom_level": "Zoom level",
|
||||
"com_ui_zoom_out": "Zoom out",
|
||||
"com_user_message": "You"
|
||||
"com_user_message": "You",
|
||||
"com_insights_title": "Insights",
|
||||
"com_insights_navigation": "Insights",
|
||||
"com_insights_loading": "Loading insights",
|
||||
"com_insights_load_error": "Insights could not be loaded. Try again.",
|
||||
"com_insights_total_users": "Unique users",
|
||||
"com_insights_total_conversations": "Conversations",
|
||||
"com_insights_churned_users": "Churned users",
|
||||
"com_insights_churned_users_definition": "Users whose latest message sent to an assistant reached 28 days old during the selected date range.",
|
||||
"com_insights_top_users": "Top users",
|
||||
"com_insights_no_data": "No data yet",
|
||||
"com_insights_user": "User",
|
||||
"com_insights_unknown_user": "Unknown user",
|
||||
"com_insights_chats": "Chats",
|
||||
"com_insights_search_placeholder": "Search by chat id or user",
|
||||
"com_insights_no_search_results": "No chats match this search.",
|
||||
"com_insights_latest_conversations": "Latest conversations",
|
||||
"com_insights_date": "Date",
|
||||
"com_insights_first_message": "First message",
|
||||
"com_insights_first_seen": "First seen",
|
||||
"com_insights_last_seen": "Last seen",
|
||||
"com_insights_messages": "Messages",
|
||||
"com_insights_total_tokens": "Tokens",
|
||||
"com_insights_no_message": "No message",
|
||||
"com_insights_page_of": "Page {{page}} of {{pages}}",
|
||||
"com_insights_range_24_hours": "24h",
|
||||
"com_insights_range_7_days": "7d",
|
||||
"com_insights_range_30_days": "30d",
|
||||
"com_insights_date_range_placeholder": "Select dates",
|
||||
"com_insights_start_date": "Start date",
|
||||
"com_insights_end_date": "End date",
|
||||
"com_insights_invalid_date_range": "Choose an end date after the start date, within {{days}} days.",
|
||||
"com_insights_sparkline_accessibility": "{{label}} over time"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,11 @@ const loadSkillsView = () =>
|
|||
Component: m.default,
|
||||
}));
|
||||
|
||||
const loadInsightsView = () =>
|
||||
import('~/components/Insights').then((m) => ({
|
||||
Component: m.default,
|
||||
}));
|
||||
|
||||
const loadProjectsView = () =>
|
||||
import('~/components/Projects').then((m) => ({
|
||||
Component: m.ProjectsView,
|
||||
|
|
@ -151,6 +156,10 @@ export const router = createBrowserRouter(
|
|||
path: 'skills',
|
||||
lazy: loadSkillsView,
|
||||
},
|
||||
{
|
||||
path: 'insights',
|
||||
lazy: loadInsightsView,
|
||||
},
|
||||
{
|
||||
path: 'skills/new',
|
||||
lazy: loadSkillsView,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue