mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 12:13:30 +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
|
|
@ -63,6 +63,9 @@ ADMIN_PANEL_SESSION_SECRET=
|
|||
# In deploy-compose the panel is served at http://admin.localhost via nginx.
|
||||
# ADMIN_PANEL_PORT=3000
|
||||
|
||||
# Enable the admin-only MongoDB Insights dashboard.
|
||||
ENABLE_INSIGHTS=false
|
||||
|
||||
NO_INDEX=true
|
||||
# Use the address that is at most n number of hops away from the Express application.
|
||||
# req.socket.remoteAddress is the first hop, and the rest are looked for in the X-Forwarded-For header from right to left.
|
||||
|
|
|
|||
|
|
@ -456,6 +456,7 @@ if (cluster.isMaster) {
|
|||
/** Routes */
|
||||
app.use('/oauth', preAuthTenantMiddleware, routes.oauth);
|
||||
app.use('/api/auth', preAuthTenantMiddleware, routes.auth);
|
||||
app.use('/api/admin/insights', routes.insights);
|
||||
app.use('/api/admin', routes.adminAuth);
|
||||
app.use('/api/admin/skills', routes.adminSkills);
|
||||
app.use('/api/actions', routes.actions);
|
||||
|
|
|
|||
|
|
@ -308,6 +308,7 @@ const startServer = async () => {
|
|||
app.use('/oauth', preAuthTenantMiddleware, routes.oauth);
|
||||
/* API Endpoints */
|
||||
app.use('/api/auth', preAuthTenantMiddleware, routes.auth);
|
||||
app.use('/api/admin/insights', routes.insights);
|
||||
app.use('/api/admin', routes.adminAuth);
|
||||
app.use('/api/admin/config', routes.adminConfig);
|
||||
app.use('/api/admin/langfuse', routes.adminLangfuse);
|
||||
|
|
|
|||
|
|
@ -104,6 +104,7 @@ afterEach(() => {
|
|||
delete process.env.SAML_SESSION_SECRET;
|
||||
delete process.env.ALLOW_ACCOUNT_DELETION;
|
||||
delete process.env.ADMIN_PANEL_URL;
|
||||
delete process.env.ENABLE_INSIGHTS;
|
||||
delete process.env.ANALYTICS_GTM_ID;
|
||||
delete process.env.CUSTOM_FOOTER;
|
||||
delete process.env.HELP_AND_FAQ_URL;
|
||||
|
|
@ -174,6 +175,7 @@ describe('GET /api/config', () => {
|
|||
expect(response.body).not.toHaveProperty('sharePointPickerGraphScope');
|
||||
expect(response.body).not.toHaveProperty('sharePointPickerSharePointScope');
|
||||
expect(response.body).not.toHaveProperty('conversationImportMaxFileSize');
|
||||
expect(response.body).not.toHaveProperty('insightsEnabled');
|
||||
});
|
||||
|
||||
it('should strip authenticated-only informational fields from unauthenticated response (#12688)', async () => {
|
||||
|
|
@ -398,6 +400,18 @@ describe('GET /api/config', () => {
|
|||
expect(response.body.conversationImportMaxFileSize).toBe(5000000);
|
||||
});
|
||||
|
||||
it('should advertise Insights only when ENABLE_INSIGHTS is enabled', async () => {
|
||||
mockGetAppConfig.mockResolvedValue(baseAppConfig);
|
||||
const app = createApp(mockUser);
|
||||
|
||||
let response = await request(app).get('/api/config');
|
||||
expect(response.body.insightsEnabled).toBe(false);
|
||||
|
||||
process.env.ENABLE_INSIGHTS = 'true';
|
||||
response = await request(app).get('/api/config');
|
||||
expect(response.body.insightsEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should advertise Langfuse fanout only when the toggle and collector URL are configured', async () => {
|
||||
mockGetAppConfig.mockResolvedValue(baseAppConfig);
|
||||
mockHasCapability.mockResolvedValue(true);
|
||||
|
|
|
|||
93
api/server/routes/__tests__/insights.spec.js
Normal file
93
api/server/routes/__tests__/insights.spec.js
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
const mockCreateInsightsAccessHandler = jest.fn(() => (_req, res) => res.json({ access: true }));
|
||||
const mockCreateInsightsHandler = jest.fn(() => (_req, res) => res.json({ summary: {} }));
|
||||
const mockGrantedCapabilities = new Set(['access:admin', 'read:insights']);
|
||||
const mockGetInsights = jest.fn();
|
||||
let mockUser = { id: 'admin-id', role: 'ADMIN' };
|
||||
|
||||
jest.mock('@librechat/api', () => ({
|
||||
createInsightsAccessHandler: (...args) => mockCreateInsightsAccessHandler(...args),
|
||||
createInsightsHandler: (...args) => mockCreateInsightsHandler(...args),
|
||||
isEnabled: (value) => value === 'true',
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
SystemCapabilities: {
|
||||
ACCESS_ADMIN: 'access:admin',
|
||||
READ_INSIGHTS: 'read:insights',
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('~/server/middleware', () => ({
|
||||
requireJwtAuth: (req, _res, next) => {
|
||||
req.user = mockUser;
|
||||
next();
|
||||
},
|
||||
checkAdmin: (req, res, next) => {
|
||||
if (req.user.role !== 'ADMIN') {
|
||||
return res.status(403).json({ message: 'Forbidden' });
|
||||
}
|
||||
next();
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('~/server/middleware/roles/capabilities', () => ({
|
||||
requireCapability: (capability) => (_req, res, next) => {
|
||||
if (!mockGrantedCapabilities.has(capability)) {
|
||||
return res.status(403).json({ message: 'Forbidden' });
|
||||
}
|
||||
next();
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('~/models', () => ({
|
||||
getInsights: (...args) => mockGetInsights(...args),
|
||||
}));
|
||||
|
||||
const insightsRouter = require('../insights');
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use('/api/admin/insights', insightsRouter);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('Insights routes', () => {
|
||||
beforeEach(() => {
|
||||
mockUser = { id: 'admin-id', role: 'ADMIN' };
|
||||
mockGrantedCapabilities.clear();
|
||||
mockGrantedCapabilities.add('access:admin');
|
||||
mockGrantedCapabilities.add('read:insights');
|
||||
});
|
||||
|
||||
it('requires the ADMIN role', async () => {
|
||||
mockUser = { id: 'delegated-admin-id', role: 'DELEGATED_ADMIN' };
|
||||
|
||||
const response = await request(createApp()).get('/api/admin/insights');
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it('serves the access probe and dashboard', async () => {
|
||||
const app = createApp();
|
||||
|
||||
await expect(request(app).get('/api/admin/insights/access')).resolves.toMatchObject({
|
||||
status: 200,
|
||||
body: { access: true },
|
||||
});
|
||||
await expect(request(app).get('/api/admin/insights')).resolves.toMatchObject({
|
||||
status: 200,
|
||||
body: { summary: {} },
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['access:admin', 'read:insights'])('requires %s', async (capability) => {
|
||||
mockGrantedCapabilities.delete(capability);
|
||||
|
||||
const response = await request(createApp()).get('/api/admin/insights');
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
|
@ -304,6 +304,7 @@ router.get('/', async function (req, res) {
|
|||
: 0,
|
||||
langfuseFanoutEnabled,
|
||||
langfuseConnectionAccess,
|
||||
insightsEnabled: isEnabled(process.env.ENABLE_INSIGHTS),
|
||||
...(cloudFront ? { cloudFront } : {}),
|
||||
...(rum ? { rum } : {}),
|
||||
fileUploadSseEnabled: isEnabled(process.env.FILE_UPLOAD_SSE_ENABLED),
|
||||
|
|
|
|||
|
|
@ -37,8 +37,10 @@ const keys = require('./keys');
|
|||
const user = require('./user');
|
||||
const mcp = require('./mcp');
|
||||
const rum = require('./rum');
|
||||
const insights = require('./insights');
|
||||
|
||||
module.exports = {
|
||||
insights,
|
||||
rum,
|
||||
mcp,
|
||||
auth,
|
||||
|
|
|
|||
17
api/server/routes/insights.js
Normal file
17
api/server/routes/insights.js
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
const express = require('express');
|
||||
const { createInsightsAccessHandler, createInsightsHandler, isEnabled } = require('@librechat/api');
|
||||
const { SystemCapabilities } = require('@librechat/data-schemas');
|
||||
const { requireJwtAuth, checkAdmin } = require('~/server/middleware');
|
||||
const { requireCapability } = require('~/server/middleware/roles/capabilities');
|
||||
const db = require('~/models');
|
||||
|
||||
const router = express.Router();
|
||||
const requireAdminAccess = requireCapability(SystemCapabilities.ACCESS_ADMIN);
|
||||
const requireInsightsAccess = requireCapability(SystemCapabilities.READ_INSIGHTS);
|
||||
const isInsightsEnabled = () => isEnabled(process.env.ENABLE_INSIGHTS);
|
||||
|
||||
router.use(requireJwtAuth, checkAdmin, requireAdminAccess, requireInsightsAccess);
|
||||
router.get('/access', createInsightsAccessHandler({ isInsightsEnabled }));
|
||||
router.get('/', createInsightsHandler({ isInsightsEnabled, getInsights: db.getInsights }));
|
||||
|
||||
module.exports = router;
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -76,6 +76,8 @@ export * from './tools';
|
|||
export * from './web';
|
||||
/* Langfuse */
|
||||
export * from './langfuse';
|
||||
/* Insights */
|
||||
export * from './insights';
|
||||
/* Cache */
|
||||
export * from './cache';
|
||||
/* Shared Links */
|
||||
|
|
|
|||
164
packages/api/src/insights/handlers.spec.ts
Normal file
164
packages/api/src/insights/handlers.spec.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
import type { TInsightsResponse } from 'librechat-data-provider';
|
||||
import type { Response } from 'express';
|
||||
import type { ServerRequest } from '~/types';
|
||||
import { createInsightsAccessHandler, createInsightsHandler } from './handlers';
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: { error: jest.fn() },
|
||||
}));
|
||||
|
||||
const emptyInsights: TInsightsResponse = {
|
||||
summary: {
|
||||
totalUsers: 0,
|
||||
totalConversations: 0,
|
||||
totalMessages: 0,
|
||||
totalTokens: 0,
|
||||
},
|
||||
daily: [],
|
||||
topUsers: [],
|
||||
churnedUsers: [],
|
||||
latest: { conversations: [], page: 1, pageSize: 10, pages: 1 },
|
||||
};
|
||||
|
||||
const insightsEnabled = jest.fn(() => true);
|
||||
const insightsDisabled = jest.fn(() => false);
|
||||
|
||||
const createResponse = () => {
|
||||
const status = jest.fn();
|
||||
const json = jest.fn();
|
||||
status.mockReturnValue({ json });
|
||||
return {
|
||||
response: { status, json } as Partial<Response> as Response,
|
||||
status,
|
||||
json,
|
||||
};
|
||||
};
|
||||
|
||||
const createRequest = (query: ServerRequest['query'] = {}): ServerRequest =>
|
||||
({
|
||||
query,
|
||||
user: { id: 'admin-id', role: 'ADMIN', tenantId: 'tenant-a' },
|
||||
}) as ServerRequest;
|
||||
|
||||
describe('Insights handlers', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns access when Insights is enabled', async () => {
|
||||
const handler = createInsightsAccessHandler({ isInsightsEnabled: insightsEnabled });
|
||||
const { response, json } = createResponse();
|
||||
|
||||
await handler(createRequest(), response);
|
||||
|
||||
expect(insightsEnabled).toHaveBeenCalledTimes(1);
|
||||
expect(json).toHaveBeenCalledWith({ access: true });
|
||||
});
|
||||
|
||||
it('returns 404 from the access endpoint when Insights is disabled', async () => {
|
||||
const handler = createInsightsAccessHandler({ isInsightsEnabled: insightsDisabled });
|
||||
const { response, status, json } = createResponse();
|
||||
|
||||
await handler(createRequest(), response);
|
||||
|
||||
expect(status).toHaveBeenCalledWith(404);
|
||||
expect(json).toHaveBeenCalledWith({ message: 'Not found' });
|
||||
});
|
||||
|
||||
it('returns 404 when Insights is disabled', async () => {
|
||||
const getInsights = jest.fn();
|
||||
const handler = createInsightsHandler({ isInsightsEnabled: insightsDisabled, getInsights });
|
||||
const { response, status, json } = createResponse();
|
||||
|
||||
await handler(createRequest(), response);
|
||||
|
||||
expect(status).toHaveBeenCalledWith(404);
|
||||
expect(json).toHaveBeenCalledWith({ message: 'Not found' });
|
||||
expect(getInsights).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('loads a bounded page for the authenticated tenant', async () => {
|
||||
const getInsights = jest.fn().mockResolvedValue(emptyInsights);
|
||||
const handler = createInsightsHandler({ isInsightsEnabled: insightsEnabled, getInsights });
|
||||
const { response, json } = createResponse();
|
||||
|
||||
await handler(
|
||||
createRequest({ page: '3', pageSize: '500', tenantId: 'forged-tenant', range: '30d' }),
|
||||
response,
|
||||
);
|
||||
|
||||
expect(getInsights).toHaveBeenCalledWith({
|
||||
page: 3,
|
||||
pageSize: 50,
|
||||
tenantId: 'tenant-a',
|
||||
search: undefined,
|
||||
range: '30d',
|
||||
fromTimestamp: undefined,
|
||||
toTimestamp: undefined,
|
||||
timeZone: undefined,
|
||||
});
|
||||
expect(json).toHaveBeenCalledWith(emptyInsights);
|
||||
});
|
||||
|
||||
it('passes custom dates and a valid timezone to the data layer', async () => {
|
||||
const getInsights = jest.fn().mockResolvedValue(emptyInsights);
|
||||
const handler = createInsightsHandler({ isInsightsEnabled: insightsEnabled, getInsights });
|
||||
const { response } = createResponse();
|
||||
|
||||
await handler(
|
||||
createRequest({
|
||||
range: 'custom',
|
||||
fromTimestamp: '2026-06-01T00:00:00.000Z',
|
||||
toTimestamp: '2026-06-10T00:00:00.000Z',
|
||||
timeZone: 'America/Los_Angeles',
|
||||
}),
|
||||
response,
|
||||
);
|
||||
|
||||
expect(getInsights).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
range: 'custom',
|
||||
fromTimestamp: '2026-06-01T00:00:00.000Z',
|
||||
toTimestamp: '2026-06-10T00:00:00.000Z',
|
||||
timeZone: 'America/Los_Angeles',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('uses pagination defaults and discards an invalid timezone', async () => {
|
||||
const getInsights = jest.fn().mockResolvedValue(emptyInsights);
|
||||
const handler = createInsightsHandler({ isInsightsEnabled: insightsEnabled, getInsights });
|
||||
const { response } = createResponse();
|
||||
|
||||
await handler(
|
||||
createRequest({ page: '-1', pageSize: 'invalid', timeZone: 'Not/A-Timezone' }),
|
||||
response,
|
||||
);
|
||||
|
||||
expect(getInsights).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ page: 1, pageSize: 10, timeZone: undefined }),
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes and bounds search input', async () => {
|
||||
const getInsights = jest.fn().mockResolvedValue(emptyInsights);
|
||||
const handler = createInsightsHandler({ isInsightsEnabled: insightsEnabled, getInsights });
|
||||
const { response } = createResponse();
|
||||
|
||||
await handler(createRequest({ search: ` ${'message'.repeat(40)} ` }), response);
|
||||
|
||||
expect(getInsights).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ search: 'message'.repeat(40).slice(0, 200) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not run searches shorter than the minimum length', async () => {
|
||||
const getInsights = jest.fn().mockResolvedValue(emptyInsights);
|
||||
const handler = createInsightsHandler({ isInsightsEnabled: insightsEnabled, getInsights });
|
||||
const { response } = createResponse();
|
||||
|
||||
await handler(createRequest({ search: 'ab' }), response);
|
||||
|
||||
expect(getInsights).toHaveBeenCalledWith(expect.objectContaining({ search: undefined }));
|
||||
});
|
||||
});
|
||||
103
packages/api/src/insights/handlers.ts
Normal file
103
packages/api/src/insights/handlers.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { logger } from '@librechat/data-schemas';
|
||||
import {
|
||||
INSIGHTS_SEARCH_MAX_LENGTH,
|
||||
INSIGHTS_SEARCH_MIN_LENGTH,
|
||||
type TInsightsParams,
|
||||
} from 'librechat-data-provider';
|
||||
import type { InsightsMethods } from '@librechat/data-schemas';
|
||||
import type { Response } from 'express';
|
||||
import type { ServerRequest } from '~/types';
|
||||
|
||||
type InsightsHandlerDeps = {
|
||||
isInsightsEnabled: () => boolean;
|
||||
getInsights: InsightsMethods['getInsights'];
|
||||
};
|
||||
|
||||
type InsightsAccessHandlerDeps = Pick<InsightsHandlerDeps, 'isInsightsEnabled'>;
|
||||
|
||||
const firstQueryValue = (value: unknown): string | undefined => {
|
||||
if (Array.isArray(value)) {
|
||||
return firstQueryValue(value[0]);
|
||||
}
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
};
|
||||
|
||||
const positiveInteger = (value: unknown, fallback: number): number => {
|
||||
const parsed = Number.parseInt(firstQueryValue(value) ?? '', 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
};
|
||||
|
||||
const stringValue = (input: unknown): string | undefined => {
|
||||
const value = firstQueryValue(input)?.trim();
|
||||
return value ? value : undefined;
|
||||
};
|
||||
|
||||
const validRanges = new Set<TInsightsParams['range']>(['24h', '7d', '30d', 'custom']);
|
||||
|
||||
const insightsRange = (value: unknown): TInsightsParams['range'] | undefined => {
|
||||
const range = stringValue(value) as TInsightsParams['range'] | undefined;
|
||||
return range && validRanges.has(range) ? range : undefined;
|
||||
};
|
||||
|
||||
const timeZoneValue = (value: unknown): string | undefined => {
|
||||
const timeZone = stringValue(value)?.slice(0, 100);
|
||||
if (!timeZone) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
new Intl.DateTimeFormat('en', { timeZone }).format();
|
||||
return timeZone;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export function createInsightsAccessHandler({ isInsightsEnabled }: InsightsAccessHandlerDeps) {
|
||||
return async (_req: ServerRequest, res: Response): Promise<void> => {
|
||||
try {
|
||||
if (!isInsightsEnabled()) {
|
||||
res.status(404).json({ message: 'Not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ access: true });
|
||||
} catch (error) {
|
||||
logger.error('[Insights] Failed to load access status', error);
|
||||
res.status(500).json({ message: 'Failed to load insights access' });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function createInsightsHandler({ isInsightsEnabled, getInsights }: InsightsHandlerDeps) {
|
||||
return async (req: ServerRequest, res: Response): Promise<void> => {
|
||||
try {
|
||||
if (!isInsightsEnabled()) {
|
||||
res.status(404).json({ message: 'Not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const page = positiveInteger(req.query.page, 1);
|
||||
const pageSize = Math.min(50, Math.max(5, positiveInteger(req.query.pageSize, 10)));
|
||||
const tenantId = stringValue(req.user?.tenantId);
|
||||
const requestedSearch = stringValue(req.query.search)?.slice(0, INSIGHTS_SEARCH_MAX_LENGTH);
|
||||
const search =
|
||||
requestedSearch && requestedSearch.length >= INSIGHTS_SEARCH_MIN_LENGTH
|
||||
? requestedSearch
|
||||
: undefined;
|
||||
const insights = await getInsights({
|
||||
page,
|
||||
pageSize,
|
||||
tenantId,
|
||||
search,
|
||||
range: insightsRange(req.query.range),
|
||||
fromTimestamp: stringValue(req.query.fromTimestamp),
|
||||
toTimestamp: stringValue(req.query.toTimestamp),
|
||||
timeZone: timeZoneValue(req.query.timeZone),
|
||||
});
|
||||
res.json(insights);
|
||||
} catch (error) {
|
||||
logger.error('[Insights] Failed to load dashboard', error);
|
||||
res.status(500).json({ message: 'Failed to load insights' });
|
||||
}
|
||||
};
|
||||
}
|
||||
1
packages/api/src/insights/index.ts
Normal file
1
packages/api/src/insights/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './handlers';
|
||||
|
|
@ -155,12 +155,12 @@ async function createConflictingServer(): Promise<ConflictTestServer> {
|
|||
}
|
||||
|
||||
async function waitForCondition(
|
||||
predicate: () => boolean,
|
||||
predicate: () => boolean | Promise<boolean>,
|
||||
timeoutMs = 10000,
|
||||
intervalMs = 25,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (!predicate()) {
|
||||
while (!(await predicate())) {
|
||||
if (Date.now() > deadline) {
|
||||
throw new Error('Timed out waiting for condition');
|
||||
}
|
||||
|
|
@ -250,23 +250,24 @@ describe('MCPConnection standalone SSE stream conflict', () => {
|
|||
it('rebuilds the session so the connection recovers from the conflict', async () => {
|
||||
const srv = await createConflictingServer();
|
||||
server = srv;
|
||||
conn = new MCPConnection({
|
||||
const connection = new MCPConnection({
|
||||
serverName: 'test',
|
||||
serverConfig: { type: 'streamable-http', url: srv.url },
|
||||
useSSRFProtection: false,
|
||||
});
|
||||
conn = connection;
|
||||
|
||||
await conn.connect();
|
||||
const firstSessionId = (conn as unknown as { transport?: { sessionId?: string } }).transport
|
||||
?.sessionId;
|
||||
await connection.connect();
|
||||
const firstSessionId = (connection as unknown as { transport?: { sessionId?: string } })
|
||||
.transport?.sessionId;
|
||||
expect(firstSessionId).toBeTruthy();
|
||||
|
||||
await waitForCondition(() => {
|
||||
const current = (conn as unknown as { transport?: { sessionId?: string } }).transport
|
||||
await waitForCondition(async () => {
|
||||
const current = (connection as unknown as { transport?: { sessionId?: string } }).transport
|
||||
?.sessionId;
|
||||
return Boolean(current) && current !== firstSessionId;
|
||||
return Boolean(current) && current !== firstSessionId && (await connection.isConnected());
|
||||
}, 15000);
|
||||
|
||||
expect(await conn.isConnected()).toBe(true);
|
||||
expect(await connection.isConnected()).toBe(true);
|
||||
}, 25000);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -420,6 +420,9 @@ export const skillFiles = (id: string) => `${getSkill(id)}/files`;
|
|||
export const skillFile = (id: string, relativePath: string) =>
|
||||
`${skillFiles(id)}/${encodeURIComponent(relativePath)}`;
|
||||
|
||||
export const insights = () => `${BASE_URL}/api/admin/insights`;
|
||||
export const insightsAccess = () => `${insights()}/access`;
|
||||
|
||||
export const adminSkillsSync = () => `${BASE_URL}/api/admin/skills/sync`;
|
||||
export const adminSkillsSyncStatus = () => `${adminSkillsSync()}/status`;
|
||||
export const adminSkillsSyncRun = () => `${adminSkillsSync()}/run`;
|
||||
|
|
|
|||
|
|
@ -1655,6 +1655,7 @@ export type TStartupConfig = {
|
|||
socialLogins?: string[];
|
||||
langfuseFanoutEnabled?: boolean;
|
||||
langfuseConnectionAccess?: boolean;
|
||||
insightsEnabled?: boolean;
|
||||
interface?: TInterfaceConfig;
|
||||
turnstile?: TTurnstileConfig;
|
||||
balance?: TBalanceConfig;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { AxiosResponse } from 'axios';
|
||||
import type { TInsightsAccessResponse, TInsightsParams, TInsightsResponse } from './types/insights';
|
||||
import type { TFileConfig } from './file-config';
|
||||
import type * as t from './types';
|
||||
import * as permissions from './accessPermissions';
|
||||
|
|
@ -16,6 +17,21 @@ import request from './request';
|
|||
import * as s from './schemas';
|
||||
import * as r from './roles';
|
||||
|
||||
export function getInsights(params: TInsightsParams = {}): Promise<TInsightsResponse> {
|
||||
const query = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
query.set(key, String(value));
|
||||
}
|
||||
}
|
||||
const suffix = query.toString() ? `?${query.toString()}` : '';
|
||||
return request.get(`${endpoints.insights()}${suffix}`);
|
||||
}
|
||||
|
||||
export function getInsightsAccess(): Promise<TInsightsAccessResponse> {
|
||||
return request.get(endpoints.insightsAccess());
|
||||
}
|
||||
|
||||
export function getLangfuseConnection(): Promise<t.TLangfuseConnectionStatus> {
|
||||
return request.get(endpoints.adminLangfuseConnection());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ export * from './types/skills';
|
|||
export * from './types/runs';
|
||||
export * from './types/web';
|
||||
export * from './types/graph';
|
||||
export * from './types/insights';
|
||||
/* access permissions */
|
||||
export * from './accessPermissions';
|
||||
/* query/mutation keys */
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ export enum QueryKeys {
|
|||
tokenCount = 'tokenCount',
|
||||
availablePlugins = 'availablePlugins',
|
||||
startupConfig = 'startupConfig',
|
||||
insights = 'insights',
|
||||
insightsAccess = 'insightsAccess',
|
||||
assistants = 'assistants',
|
||||
assistant = 'assistant',
|
||||
agents = 'agents',
|
||||
|
|
|
|||
70
packages/data-provider/src/types/insights.ts
Normal file
70
packages/data-provider/src/types/insights.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
export type InsightsRange = '24h' | '7d' | '30d' | 'custom';
|
||||
|
||||
export const INSIGHTS_MAX_RANGE_DAYS = 30;
|
||||
export const INSIGHTS_SEARCH_MIN_LENGTH = 3;
|
||||
export const INSIGHTS_SEARCH_MAX_LENGTH = 200;
|
||||
export type TInsightsParams = {
|
||||
range?: InsightsRange;
|
||||
fromTimestamp?: string;
|
||||
toTimestamp?: string;
|
||||
timeZone?: string;
|
||||
search?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
};
|
||||
|
||||
export type TInsightsDailyPoint = {
|
||||
date: string;
|
||||
conversations: number;
|
||||
users: number;
|
||||
messages: number;
|
||||
totalTokens: number;
|
||||
};
|
||||
|
||||
export type TInsightsUser = {
|
||||
userId: string;
|
||||
name: string;
|
||||
email: string;
|
||||
conversations: number;
|
||||
messages: number;
|
||||
};
|
||||
|
||||
export type TInsightsChurnedUser = TInsightsUser & {
|
||||
firstSeen: string;
|
||||
lastSeen: string;
|
||||
};
|
||||
|
||||
export type TInsightsConversation = {
|
||||
conversationId: string;
|
||||
date: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
email: string;
|
||||
firstMessage: string;
|
||||
messages: number;
|
||||
totalTokens: number;
|
||||
};
|
||||
|
||||
export type TInsightsSummary = {
|
||||
totalUsers: number;
|
||||
totalConversations: number;
|
||||
totalMessages: number;
|
||||
totalTokens: number;
|
||||
};
|
||||
|
||||
export type TInsightsResponse = {
|
||||
summary: TInsightsSummary;
|
||||
daily: TInsightsDailyPoint[];
|
||||
topUsers: TInsightsUser[];
|
||||
churnedUsers: TInsightsChurnedUser[];
|
||||
latest: {
|
||||
conversations: TInsightsConversation[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
pages: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type TInsightsAccessResponse = {
|
||||
access: boolean;
|
||||
};
|
||||
|
|
@ -25,6 +25,7 @@ export const SystemCapabilities = {
|
|||
MANAGE_CONFIGS: 'manage:configs',
|
||||
ASSIGN_CONFIGS: 'assign:configs',
|
||||
READ_USAGE: 'read:usage',
|
||||
READ_INSIGHTS: 'read:insights',
|
||||
READ_AGENTS: 'read:agents',
|
||||
MANAGE_AGENTS: 'manage:agents',
|
||||
MANAGE_MCP_SERVERS: 'manage:mcpservers',
|
||||
|
|
@ -261,6 +262,7 @@ export const CAPABILITY_CATEGORIES: CapabilityCategory[] = [
|
|||
capabilities: [
|
||||
SystemCapabilities.ACCESS_ADMIN,
|
||||
SystemCapabilities.READ_USAGE,
|
||||
SystemCapabilities.READ_INSIGHTS,
|
||||
SystemCapabilities.READ_AUDIT_LOG,
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -122,6 +122,8 @@ import {
|
|||
type MCPAuthorityConfigSourceDocument,
|
||||
type MCPAuthorityCredentialSourceDocument,
|
||||
} from './mcpAuthority';
|
||||
/* Insights */
|
||||
import { createInsightsMethods, type InsightsMethods } from './insights';
|
||||
|
||||
export {
|
||||
RoleConflictError,
|
||||
|
|
@ -191,7 +193,8 @@ export type AllMethods = UserMethods &
|
|||
AgentTriggerDeliveryMethods &
|
||||
AgentMethods &
|
||||
ConfigMethods &
|
||||
MCPAuthorityMethods;
|
||||
MCPAuthorityMethods &
|
||||
InsightsMethods;
|
||||
|
||||
/** Dependencies injected from the api layer into createMethods */
|
||||
export interface CreateMethodsDeps {
|
||||
|
|
@ -332,6 +335,8 @@ export function createMethods(
|
|||
...createConfigMethods(mongoose),
|
||||
/* MCP authority proofs */
|
||||
...createMCPAuthorityMethods(mongoose),
|
||||
/* Insights */
|
||||
...createInsightsMethods(mongoose),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -390,4 +395,5 @@ export type {
|
|||
MCPAuthorityMethodHooks,
|
||||
MCPAuthorityConfigSourceDocument,
|
||||
MCPAuthorityCredentialSourceDocument,
|
||||
InsightsMethods,
|
||||
};
|
||||
|
|
|
|||
807
packages/data-schemas/src/methods/insights.spec.ts
Normal file
807
packages/data-schemas/src/methods/insights.spec.ts
Normal file
|
|
@ -0,0 +1,807 @@
|
|||
import mongoose from 'mongoose';
|
||||
import { MongoMemoryServer } from 'mongodb-memory-server';
|
||||
import { createConversationModel } from '../models/convo';
|
||||
import { createMessageModel } from '../models/message';
|
||||
import { createInsightsMethods } from './insights';
|
||||
import { createUserModel } from '../models/user';
|
||||
|
||||
let mongoServer: MongoMemoryServer;
|
||||
|
||||
beforeAll(async () => {
|
||||
mongoServer = await MongoMemoryServer.create();
|
||||
await mongoose.connect(mongoServer.getUri());
|
||||
const models = [
|
||||
createConversationModel(mongoose),
|
||||
createMessageModel(mongoose),
|
||||
createUserModel(mongoose),
|
||||
];
|
||||
await Promise.all(models.map((model) => model.init()));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await mongoose.disconnect();
|
||||
await mongoServer.stop();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await Promise.all([
|
||||
mongoose.models.Conversation.deleteMany({}),
|
||||
mongoose.models.Message.deleteMany({}),
|
||||
mongoose.models.User.deleteMany({}),
|
||||
]);
|
||||
});
|
||||
|
||||
describe('Insights methods', () => {
|
||||
it('counts active users from prompts and all messages for top users within the tenant', async () => {
|
||||
const now = new Date();
|
||||
const activeAt = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
const oldConversationAt = new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000);
|
||||
const activeUserId = new mongoose.Types.ObjectId();
|
||||
const newUserId = new mongoose.Types.ObjectId();
|
||||
const otherTenantUserId = new mongoose.Types.ObjectId();
|
||||
|
||||
await mongoose.models.User.collection.insertMany([
|
||||
{
|
||||
_id: activeUserId,
|
||||
tenantId: 'tenant-a',
|
||||
name: 'Active Existing User',
|
||||
email: 'active@example.com',
|
||||
},
|
||||
{
|
||||
_id: newUserId,
|
||||
tenantId: 'tenant-a',
|
||||
name: 'New User',
|
||||
email: 'new@example.com',
|
||||
},
|
||||
{
|
||||
_id: otherTenantUserId,
|
||||
tenantId: 'tenant-b',
|
||||
name: 'Other Tenant User',
|
||||
email: 'other@example.com',
|
||||
},
|
||||
]);
|
||||
|
||||
await mongoose.models.Conversation.collection.insertMany([
|
||||
{
|
||||
conversationId: 'old-conversation',
|
||||
tenantId: 'tenant-a',
|
||||
user: activeUserId.toString(),
|
||||
createdAt: oldConversationAt,
|
||||
updatedAt: activeAt,
|
||||
isTemporary: false,
|
||||
},
|
||||
{
|
||||
conversationId: 'new-conversation',
|
||||
tenantId: 'tenant-a',
|
||||
user: newUserId.toString(),
|
||||
createdAt: activeAt,
|
||||
updatedAt: activeAt,
|
||||
isTemporary: false,
|
||||
},
|
||||
{
|
||||
conversationId: 'other-conversation',
|
||||
tenantId: 'tenant-b',
|
||||
user: otherTenantUserId.toString(),
|
||||
createdAt: activeAt,
|
||||
updatedAt: activeAt,
|
||||
isTemporary: false,
|
||||
},
|
||||
]);
|
||||
|
||||
const messages = [
|
||||
['old-user', 'old-conversation', activeUserId, true],
|
||||
['old-assistant', 'old-conversation', activeUserId, false],
|
||||
['new-user', 'new-conversation', newUserId, true],
|
||||
['new-assistant', 'new-conversation', newUserId, false],
|
||||
['other-user', 'other-conversation', otherTenantUserId, true],
|
||||
['other-assistant', 'other-conversation', otherTenantUserId, false],
|
||||
] as const;
|
||||
|
||||
await mongoose.models.Message.collection.insertMany([
|
||||
...messages.map(([messageId, conversationId, userId, isCreatedByUser]) => ({
|
||||
messageId,
|
||||
conversationId,
|
||||
tenantId: conversationId === 'other-conversation' ? 'tenant-b' : 'tenant-a',
|
||||
user: userId.toString(),
|
||||
createdAt: activeAt,
|
||||
updatedAt: activeAt,
|
||||
isCreatedByUser,
|
||||
isTemporary: false,
|
||||
text: messageId,
|
||||
tokenCount: 10,
|
||||
})),
|
||||
{
|
||||
messageId: 'unattributed-null',
|
||||
conversationId: 'old-conversation',
|
||||
tenantId: 'tenant-a',
|
||||
user: null,
|
||||
createdAt: activeAt,
|
||||
updatedAt: activeAt,
|
||||
isCreatedByUser: false,
|
||||
isTemporary: false,
|
||||
text: 'Unattributed message',
|
||||
tokenCount: 10,
|
||||
},
|
||||
{
|
||||
messageId: 'unattributed-empty',
|
||||
conversationId: 'old-conversation',
|
||||
tenantId: 'tenant-a',
|
||||
user: '',
|
||||
createdAt: activeAt,
|
||||
updatedAt: activeAt,
|
||||
isCreatedByUser: false,
|
||||
isTemporary: false,
|
||||
text: 'Unattributed message',
|
||||
tokenCount: 10,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await createInsightsMethods(mongoose).getInsights({
|
||||
tenantId: 'tenant-a',
|
||||
range: '7d',
|
||||
});
|
||||
|
||||
expect(result.summary).toEqual(
|
||||
expect.objectContaining({
|
||||
totalUsers: 2,
|
||||
totalConversations: 1,
|
||||
totalMessages: 4,
|
||||
totalTokens: 40,
|
||||
}),
|
||||
);
|
||||
expect(result.daily).toContainEqual(
|
||||
expect.objectContaining({ conversations: 1, users: 2, messages: 4, totalTokens: 40 }),
|
||||
);
|
||||
expect(result.topUsers).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
userId: activeUserId.toString(),
|
||||
messages: 2,
|
||||
conversations: 1,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
userId: newUserId.toString(),
|
||||
messages: 2,
|
||||
conversations: 1,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(result.topUsers).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('lists users whose 28-day inactivity threshold falls within the selected range', async () => {
|
||||
const now = Date.now();
|
||||
const daysAgo = (days: number) => new Date(now - days * 24 * 60 * 60 * 1000);
|
||||
const churnedUserId = new mongoose.Types.ObjectId();
|
||||
const previouslyChurnedUserId = new mongoose.Types.ObjectId();
|
||||
const activeUserId = new mongoose.Types.ObjectId();
|
||||
const otherTenantUserId = new mongoose.Types.ObjectId();
|
||||
|
||||
await mongoose.models.User.collection.insertMany([
|
||||
{
|
||||
_id: churnedUserId,
|
||||
tenantId: 'tenant-a',
|
||||
name: 'Churned User',
|
||||
email: 'churned@example.com',
|
||||
},
|
||||
{
|
||||
_id: previouslyChurnedUserId,
|
||||
tenantId: 'tenant-a',
|
||||
name: 'Previously Churned User',
|
||||
email: 'previously-churned@example.com',
|
||||
},
|
||||
{
|
||||
_id: activeUserId,
|
||||
tenantId: 'tenant-a',
|
||||
name: 'Active User',
|
||||
email: 'active@example.com',
|
||||
},
|
||||
{
|
||||
_id: otherTenantUserId,
|
||||
tenantId: 'tenant-b',
|
||||
name: 'Other Tenant User',
|
||||
email: 'other@example.com',
|
||||
},
|
||||
]);
|
||||
|
||||
await mongoose.models.Message.collection.insertMany([
|
||||
{
|
||||
messageId: 'churned-first-prompt',
|
||||
conversationId: 'churned-conversation-1',
|
||||
tenantId: 'tenant-a',
|
||||
user: churnedUserId.toString(),
|
||||
createdAt: daysAgo(60),
|
||||
updatedAt: daysAgo(60),
|
||||
isCreatedByUser: true,
|
||||
isTemporary: false,
|
||||
},
|
||||
{
|
||||
messageId: 'churned-second-prompt',
|
||||
conversationId: 'churned-conversation-1',
|
||||
tenantId: 'tenant-a',
|
||||
user: churnedUserId.toString(),
|
||||
createdAt: daysAgo(45),
|
||||
updatedAt: daysAgo(45),
|
||||
isCreatedByUser: true,
|
||||
isTemporary: false,
|
||||
},
|
||||
{
|
||||
messageId: 'churned-last-prompt',
|
||||
conversationId: 'churned-conversation-2',
|
||||
tenantId: 'tenant-a',
|
||||
user: churnedUserId.toString(),
|
||||
createdAt: daysAgo(29),
|
||||
updatedAt: daysAgo(29),
|
||||
isCreatedByUser: true,
|
||||
isTemporary: false,
|
||||
},
|
||||
{
|
||||
messageId: 'recent-assistant-response',
|
||||
conversationId: 'churned-conversation-2',
|
||||
tenantId: 'tenant-a',
|
||||
user: churnedUserId.toString(),
|
||||
createdAt: daysAgo(1),
|
||||
updatedAt: daysAgo(1),
|
||||
isCreatedByUser: false,
|
||||
isTemporary: false,
|
||||
},
|
||||
{
|
||||
messageId: 'previously-churned-prompt',
|
||||
conversationId: 'previously-churned-conversation',
|
||||
tenantId: 'tenant-a',
|
||||
user: previouslyChurnedUserId.toString(),
|
||||
createdAt: daysAgo(40),
|
||||
updatedAt: daysAgo(40),
|
||||
isCreatedByUser: true,
|
||||
isTemporary: false,
|
||||
},
|
||||
{
|
||||
messageId: 'active-prompt',
|
||||
conversationId: 'active-conversation',
|
||||
tenantId: 'tenant-a',
|
||||
user: activeUserId.toString(),
|
||||
createdAt: daysAgo(27),
|
||||
updatedAt: daysAgo(27),
|
||||
isCreatedByUser: true,
|
||||
isTemporary: false,
|
||||
},
|
||||
{
|
||||
messageId: 'other-tenant-old-prompt',
|
||||
conversationId: 'other-conversation',
|
||||
tenantId: 'tenant-b',
|
||||
user: otherTenantUserId.toString(),
|
||||
createdAt: daysAgo(35),
|
||||
updatedAt: daysAgo(35),
|
||||
isCreatedByUser: true,
|
||||
isTemporary: false,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await createInsightsMethods(mongoose).getInsights({
|
||||
tenantId: 'tenant-a',
|
||||
range: '7d',
|
||||
});
|
||||
|
||||
expect(result.churnedUsers).toEqual([
|
||||
{
|
||||
userId: churnedUserId.toString(),
|
||||
name: 'Churned User',
|
||||
email: 'churned@example.com',
|
||||
conversations: 2,
|
||||
messages: 4,
|
||||
firstSeen: daysAgo(60).toISOString(),
|
||||
lastSeen: daysAgo(29).toISOString(),
|
||||
},
|
||||
]);
|
||||
expect(result.summary).not.toHaveProperty('churnedUsers');
|
||||
});
|
||||
|
||||
it('keeps duplicate conversation IDs isolated by owner', async () => {
|
||||
const now = new Date();
|
||||
const firstUserId = new mongoose.Types.ObjectId();
|
||||
const secondUserId = new mongoose.Types.ObjectId();
|
||||
await mongoose.models.User.collection.insertMany([
|
||||
{
|
||||
_id: firstUserId,
|
||||
tenantId: 'tenant-a',
|
||||
name: 'First User',
|
||||
email: 'first@example.com',
|
||||
},
|
||||
{
|
||||
_id: secondUserId,
|
||||
tenantId: 'tenant-a',
|
||||
name: 'Second User',
|
||||
email: 'second@example.com',
|
||||
},
|
||||
]);
|
||||
await mongoose.models.Conversation.collection.insertMany([
|
||||
{
|
||||
conversationId: 'shared-client-id',
|
||||
tenantId: 'tenant-a',
|
||||
user: firstUserId.toString(),
|
||||
title: 'First conversation',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
isTemporary: false,
|
||||
},
|
||||
{
|
||||
conversationId: 'shared-client-id',
|
||||
tenantId: 'tenant-a',
|
||||
user: secondUserId.toString(),
|
||||
title: 'Second conversation',
|
||||
createdAt: new Date(now.getTime() - 1),
|
||||
updatedAt: now,
|
||||
isTemporary: false,
|
||||
},
|
||||
]);
|
||||
await mongoose.models.Message.collection.insertMany([
|
||||
{
|
||||
messageId: 'first-prompt',
|
||||
conversationId: 'shared-client-id',
|
||||
tenantId: 'tenant-a',
|
||||
user: firstUserId.toString(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
isCreatedByUser: true,
|
||||
isTemporary: false,
|
||||
text: 'First owner private prompt',
|
||||
tokenCount: 10,
|
||||
},
|
||||
{
|
||||
messageId: 'first-response',
|
||||
conversationId: 'shared-client-id',
|
||||
tenantId: 'tenant-a',
|
||||
user: firstUserId.toString(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
isCreatedByUser: false,
|
||||
isTemporary: false,
|
||||
endpoint: 'agents',
|
||||
sender: 'First Agent',
|
||||
text: 'First response',
|
||||
tokenCount: 20,
|
||||
},
|
||||
{
|
||||
messageId: 'second-prompt',
|
||||
conversationId: 'shared-client-id',
|
||||
tenantId: 'tenant-a',
|
||||
user: secondUserId.toString(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
isCreatedByUser: true,
|
||||
isTemporary: false,
|
||||
text: 'Second owner private prompt',
|
||||
tokenCount: 30,
|
||||
},
|
||||
{
|
||||
messageId: 'second-response',
|
||||
conversationId: 'shared-client-id',
|
||||
tenantId: 'tenant-a',
|
||||
user: secondUserId.toString(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
isCreatedByUser: false,
|
||||
isTemporary: false,
|
||||
endpoint: 'agents',
|
||||
sender: 'Second Agent',
|
||||
text: 'Second response',
|
||||
tokenCount: 40,
|
||||
},
|
||||
]);
|
||||
|
||||
const methods = createInsightsMethods(mongoose);
|
||||
const insights = await methods.getInsights({ tenantId: 'tenant-a', range: '7d' });
|
||||
const byUser = new Map(insights.latest.conversations.map((row) => [row.userId, row]));
|
||||
|
||||
expect(byUser.get(firstUserId.toString())).toEqual(
|
||||
expect.objectContaining({
|
||||
firstMessage: 'First owner private prompt',
|
||||
messages: 2,
|
||||
totalTokens: 30,
|
||||
}),
|
||||
);
|
||||
expect(byUser.get(secondUserId.toString())).toEqual(
|
||||
expect.objectContaining({
|
||||
firstMessage: 'Second owner private prompt',
|
||||
messages: 2,
|
||||
totalTokens: 70,
|
||||
}),
|
||||
);
|
||||
|
||||
const search = await methods.getInsights({
|
||||
tenantId: 'tenant-a',
|
||||
range: '7d',
|
||||
search: 'first@example.com',
|
||||
});
|
||||
expect(search.latest.conversations).toEqual([
|
||||
expect.objectContaining({ userId: firstUserId.toString() }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('bounds latest conversation message totals to a custom range', async () => {
|
||||
const from = new Date('2026-01-01T00:00:00.000Z');
|
||||
const to = new Date('2026-01-07T23:59:59.999Z');
|
||||
const inRange = new Date('2026-01-02T12:00:00.000Z');
|
||||
const temporaryInRange = new Date('2026-01-02T11:59:00.000Z');
|
||||
const afterRange = new Date('2026-01-08T12:00:00.000Z');
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
|
||||
await mongoose.models.User.collection.insertOne({
|
||||
_id: userId,
|
||||
tenantId: 'tenant-a',
|
||||
name: 'Historical User',
|
||||
email: 'historical@example.com',
|
||||
});
|
||||
await mongoose.models.Conversation.collection.insertOne({
|
||||
conversationId: 'historical-conversation',
|
||||
tenantId: 'tenant-a',
|
||||
user: userId.toString(),
|
||||
title: 'Historical conversation',
|
||||
createdAt: inRange,
|
||||
updatedAt: afterRange,
|
||||
isTemporary: false,
|
||||
});
|
||||
await mongoose.models.Message.collection.insertMany([
|
||||
{
|
||||
messageId: 'temporary-prompt',
|
||||
conversationId: 'historical-conversation',
|
||||
tenantId: 'tenant-a',
|
||||
user: userId.toString(),
|
||||
createdAt: temporaryInRange,
|
||||
updatedAt: temporaryInRange,
|
||||
isCreatedByUser: true,
|
||||
isTemporary: true,
|
||||
text: 'Temporary prompt',
|
||||
tokenCount: 100,
|
||||
},
|
||||
{
|
||||
messageId: 'historical-prompt',
|
||||
conversationId: 'historical-conversation',
|
||||
tenantId: 'tenant-a',
|
||||
user: userId.toString(),
|
||||
createdAt: inRange,
|
||||
updatedAt: inRange,
|
||||
isCreatedByUser: true,
|
||||
isTemporary: false,
|
||||
text: 'Historical prompt',
|
||||
tokenCount: 10,
|
||||
},
|
||||
{
|
||||
messageId: 'historical-response',
|
||||
conversationId: 'historical-conversation',
|
||||
tenantId: 'tenant-a',
|
||||
user: userId.toString(),
|
||||
createdAt: inRange,
|
||||
updatedAt: inRange,
|
||||
isCreatedByUser: false,
|
||||
isTemporary: false,
|
||||
text: 'Historical response',
|
||||
tokenCount: 20,
|
||||
},
|
||||
{
|
||||
messageId: 'later-response',
|
||||
conversationId: 'historical-conversation',
|
||||
tenantId: 'tenant-a',
|
||||
user: userId.toString(),
|
||||
createdAt: afterRange,
|
||||
updatedAt: afterRange,
|
||||
isCreatedByUser: false,
|
||||
isTemporary: false,
|
||||
text: 'Later response',
|
||||
tokenCount: 30,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await createInsightsMethods(mongoose).getInsights({
|
||||
tenantId: 'tenant-a',
|
||||
range: 'custom',
|
||||
fromTimestamp: from.toISOString(),
|
||||
toTimestamp: to.toISOString(),
|
||||
});
|
||||
|
||||
expect(result.latest.conversations).toEqual([
|
||||
expect.objectContaining({
|
||||
conversationId: 'historical-conversation',
|
||||
firstMessage: 'Historical prompt',
|
||||
messages: 2,
|
||||
totalTokens: 30,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses the selected timezone for daily buckets and fills inactive dates', async () => {
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
const activity = [new Date('2026-08-01T12:00:00.000Z'), new Date('2026-08-03T12:00:00.000Z')];
|
||||
|
||||
await mongoose.models.User.collection.insertOne({
|
||||
_id: userId,
|
||||
tenantId: 'tenant-a',
|
||||
name: 'Pacific User',
|
||||
email: 'pacific@example.com',
|
||||
});
|
||||
await mongoose.models.Conversation.collection.insertMany(
|
||||
activity.map((createdAt, index) => ({
|
||||
conversationId: `pacific-${index}`,
|
||||
tenantId: 'tenant-a',
|
||||
user: userId.toString(),
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
isTemporary: false,
|
||||
})),
|
||||
);
|
||||
await mongoose.models.Message.collection.insertMany(
|
||||
activity.map((createdAt, index) => ({
|
||||
messageId: `pacific-message-${index}`,
|
||||
conversationId: `pacific-${index}`,
|
||||
tenantId: 'tenant-a',
|
||||
user: userId.toString(),
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
isCreatedByUser: true,
|
||||
isTemporary: false,
|
||||
text: `Message ${index}`,
|
||||
})),
|
||||
);
|
||||
|
||||
const result = await createInsightsMethods(mongoose).getInsights({
|
||||
tenantId: 'tenant-a',
|
||||
range: 'custom',
|
||||
fromTimestamp: '2026-08-01T07:00:00.000Z',
|
||||
toTimestamp: '2026-08-04T06:59:59.999Z',
|
||||
timeZone: 'America/Los_Angeles',
|
||||
});
|
||||
|
||||
expect(result.daily).toEqual([
|
||||
{ date: '2026-08-01', conversations: 1, messages: 1, totalTokens: 0, users: 1 },
|
||||
{ date: '2026-08-02', conversations: 0, messages: 0, totalTokens: 0, users: 0 },
|
||||
{ date: '2026-08-03', conversations: 1, messages: 1, totalTokens: 0, users: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves a 30-date custom range across a daylight saving time change', async () => {
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
const activity = new Date('2026-10-04T04:30:00.000Z');
|
||||
|
||||
await mongoose.models.User.collection.insertOne({
|
||||
_id: userId,
|
||||
tenantId: 'tenant-a',
|
||||
name: 'Eastern User',
|
||||
email: 'eastern@example.com',
|
||||
});
|
||||
await mongoose.models.Conversation.collection.insertOne({
|
||||
conversationId: 'dst-fallback',
|
||||
tenantId: 'tenant-a',
|
||||
user: userId.toString(),
|
||||
createdAt: activity,
|
||||
updatedAt: activity,
|
||||
isTemporary: false,
|
||||
});
|
||||
await mongoose.models.Message.collection.insertOne({
|
||||
messageId: 'dst-fallback-message',
|
||||
conversationId: 'dst-fallback',
|
||||
tenantId: 'tenant-a',
|
||||
user: userId.toString(),
|
||||
createdAt: activity,
|
||||
updatedAt: activity,
|
||||
isCreatedByUser: true,
|
||||
isTemporary: false,
|
||||
text: 'Message before the repeated hour',
|
||||
});
|
||||
|
||||
const result = await createInsightsMethods(mongoose).getInsights({
|
||||
tenantId: 'tenant-a',
|
||||
range: 'custom',
|
||||
fromTimestamp: '2026-10-04T04:00:00.000Z',
|
||||
toTimestamp: '2026-11-03T04:59:59.999Z',
|
||||
timeZone: 'America/New_York',
|
||||
});
|
||||
|
||||
expect(result.summary.totalConversations).toBe(1);
|
||||
expect(result.summary.totalMessages).toBe(1);
|
||||
expect(result.daily).toHaveLength(30);
|
||||
expect(result.daily[0]).toEqual(
|
||||
expect.objectContaining({ date: '2026-10-04', conversations: 1, messages: 1 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('fills every local calendar date across a daylight saving time change', async () => {
|
||||
const result = await createInsightsMethods(mongoose).getInsights({
|
||||
tenantId: 'tenant-a',
|
||||
range: 'custom',
|
||||
fromTimestamp: '2026-03-08T04:30:00.000Z',
|
||||
toTimestamp: '2026-03-09T04:30:00.000Z',
|
||||
timeZone: 'America/New_York',
|
||||
});
|
||||
|
||||
expect(result.daily).toEqual([
|
||||
{ date: '2026-03-07', conversations: 0, messages: 0, totalTokens: 0, users: 0 },
|
||||
{ date: '2026-03-08', conversations: 0, messages: 0, totalTokens: 0, users: 0 },
|
||||
{ date: '2026-03-09', conversations: 0, messages: 0, totalTokens: 0, users: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('searches tenant conversations by ID and user without inspecting message text', async () => {
|
||||
const now = new Date();
|
||||
const aliceId = new mongoose.Types.ObjectId();
|
||||
const bobId = new mongoose.Types.ObjectId();
|
||||
const otherTenantUserId = new mongoose.Types.ObjectId();
|
||||
|
||||
await mongoose.models.User.collection.insertMany([
|
||||
{
|
||||
_id: aliceId,
|
||||
tenantId: 'tenant-a',
|
||||
name: 'Alice Example',
|
||||
email: 'alice@example.com',
|
||||
},
|
||||
{
|
||||
_id: bobId,
|
||||
tenantId: 'tenant-a',
|
||||
name: 'Bob Example',
|
||||
email: 'bob@example.com',
|
||||
},
|
||||
{
|
||||
_id: otherTenantUserId,
|
||||
tenantId: 'tenant-b',
|
||||
name: 'Secret User',
|
||||
email: 'secret@example.com',
|
||||
},
|
||||
]);
|
||||
await mongoose.models.Conversation.collection.insertMany([
|
||||
{
|
||||
conversationId: 'incident-review-123',
|
||||
tenantId: 'tenant-a',
|
||||
user: aliceId.toString(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
isTemporary: false,
|
||||
},
|
||||
{
|
||||
conversationId: 'capacity-plan-456',
|
||||
tenantId: 'tenant-a',
|
||||
user: bobId.toString(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
isTemporary: false,
|
||||
},
|
||||
{
|
||||
conversationId: 'other-tenant-789',
|
||||
tenantId: 'tenant-b',
|
||||
user: otherTenantUserId.toString(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
isTemporary: false,
|
||||
},
|
||||
]);
|
||||
await mongoose.models.Message.collection.insertMany([
|
||||
{
|
||||
messageId: 'message-a',
|
||||
conversationId: 'incident-review-123',
|
||||
tenantId: 'tenant-a',
|
||||
user: aliceId.toString(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
isCreatedByUser: true,
|
||||
isTemporary: false,
|
||||
text: 'Review the production incident',
|
||||
},
|
||||
{
|
||||
messageId: 'message-b',
|
||||
conversationId: 'capacity-plan-456',
|
||||
tenantId: 'tenant-a',
|
||||
user: bobId.toString(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
isCreatedByUser: true,
|
||||
isTemporary: false,
|
||||
text: 'Forecast the sapphire cluster capacity',
|
||||
},
|
||||
{
|
||||
messageId: 'message-secret',
|
||||
conversationId: 'other-tenant-789',
|
||||
tenantId: 'tenant-b',
|
||||
user: otherTenantUserId.toString(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
isCreatedByUser: true,
|
||||
isTemporary: false,
|
||||
text: 'Cross tenant secret phrase',
|
||||
},
|
||||
]);
|
||||
|
||||
const methods = createInsightsMethods(mongoose);
|
||||
const byConversationId = await methods.getInsights({
|
||||
tenantId: 'tenant-a',
|
||||
range: '7d',
|
||||
search: 'review-123',
|
||||
});
|
||||
const byUser = await methods.getInsights({
|
||||
tenantId: 'tenant-a',
|
||||
range: '7d',
|
||||
search: 'alice@example.com',
|
||||
});
|
||||
const byMessage = await methods.getInsights({
|
||||
tenantId: 'tenant-a',
|
||||
range: '7d',
|
||||
search: 'SAPPHIRE CLUSTER',
|
||||
});
|
||||
const crossTenant = await methods.getInsights({
|
||||
tenantId: 'tenant-a',
|
||||
range: '7d',
|
||||
search: 'secret phrase',
|
||||
});
|
||||
|
||||
expect(byConversationId.summary.totalConversations).toBe(2);
|
||||
expect(byConversationId.latest.conversations.map((row) => row.conversationId)).toEqual([
|
||||
'incident-review-123',
|
||||
]);
|
||||
expect(byUser.latest.conversations.map((row) => row.conversationId)).toEqual([
|
||||
'incident-review-123',
|
||||
]);
|
||||
expect(byMessage.latest.conversations).toEqual([]);
|
||||
expect(crossTenant.latest).toEqual(expect.objectContaining({ conversations: [], pages: 1 }));
|
||||
});
|
||||
|
||||
it('omits the unfiltered recent-conversation facet during searches', async () => {
|
||||
const aggregateSpy = jest.spyOn(mongoose.models.Conversation, 'aggregate');
|
||||
|
||||
try {
|
||||
await createInsightsMethods(mongoose).getInsights({
|
||||
tenantId: 'tenant-a',
|
||||
range: '7d',
|
||||
search: 'conversation-id',
|
||||
});
|
||||
|
||||
type FacetStage = { $facet?: Record<string, unknown> };
|
||||
const facets = aggregateSpy.mock.calls
|
||||
.flatMap(([pipeline]) => (pipeline as FacetStage[]).map((stage) => stage.$facet))
|
||||
.filter((facet): facet is Record<string, unknown> => facet != null);
|
||||
const unfilteredFacet = facets.find((facet) => 'daily' in facet);
|
||||
const searchedFacet = facets.find(
|
||||
(facet) => 'recentConversations' in facet && !('daily' in facet),
|
||||
);
|
||||
|
||||
expect(unfilteredFacet).not.toHaveProperty('recentConversations');
|
||||
expect(searchedFacet).toHaveProperty('recentConversations');
|
||||
} finally {
|
||||
aggregateSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps identity matches in a DocumentDB-compatible conversation aggregation', async () => {
|
||||
const aggregateSpy = jest.spyOn(mongoose.models.Conversation, 'aggregate');
|
||||
|
||||
try {
|
||||
await createInsightsMethods(mongoose).getInsights({
|
||||
tenantId: 'tenant-a',
|
||||
range: '7d',
|
||||
search: 'alice@example.com',
|
||||
});
|
||||
|
||||
type SearchStage = {
|
||||
$addFields?: Record<string, unknown>;
|
||||
$lookup?: Record<string, unknown>;
|
||||
};
|
||||
const searchedPipeline = aggregateSpy.mock.calls
|
||||
.map(([pipeline]) => pipeline as SearchStage[])
|
||||
.find((pipeline) => pipeline.some((stage) => stage.$lookup));
|
||||
const identityLookup = searchedPipeline?.find((stage) => stage.$lookup)?.$lookup;
|
||||
const ownerConversion = searchedPipeline?.find((stage) => stage.$addFields)?.$addFields;
|
||||
|
||||
expect(identityLookup).toEqual({
|
||||
from: 'users',
|
||||
localField: 'insightsUserId',
|
||||
foreignField: '_id',
|
||||
as: 'matchedIdentity',
|
||||
});
|
||||
expect(identityLookup).not.toHaveProperty('let');
|
||||
expect(identityLookup).not.toHaveProperty('pipeline');
|
||||
expect(ownerConversion).toEqual({
|
||||
insightsUserId: {
|
||||
$convert: { input: '$user', to: 'objectId', onError: null, onNull: null },
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
aggregateSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
699
packages/data-schemas/src/methods/insights.ts
Normal file
699
packages/data-schemas/src/methods/insights.ts
Normal file
|
|
@ -0,0 +1,699 @@
|
|||
import {
|
||||
INSIGHTS_MAX_RANGE_DAYS,
|
||||
INSIGHTS_SEARCH_MAX_LENGTH,
|
||||
INSIGHTS_SEARCH_MIN_LENGTH,
|
||||
} from 'librechat-data-provider';
|
||||
import type {
|
||||
TInsightsResponse,
|
||||
TInsightsParams,
|
||||
TInsightsDailyPoint,
|
||||
TInsightsUser,
|
||||
TInsightsChurnedUser,
|
||||
TInsightsConversation,
|
||||
} from 'librechat-data-provider';
|
||||
import type { Model } from 'mongoose';
|
||||
import type { IConversation, IMessage, IUser } from '~/types';
|
||||
|
||||
export type InsightsOptions = TInsightsParams & {
|
||||
tenantId?: string;
|
||||
};
|
||||
|
||||
export type InsightsResult = TInsightsResponse;
|
||||
|
||||
export type InsightsMethods = {
|
||||
getInsights: (options?: InsightsOptions) => Promise<InsightsResult>;
|
||||
};
|
||||
|
||||
type ConversationFacet = {
|
||||
conversationCount: Array<{ total: number }>;
|
||||
daily: Array<{ date: string; conversations: number }>;
|
||||
recentConversations?: RecentConversation[];
|
||||
};
|
||||
|
||||
type ConversationListFacet = Pick<ConversationFacet, 'conversationCount' | 'recentConversations'>;
|
||||
|
||||
type MessageFacet = {
|
||||
totals: MessageTotals[];
|
||||
daily: MessageDay[];
|
||||
userCount: Array<{ total: number }>;
|
||||
dailyUsers: Array<{ date: string; users: number }>;
|
||||
topUsers: MessageSummary[];
|
||||
};
|
||||
|
||||
type UserMessageTotals = {
|
||||
messages: number;
|
||||
};
|
||||
|
||||
type RecentConversation = {
|
||||
conversationId: string;
|
||||
date: Date;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
type MessageSummary = {
|
||||
_id: string;
|
||||
messages: number;
|
||||
};
|
||||
|
||||
type MessageTotals = {
|
||||
messages: number;
|
||||
totalTokens: number;
|
||||
};
|
||||
|
||||
type ChurnedUserActivity = {
|
||||
_id: string;
|
||||
lastSeen: Date;
|
||||
};
|
||||
|
||||
type ChurnedUserSummary = UserMessageTotals & {
|
||||
_id: string;
|
||||
conversations: number;
|
||||
firstSeen: Date;
|
||||
};
|
||||
|
||||
type ConversationOwner = {
|
||||
conversationId: string;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
type ConversationMessageSummary = UserMessageTotals & {
|
||||
_id: ConversationOwner;
|
||||
totalTokens: number;
|
||||
};
|
||||
|
||||
type UserConversationCount = {
|
||||
_id: string;
|
||||
conversations: number;
|
||||
};
|
||||
|
||||
type MessageDay = {
|
||||
date: string;
|
||||
messages: number;
|
||||
totalTokens: number;
|
||||
};
|
||||
|
||||
type FirstMessage = {
|
||||
_id: ConversationOwner;
|
||||
text: string;
|
||||
};
|
||||
|
||||
type UserSummary = {
|
||||
_id: { toString(): string };
|
||||
name?: string;
|
||||
username?: string;
|
||||
email?: string;
|
||||
};
|
||||
|
||||
const rangeDays = {
|
||||
'24h': 1,
|
||||
'7d': 7,
|
||||
'30d': 30,
|
||||
};
|
||||
|
||||
const defaultRange = '7d';
|
||||
const churnedUserWindowDays = 28;
|
||||
const churnedUserLimit = 8;
|
||||
const dayMs = 24 * 60 * 60 * 1000;
|
||||
function tenantMatch(tenantId?: string) {
|
||||
return tenantId ? { tenantId } : { tenantId: { $exists: false } };
|
||||
}
|
||||
|
||||
function validTimeZone(timeZone?: string) {
|
||||
if (!timeZone) {
|
||||
return 'UTC';
|
||||
}
|
||||
try {
|
||||
new Intl.DateTimeFormat('en', { timeZone }).format();
|
||||
return timeZone;
|
||||
} catch {
|
||||
return 'UTC';
|
||||
}
|
||||
}
|
||||
|
||||
function dateKey(date: Date, timeZone: string) {
|
||||
const parts = new Intl.DateTimeFormat('en', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
timeZone,
|
||||
year: 'numeric',
|
||||
}).formatToParts(date);
|
||||
const values = new Map(parts.map((part) => [part.type, part.value]));
|
||||
return `${values.get('year')}-${values.get('month')}-${values.get('day')}`;
|
||||
}
|
||||
|
||||
function dateKeyValue(key: string) {
|
||||
const [year, month, day] = key.split('-').map(Number);
|
||||
return Date.UTC(year, month - 1, day);
|
||||
}
|
||||
|
||||
function addCalendarDaysToKey(key: string, days: number) {
|
||||
const date = new Date(dateKeyValue(key));
|
||||
date.setUTCDate(date.getUTCDate() + days);
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function calendarDayDifference(startKey: string, endKey: string) {
|
||||
return Math.round((dateKeyValue(endKey) - dateKeyValue(startKey)) / dayMs);
|
||||
}
|
||||
|
||||
function startOfZonedDate(key: string, timeZone: string) {
|
||||
const target = dateKeyValue(key);
|
||||
let instant = target;
|
||||
const formatter = new Intl.DateTimeFormat('en', {
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
minute: '2-digit',
|
||||
month: '2-digit',
|
||||
second: '2-digit',
|
||||
timeZone,
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
for (let iteration = 0; iteration < 2; iteration += 1) {
|
||||
const parts = new Map(
|
||||
formatter.formatToParts(new Date(instant)).map((part) => [part.type, part.value]),
|
||||
);
|
||||
const rendered = Date.UTC(
|
||||
Number(parts.get('year')),
|
||||
Number(parts.get('month')) - 1,
|
||||
Number(parts.get('day')),
|
||||
Number(parts.get('hour')),
|
||||
Number(parts.get('minute')),
|
||||
Number(parts.get('second')),
|
||||
);
|
||||
instant += target - rendered;
|
||||
}
|
||||
return new Date(instant);
|
||||
}
|
||||
|
||||
const isNonEmptyString = (value: unknown): value is string =>
|
||||
typeof value === 'string' && value.trim() !== '';
|
||||
|
||||
const conversationOwnerKey = ({ conversationId, userId }: ConversationOwner): string =>
|
||||
JSON.stringify([conversationId, userId]);
|
||||
|
||||
const conversationOwnerMatch = ({ conversationId, userId }: ConversationOwner) => ({
|
||||
conversationId,
|
||||
user: userId,
|
||||
});
|
||||
|
||||
const escapeRegex = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
function resolveRange(options: InsightsOptions) {
|
||||
const now = new Date();
|
||||
const range = options.range ?? defaultRange;
|
||||
|
||||
if (range === 'custom' && options.fromTimestamp && options.toTimestamp) {
|
||||
const customFrom = new Date(options.fromTimestamp);
|
||||
const customTo = new Date(options.toTimestamp);
|
||||
if (Number.isFinite(customFrom.getTime()) && Number.isFinite(customTo.getTime())) {
|
||||
const [start, end] = customFrom <= customTo ? [customFrom, customTo] : [customTo, customFrom];
|
||||
const timeZone = validTimeZone(options.timeZone);
|
||||
const startKey = dateKey(start, timeZone);
|
||||
const endKey = dateKey(end, timeZone);
|
||||
if (calendarDayDifference(startKey, endKey) < INSIGHTS_MAX_RANGE_DAYS) {
|
||||
return { from: start, to: end };
|
||||
}
|
||||
const firstAllowedKey = addCalendarDaysToKey(endKey, -(INSIGHTS_MAX_RANGE_DAYS - 1));
|
||||
return { from: startOfZonedDate(firstAllowedKey, timeZone), to: end };
|
||||
}
|
||||
}
|
||||
|
||||
const days = rangeDays[range as keyof typeof rangeDays] ?? rangeDays[defaultRange];
|
||||
return { from: new Date(now.getTime() - days * dayMs), to: now };
|
||||
}
|
||||
|
||||
const addDay = (days: Map<string, TInsightsDailyPoint>, date: string): TInsightsDailyPoint => {
|
||||
const current = days.get(date) ?? {
|
||||
date,
|
||||
conversations: 0,
|
||||
users: 0,
|
||||
messages: 0,
|
||||
totalTokens: 0,
|
||||
};
|
||||
days.set(date, current);
|
||||
return current;
|
||||
};
|
||||
|
||||
const toUser = (
|
||||
userId: string,
|
||||
conversations: number,
|
||||
users: Map<string, UserSummary>,
|
||||
messageSummary: UserMessageTotals | undefined,
|
||||
): TInsightsUser => {
|
||||
const user = users.get(userId);
|
||||
return {
|
||||
userId,
|
||||
name: user?.name || user?.username || '',
|
||||
email: user?.email ?? '',
|
||||
conversations,
|
||||
messages: messageSummary?.messages ?? 0,
|
||||
};
|
||||
};
|
||||
|
||||
export function createInsightsMethods(mongoose: typeof import('mongoose')): InsightsMethods {
|
||||
async function getInsights(options: InsightsOptions = {}): Promise<InsightsResult> {
|
||||
const Conversation = mongoose.models.Conversation as Model<IConversation>;
|
||||
const Message = mongoose.models.Message as Model<IMessage>;
|
||||
const User = mongoose.models.User as Model<IUser>;
|
||||
const page = Math.max(1, Math.floor(options.page ?? 1));
|
||||
const pageSize = Math.min(50, Math.max(5, Math.floor(options.pageSize ?? 10)));
|
||||
const { from, to } = resolveRange(options);
|
||||
const timeZone = validTimeZone(options.timeZone);
|
||||
const churnedCutoff = new Date(to.getTime() - churnedUserWindowDays * dayMs);
|
||||
const churnedActivityFrom = new Date(from.getTime() - churnedUserWindowDays * dayMs);
|
||||
const tenant = tenantMatch(options.tenantId);
|
||||
const tenantConversations = {
|
||||
...tenant,
|
||||
isTemporary: { $ne: true },
|
||||
};
|
||||
const conversationMatch = {
|
||||
...tenantConversations,
|
||||
user: { $nin: [null, ''] },
|
||||
createdAt: { $gte: from, $lte: to },
|
||||
};
|
||||
const messageMatch = {
|
||||
...tenant,
|
||||
user: { $nin: [null, ''] },
|
||||
isTemporary: { $ne: true },
|
||||
createdAt: { $gte: from, $lte: to },
|
||||
};
|
||||
const conversationScope = [{ $match: conversationMatch }];
|
||||
const messageScope = [{ $match: messageMatch }];
|
||||
|
||||
const requestedSearch = options.search?.trim().slice(0, INSIGHTS_SEARCH_MAX_LENGTH) ?? '';
|
||||
const search = requestedSearch.length >= INSIGHTS_SEARCH_MIN_LENGTH ? requestedSearch : '';
|
||||
const searchRegex = search ? new RegExp(escapeRegex(search), 'i') : undefined;
|
||||
const searchedConversationAggregation = searchRegex
|
||||
? Conversation.aggregate<ConversationListFacet>([
|
||||
{ $match: conversationMatch },
|
||||
{
|
||||
$addFields: {
|
||||
insightsUserId: {
|
||||
$convert: { input: '$user', to: 'objectId', onError: null, onNull: null },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
$lookup: {
|
||||
from: 'users',
|
||||
localField: 'insightsUserId',
|
||||
foreignField: '_id',
|
||||
as: 'matchedIdentity',
|
||||
},
|
||||
},
|
||||
{
|
||||
$match: {
|
||||
$or: [
|
||||
{ conversationId: searchRegex },
|
||||
{ user: searchRegex },
|
||||
{
|
||||
matchedIdentity: {
|
||||
$elemMatch: {
|
||||
...(options.tenantId
|
||||
? { tenantId: options.tenantId }
|
||||
: { tenantId: { $exists: false } }),
|
||||
$or: [
|
||||
{ name: searchRegex },
|
||||
{ username: searchRegex },
|
||||
{ email: searchRegex },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
$facet: {
|
||||
conversationCount: [{ $count: 'total' }],
|
||||
recentConversations: [
|
||||
{ $sort: { createdAt: -1, _id: -1 } },
|
||||
{ $skip: (page - 1) * pageSize },
|
||||
{ $limit: pageSize },
|
||||
{
|
||||
$project: {
|
||||
_id: 0,
|
||||
conversationId: 1,
|
||||
date: '$createdAt',
|
||||
userId: '$user',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
])
|
||||
: Promise.resolve([] as ConversationListFacet[]);
|
||||
|
||||
const insightsAggregations = await Promise.all([
|
||||
Conversation.aggregate<ConversationFacet>([
|
||||
...conversationScope,
|
||||
{
|
||||
$facet: {
|
||||
conversationCount: [{ $count: 'total' }],
|
||||
daily: [
|
||||
{
|
||||
$group: {
|
||||
_id: {
|
||||
$dateToString: {
|
||||
format: '%Y-%m-%d',
|
||||
date: '$createdAt',
|
||||
timezone: timeZone,
|
||||
},
|
||||
},
|
||||
conversations: { $sum: 1 },
|
||||
},
|
||||
},
|
||||
{ $project: { _id: 0, date: '$_id', conversations: 1 } },
|
||||
{ $sort: { date: 1 } },
|
||||
],
|
||||
...(searchRegex
|
||||
? {}
|
||||
: {
|
||||
recentConversations: [
|
||||
{ $sort: { createdAt: -1, _id: -1 } },
|
||||
{ $skip: (page - 1) * pageSize },
|
||||
{ $limit: pageSize },
|
||||
{
|
||||
$project: {
|
||||
_id: 0,
|
||||
conversationId: 1,
|
||||
date: '$createdAt',
|
||||
userId: '$user',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
},
|
||||
]),
|
||||
Message.aggregate<MessageFacet>([
|
||||
...messageScope,
|
||||
{
|
||||
$facet: {
|
||||
totals: [
|
||||
{
|
||||
$group: {
|
||||
_id: 'all',
|
||||
messages: { $sum: 1 },
|
||||
totalTokens: { $sum: { $ifNull: ['$tokenCount', 0] } },
|
||||
},
|
||||
},
|
||||
],
|
||||
daily: [
|
||||
{
|
||||
$group: {
|
||||
_id: {
|
||||
$dateToString: {
|
||||
format: '%Y-%m-%d',
|
||||
date: '$createdAt',
|
||||
timezone: timeZone,
|
||||
},
|
||||
},
|
||||
messages: { $sum: 1 },
|
||||
totalTokens: { $sum: { $ifNull: ['$tokenCount', 0] } },
|
||||
},
|
||||
},
|
||||
{ $project: { _id: 0, date: '$_id', messages: 1, totalTokens: 1 } },
|
||||
{ $sort: { date: 1 } },
|
||||
],
|
||||
userCount: [
|
||||
{ $match: { user: { $nin: [null, ''] }, isCreatedByUser: true } },
|
||||
{ $group: { _id: '$user' } },
|
||||
{ $count: 'total' },
|
||||
],
|
||||
dailyUsers: [
|
||||
{ $match: { user: { $nin: [null, ''] }, isCreatedByUser: true } },
|
||||
{
|
||||
$group: {
|
||||
_id: {
|
||||
date: {
|
||||
$dateToString: {
|
||||
format: '%Y-%m-%d',
|
||||
date: '$createdAt',
|
||||
timezone: timeZone,
|
||||
},
|
||||
},
|
||||
user: '$user',
|
||||
},
|
||||
},
|
||||
},
|
||||
{ $group: { _id: '$_id.date', users: { $sum: 1 } } },
|
||||
{ $project: { _id: 0, date: '$_id', users: 1 } },
|
||||
{ $sort: { date: 1 } },
|
||||
],
|
||||
topUsers: [
|
||||
{ $match: { user: { $nin: [null, ''] } } },
|
||||
{
|
||||
$group: {
|
||||
_id: '$user',
|
||||
messages: { $sum: 1 },
|
||||
},
|
||||
},
|
||||
{ $sort: { messages: -1, _id: 1 } },
|
||||
{ $limit: 8 },
|
||||
],
|
||||
},
|
||||
},
|
||||
]),
|
||||
Message.aggregate<ChurnedUserActivity>([
|
||||
{
|
||||
$match: {
|
||||
...tenant,
|
||||
isTemporary: { $ne: true },
|
||||
user: { $nin: [null, ''] },
|
||||
isCreatedByUser: true,
|
||||
createdAt: { $gte: churnedActivityFrom, $lte: to },
|
||||
},
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: '$user',
|
||||
lastSeen: { $max: '$createdAt' },
|
||||
},
|
||||
},
|
||||
{ $match: { lastSeen: { $gte: churnedActivityFrom, $lte: churnedCutoff } } },
|
||||
{ $sort: { lastSeen: -1, _id: 1 } },
|
||||
{ $limit: churnedUserLimit },
|
||||
]),
|
||||
searchedConversationAggregation,
|
||||
]);
|
||||
const [conversationFacets, messageFacets, churnedUserRows, searchedConversationFacets] =
|
||||
insightsAggregations;
|
||||
|
||||
const conversationFacet = conversationFacets[0];
|
||||
const conversationListFacet = searchRegex ? searchedConversationFacets[0] : conversationFacet;
|
||||
const messageFacet = messageFacets[0];
|
||||
const messageTotals = messageFacet?.totals ?? [];
|
||||
const topMessageUsers = messageFacet?.topUsers ?? [];
|
||||
const latestRows = conversationListFacet?.recentConversations ?? [];
|
||||
const latestConversationOwners = latestRows
|
||||
.filter(
|
||||
(row): row is RecentConversation & { userId: string } =>
|
||||
isNonEmptyString(row.conversationId) && isNonEmptyString(row.userId),
|
||||
)
|
||||
.map(({ conversationId, userId }) => ({ conversationId, userId }));
|
||||
const latestConversationIds = [
|
||||
...new Set(latestConversationOwners.map(({ conversationId }) => conversationId)),
|
||||
];
|
||||
const latestConversationMatches = latestConversationOwners.map(conversationOwnerMatch);
|
||||
const topUserIds = topMessageUsers.map(({ _id }) => _id);
|
||||
const churnedUserIds = churnedUserRows.map(({ _id }) => _id);
|
||||
const userIds = new Set<string>([
|
||||
...topUserIds,
|
||||
...churnedUserIds,
|
||||
...latestRows.map(({ userId }) => userId).filter(isNonEmptyString),
|
||||
]);
|
||||
|
||||
const [
|
||||
latestMessageSummaries,
|
||||
firstMessages,
|
||||
topConversationCounts,
|
||||
churnedUserSummaries,
|
||||
userRows,
|
||||
] = await Promise.all([
|
||||
latestConversationMatches.length === 0
|
||||
? Promise.resolve([] as ConversationMessageSummary[])
|
||||
: Message.aggregate<ConversationMessageSummary>([
|
||||
{
|
||||
$match: {
|
||||
...tenant,
|
||||
isTemporary: { $ne: true },
|
||||
conversationId: { $in: latestConversationIds },
|
||||
$or: latestConversationMatches,
|
||||
createdAt: { $gte: from, $lte: to },
|
||||
},
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: { conversationId: '$conversationId', userId: '$user' },
|
||||
messages: { $sum: 1 },
|
||||
totalTokens: { $sum: { $ifNull: ['$tokenCount', 0] } },
|
||||
},
|
||||
},
|
||||
]),
|
||||
latestConversationMatches.length === 0
|
||||
? Promise.resolve([] as FirstMessage[])
|
||||
: Message.aggregate<FirstMessage>([
|
||||
{
|
||||
$match: {
|
||||
...tenant,
|
||||
isTemporary: { $ne: true },
|
||||
conversationId: { $in: latestConversationIds },
|
||||
$or: latestConversationMatches,
|
||||
isCreatedByUser: true,
|
||||
},
|
||||
},
|
||||
{ $sort: { createdAt: 1, _id: 1 } },
|
||||
{
|
||||
$group: {
|
||||
_id: { conversationId: '$conversationId', userId: '$user' },
|
||||
text: { $first: { $ifNull: ['$text', ''] } },
|
||||
},
|
||||
},
|
||||
]),
|
||||
topUserIds.length === 0
|
||||
? Promise.resolve([] as UserConversationCount[])
|
||||
: Message.aggregate<UserConversationCount>([
|
||||
{
|
||||
$match: {
|
||||
...messageMatch,
|
||||
user: { $in: topUserIds },
|
||||
isCreatedByUser: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: { user: '$user', conversationId: '$conversationId' },
|
||||
},
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: '$_id.user',
|
||||
conversations: { $sum: 1 },
|
||||
},
|
||||
},
|
||||
]),
|
||||
churnedUserIds.length === 0
|
||||
? Promise.resolve([] as ChurnedUserSummary[])
|
||||
: Message.aggregate<ChurnedUserSummary>([
|
||||
{
|
||||
$match: {
|
||||
...tenant,
|
||||
isTemporary: { $ne: true },
|
||||
user: { $in: churnedUserIds },
|
||||
createdAt: { $lte: to },
|
||||
},
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: { user: '$user', conversationId: '$conversationId' },
|
||||
messages: { $sum: 1 },
|
||||
firstSeen: { $min: '$createdAt' },
|
||||
},
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: '$_id.user',
|
||||
conversations: { $sum: 1 },
|
||||
messages: { $sum: '$messages' },
|
||||
firstSeen: { $min: '$firstSeen' },
|
||||
},
|
||||
},
|
||||
]),
|
||||
userIds.size === 0
|
||||
? Promise.resolve([] as UserSummary[])
|
||||
: User.find({ ...tenant, _id: { $in: [...userIds] } })
|
||||
.select('_id name username email')
|
||||
.lean<UserSummary[]>(),
|
||||
]);
|
||||
|
||||
const users = new Map(userRows.map((user) => [user._id.toString(), user]));
|
||||
const latestSummaries = new Map(
|
||||
latestMessageSummaries.map((summary) => [conversationOwnerKey(summary._id), summary]),
|
||||
);
|
||||
const firstByConversation = new Map(
|
||||
firstMessages.map((message) => [conversationOwnerKey(message._id), message.text]),
|
||||
);
|
||||
const days = new Map<string, TInsightsDailyPoint>();
|
||||
const firstDay = dateKey(from, timeZone);
|
||||
const lastDay = dateKey(to, timeZone);
|
||||
for (let key = firstDay; key <= lastDay; key = addCalendarDaysToKey(key, 1)) {
|
||||
addDay(days, key);
|
||||
}
|
||||
for (const row of conversationFacet?.daily ?? []) {
|
||||
const day = addDay(days, row.date);
|
||||
day.conversations = row.conversations;
|
||||
}
|
||||
for (const row of messageFacet?.daily ?? []) {
|
||||
const day = addDay(days, row.date);
|
||||
day.messages = row.messages;
|
||||
day.totalTokens = row.totalTokens;
|
||||
}
|
||||
for (const row of messageFacet?.dailyUsers ?? []) {
|
||||
const day = addDay(days, row.date);
|
||||
day.users = row.users;
|
||||
}
|
||||
|
||||
const totalConversations = conversationFacet?.conversationCount[0]?.total ?? 0;
|
||||
const matchingConversations = conversationListFacet?.conversationCount[0]?.total ?? 0;
|
||||
const totalMessages = messageTotals[0]?.messages ?? 0;
|
||||
const totalTokens = messageTotals[0]?.totalTokens ?? 0;
|
||||
const topConversationCountsByUser = new Map(
|
||||
topConversationCounts.map((summary) => [summary._id, summary.conversations]),
|
||||
);
|
||||
const churnedSummariesByUser = new Map(
|
||||
churnedUserSummaries.map((summary) => [summary._id, summary]),
|
||||
);
|
||||
|
||||
return {
|
||||
summary: {
|
||||
totalUsers: messageFacet?.userCount[0]?.total ?? 0,
|
||||
totalConversations,
|
||||
totalMessages,
|
||||
totalTokens,
|
||||
},
|
||||
daily: [...days.values()].sort((left, right) => left.date.localeCompare(right.date)),
|
||||
topUsers: topMessageUsers.map((row) =>
|
||||
toUser(row._id, topConversationCountsByUser.get(row._id) ?? 0, users, row),
|
||||
),
|
||||
churnedUsers: churnedUserRows.map((row): TInsightsChurnedUser => {
|
||||
const user = users.get(row._id);
|
||||
const summary = churnedSummariesByUser.get(row._id);
|
||||
return {
|
||||
userId: row._id,
|
||||
name: user?.name || user?.username || '',
|
||||
email: user?.email ?? '',
|
||||
conversations: summary?.conversations ?? 0,
|
||||
messages: summary?.messages ?? 0,
|
||||
firstSeen: summary?.firstSeen.toISOString() ?? row.lastSeen.toISOString(),
|
||||
lastSeen: row.lastSeen.toISOString(),
|
||||
};
|
||||
}),
|
||||
latest: {
|
||||
conversations: latestRows.map((row): TInsightsConversation => {
|
||||
const userId = isNonEmptyString(row.userId) ? row.userId : '';
|
||||
const user = userId ? users.get(userId) : undefined;
|
||||
const ownerKey = conversationOwnerKey({ conversationId: row.conversationId, userId });
|
||||
const summary = latestSummaries.get(ownerKey);
|
||||
return {
|
||||
...row,
|
||||
userId,
|
||||
date: row.date.toISOString(),
|
||||
name: user?.name || user?.username || '',
|
||||
email: user?.email ?? '',
|
||||
firstMessage: firstByConversation.get(ownerKey) ?? '',
|
||||
messages: summary?.messages ?? 0,
|
||||
totalTokens: summary?.totalTokens ?? 0,
|
||||
};
|
||||
}),
|
||||
page,
|
||||
pageSize,
|
||||
pages: Math.max(1, Math.ceil(matchingConversations / pageSize)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { getInsights };
|
||||
}
|
||||
|
|
@ -91,6 +91,7 @@ const convoSchema: Schema<IConversation> = new Schema(
|
|||
convoSchema.index({ expiredAt: 1 }, { expireAfterSeconds: 0 });
|
||||
convoSchema.index({ createdAt: 1, updatedAt: 1 });
|
||||
convoSchema.index({ conversationId: 1, user: 1, tenantId: 1 }, { unique: true });
|
||||
convoSchema.index({ tenantId: 1, isTemporary: 1, createdAt: -1, _id: -1 });
|
||||
convoSchema.index({ user: 1, _id: 1 });
|
||||
convoSchema.index({ user: 1, chatProjectId: 1, updatedAt: -1, _id: -1 });
|
||||
convoSchema.index({ user: 1, chatProjectId: 1, createdAt: -1, _id: -1 });
|
||||
|
|
|
|||
40
packages/data-schemas/src/schema/insights.spec.ts
Normal file
40
packages/data-schemas/src/schema/insights.spec.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import type { IndexDefinition, IndexOptions } from 'mongoose';
|
||||
import conversationSchema from './convo';
|
||||
import messageSchema from './message';
|
||||
|
||||
type SchemaIndexes = Array<[IndexDefinition, IndexOptions]>;
|
||||
|
||||
function hasIndex(indexes: SchemaIndexes, fields: IndexDefinition): boolean {
|
||||
return indexes.some(([candidate]) => JSON.stringify(candidate) === JSON.stringify(fields));
|
||||
}
|
||||
|
||||
describe('Insights indexes', () => {
|
||||
it('registers the dashboard timeline and user activity indexes', () => {
|
||||
expect(
|
||||
hasIndex(conversationSchema.indexes(), {
|
||||
tenantId: 1,
|
||||
isTemporary: 1,
|
||||
createdAt: -1,
|
||||
_id: -1,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
hasIndex(messageSchema.indexes(), {
|
||||
tenantId: 1,
|
||||
isTemporary: 1,
|
||||
createdAt: -1,
|
||||
_id: -1,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
hasIndex(messageSchema.indexes(), {
|
||||
tenantId: 1,
|
||||
isTemporary: 1,
|
||||
isCreatedByUser: 1,
|
||||
user: 1,
|
||||
createdAt: -1,
|
||||
_id: -1,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -223,6 +223,15 @@ const messageSchema: Schema<IMessage> = new Schema(
|
|||
messageSchema.index({ expiredAt: 1 }, { expireAfterSeconds: 0 });
|
||||
messageSchema.index({ createdAt: 1 });
|
||||
messageSchema.index({ messageId: 1, user: 1, tenantId: 1 }, { unique: true });
|
||||
messageSchema.index({ tenantId: 1, isTemporary: 1, createdAt: -1, _id: -1 });
|
||||
messageSchema.index({
|
||||
tenantId: 1,
|
||||
isTemporary: 1,
|
||||
isCreatedByUser: 1,
|
||||
user: 1,
|
||||
createdAt: -1,
|
||||
_id: -1,
|
||||
});
|
||||
|
||||
/**
|
||||
* Serves the conversation fetch ({conversationId, user} filter + createdAt
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue