feat(ai): add ai chat to dashboard
This commit is contained in:
@@ -11,6 +11,8 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ai-sdk/anthropic": "^1.2.10",
|
||||||
|
"@ai-sdk/openai": "^1.3.12",
|
||||||
"@fastify/compress": "^8.0.1",
|
"@fastify/compress": "^8.0.1",
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@fastify/cors": "^11.0.0",
|
"@fastify/cors": "^11.0.0",
|
||||||
@@ -19,6 +21,7 @@
|
|||||||
"@node-rs/argon2": "^2.0.2",
|
"@node-rs/argon2": "^2.0.2",
|
||||||
"@openpanel/auth": "workspace:^",
|
"@openpanel/auth": "workspace:^",
|
||||||
"@openpanel/common": "workspace:*",
|
"@openpanel/common": "workspace:*",
|
||||||
|
"@openpanel/constants": "workspace:*",
|
||||||
"@openpanel/db": "workspace:*",
|
"@openpanel/db": "workspace:*",
|
||||||
"@openpanel/integrations": "workspace:^",
|
"@openpanel/integrations": "workspace:^",
|
||||||
"@openpanel/json": "workspace:*",
|
"@openpanel/json": "workspace:*",
|
||||||
@@ -29,6 +32,7 @@
|
|||||||
"@openpanel/trpc": "workspace:*",
|
"@openpanel/trpc": "workspace:*",
|
||||||
"@openpanel/validation": "workspace:*",
|
"@openpanel/validation": "workspace:*",
|
||||||
"@trpc/server": "^10.45.2",
|
"@trpc/server": "^10.45.2",
|
||||||
|
"ai": "^4.2.10",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"fast-json-stable-hash": "^1.0.3",
|
"fast-json-stable-hash": "^1.0.3",
|
||||||
"fastify": "^5.2.1",
|
"fastify": "^5.2.1",
|
||||||
@@ -45,7 +49,7 @@
|
|||||||
"svix": "^1.24.0",
|
"svix": "^1.24.0",
|
||||||
"url-metadata": "^4.1.1",
|
"url-metadata": "^4.1.1",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
"zod": "^3.22.4"
|
"zod": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@faker-js/faker": "^9.0.1",
|
"@faker-js/faker": "^9.0.1",
|
||||||
|
|||||||
134
apps/api/src/controllers/ai.controller.ts
Normal file
134
apps/api/src/controllers/ai.controller.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import { getChatModel, getChatSystemPrompt } from '@/utils/ai';
|
||||||
|
import {
|
||||||
|
getAllEventNames,
|
||||||
|
getConversionReport,
|
||||||
|
getFunnelReport,
|
||||||
|
getProfile,
|
||||||
|
getProfiles,
|
||||||
|
getReport,
|
||||||
|
} from '@/utils/ai-tools';
|
||||||
|
import { HttpError } from '@/utils/errors';
|
||||||
|
import { db, getOrganizationByProjectIdCached } from '@openpanel/db';
|
||||||
|
import { getProjectAccessCached } from '@openpanel/trpc/src/access';
|
||||||
|
import { type Message, appendResponseMessages, streamText } from 'ai';
|
||||||
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
|
||||||
|
export async function chat(
|
||||||
|
request: FastifyRequest<{
|
||||||
|
Querystring: {
|
||||||
|
projectId: string;
|
||||||
|
};
|
||||||
|
Body: {
|
||||||
|
messages: Message[];
|
||||||
|
};
|
||||||
|
}>,
|
||||||
|
reply: FastifyReply,
|
||||||
|
) {
|
||||||
|
const { session } = request.session;
|
||||||
|
const { messages } = request.body;
|
||||||
|
const { projectId } = request.query;
|
||||||
|
|
||||||
|
if (!session?.userId) {
|
||||||
|
return reply.status(401).send('Unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!projectId) {
|
||||||
|
return reply.status(400).send('Missing projectId');
|
||||||
|
}
|
||||||
|
|
||||||
|
const organization = await getOrganizationByProjectIdCached(projectId);
|
||||||
|
const access = await getProjectAccessCached({
|
||||||
|
projectId,
|
||||||
|
userId: session.userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!organization) {
|
||||||
|
throw new HttpError('Organization not found', {
|
||||||
|
status: 404,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!access) {
|
||||||
|
throw new HttpError('You are not allowed to access this project', {
|
||||||
|
status: 403,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (organization?.isExceeded) {
|
||||||
|
throw new HttpError('Organization has exceeded its limits', {
|
||||||
|
status: 403,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (organization?.isCanceled) {
|
||||||
|
throw new HttpError('Organization has been canceled', {
|
||||||
|
status: 403,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const systemPrompt = getChatSystemPrompt({
|
||||||
|
projectId,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = streamText({
|
||||||
|
model: getChatModel(),
|
||||||
|
messages: messages.slice(-4),
|
||||||
|
maxSteps: 2,
|
||||||
|
tools: {
|
||||||
|
getAllEventNames: getAllEventNames({
|
||||||
|
projectId,
|
||||||
|
}),
|
||||||
|
getReport: getReport({
|
||||||
|
projectId,
|
||||||
|
}),
|
||||||
|
getConversionReport: getConversionReport({
|
||||||
|
projectId,
|
||||||
|
}),
|
||||||
|
getFunnelReport: getFunnelReport({
|
||||||
|
projectId,
|
||||||
|
}),
|
||||||
|
getProfiles: getProfiles({
|
||||||
|
projectId,
|
||||||
|
}),
|
||||||
|
getProfile: getProfile({
|
||||||
|
projectId,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
toolCallStreaming: false,
|
||||||
|
system: systemPrompt,
|
||||||
|
onFinish: async ({ response, usage }) => {
|
||||||
|
request.log.info('chat usage', { usage });
|
||||||
|
const messagesToSave = appendResponseMessages({
|
||||||
|
messages,
|
||||||
|
responseMessages: response.messages,
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.chat.deleteMany({
|
||||||
|
where: {
|
||||||
|
projectId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.chat.create({
|
||||||
|
data: {
|
||||||
|
messages: messagesToSave.slice(-10),
|
||||||
|
projectId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: async (error) => {
|
||||||
|
request.log.error('chat error', { error });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
reply.header('X-Vercel-AI-Data-Stream', 'v1');
|
||||||
|
reply.header('Content-Type', 'text/plain; charset=utf-8');
|
||||||
|
|
||||||
|
return reply.send(result.toDataStream());
|
||||||
|
} catch (error) {
|
||||||
|
throw new HttpError('Error during stream processing', {
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ import { ipHook } from './hooks/ip.hook';
|
|||||||
import { requestIdHook } from './hooks/request-id.hook';
|
import { requestIdHook } from './hooks/request-id.hook';
|
||||||
import { requestLoggingHook } from './hooks/request-logging.hook';
|
import { requestLoggingHook } from './hooks/request-logging.hook';
|
||||||
import { timestampHook } from './hooks/timestamp.hook';
|
import { timestampHook } from './hooks/timestamp.hook';
|
||||||
|
import aiRouter from './routes/ai.router';
|
||||||
import eventRouter from './routes/event.router';
|
import eventRouter from './routes/event.router';
|
||||||
import exportRouter from './routes/export.router';
|
import exportRouter from './routes/export.router';
|
||||||
import importRouter from './routes/import.router';
|
import importRouter from './routes/import.router';
|
||||||
@@ -37,6 +38,7 @@ import oauthRouter from './routes/oauth-callback.router';
|
|||||||
import profileRouter from './routes/profile.router';
|
import profileRouter from './routes/profile.router';
|
||||||
import trackRouter from './routes/track.router';
|
import trackRouter from './routes/track.router';
|
||||||
import webhookRouter from './routes/webhook.router';
|
import webhookRouter from './routes/webhook.router';
|
||||||
|
import { HttpError } from './utils/errors';
|
||||||
import { logger } from './utils/logger';
|
import { logger } from './utils/logger';
|
||||||
|
|
||||||
sourceMapSupport.install();
|
sourceMapSupport.install();
|
||||||
@@ -74,7 +76,14 @@ const startServer = async () => {
|
|||||||
callback: (error: Error | null, options: FastifyCorsOptions) => void,
|
callback: (error: Error | null, options: FastifyCorsOptions) => void,
|
||||||
) => {
|
) => {
|
||||||
// TODO: set prefix on dashboard routes
|
// TODO: set prefix on dashboard routes
|
||||||
const corsPaths = ['/trpc', '/live', '/webhook', '/oauth', '/misc'];
|
const corsPaths = [
|
||||||
|
'/trpc',
|
||||||
|
'/live',
|
||||||
|
'/webhook',
|
||||||
|
'/oauth',
|
||||||
|
'/misc',
|
||||||
|
'/ai',
|
||||||
|
];
|
||||||
|
|
||||||
const isPrivatePath = corsPaths.some((path) =>
|
const isPrivatePath = corsPaths.some((path) =>
|
||||||
req.url.startsWith(path),
|
req.url.startsWith(path),
|
||||||
@@ -150,6 +159,7 @@ const startServer = async () => {
|
|||||||
instance.register(webhookRouter, { prefix: '/webhook' });
|
instance.register(webhookRouter, { prefix: '/webhook' });
|
||||||
instance.register(oauthRouter, { prefix: '/oauth' });
|
instance.register(oauthRouter, { prefix: '/oauth' });
|
||||||
instance.register(miscRouter, { prefix: '/misc' });
|
instance.register(miscRouter, { prefix: '/misc' });
|
||||||
|
instance.register(aiRouter, { prefix: '/ai' });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Public API
|
// Public API
|
||||||
@@ -168,7 +178,19 @@ const startServer = async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
fastify.setErrorHandler((error, request, reply) => {
|
fastify.setErrorHandler((error, request, reply) => {
|
||||||
if (error.statusCode === 429) {
|
if (error instanceof HttpError) {
|
||||||
|
request.log.error(`${error.message}`, error);
|
||||||
|
if (process.env.NODE_ENV === 'production' && error.status === 500) {
|
||||||
|
request.log.error('request error', { error });
|
||||||
|
reply.status(500).send('Internal server error');
|
||||||
|
} else {
|
||||||
|
reply.status(error.status).send({
|
||||||
|
status: error.status,
|
||||||
|
error: error.error,
|
||||||
|
message: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (error.statusCode === 429) {
|
||||||
reply.status(429).send({
|
reply.status(429).send({
|
||||||
status: 429,
|
status: 429,
|
||||||
error: 'Too Many Requests',
|
error: 'Too Many Requests',
|
||||||
|
|||||||
28
apps/api/src/routes/ai.router.ts
Normal file
28
apps/api/src/routes/ai.router.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import * as controller from '@/controllers/ai.controller';
|
||||||
|
import { activateRateLimiter } from '@/utils/rate-limiter';
|
||||||
|
import type { FastifyPluginCallback, FastifyRequest } from 'fastify';
|
||||||
|
|
||||||
|
const aiRouter: FastifyPluginCallback = async (fastify) => {
|
||||||
|
await activateRateLimiter<
|
||||||
|
FastifyRequest<{
|
||||||
|
Querystring: {
|
||||||
|
projectId: string;
|
||||||
|
};
|
||||||
|
}>
|
||||||
|
>({
|
||||||
|
fastify,
|
||||||
|
max: process.env.NODE_ENV === 'production' ? 20 : 100,
|
||||||
|
timeWindow: '300 seconds',
|
||||||
|
keyGenerator: (req) => {
|
||||||
|
return req.query.projectId;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.route({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/chat',
|
||||||
|
handler: controller.chat,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default aiRouter;
|
||||||
475
apps/api/src/utils/ai-tools.ts
Normal file
475
apps/api/src/utils/ai-tools.ts
Normal file
@@ -0,0 +1,475 @@
|
|||||||
|
import { chartTypes } from '@openpanel/constants';
|
||||||
|
import type { IClickhouseSession } from '@openpanel/db';
|
||||||
|
import {
|
||||||
|
type IClickhouseEvent,
|
||||||
|
type IClickhouseProfile,
|
||||||
|
TABLE_NAMES,
|
||||||
|
ch,
|
||||||
|
clix,
|
||||||
|
} from '@openpanel/db';
|
||||||
|
import { getCache } from '@openpanel/redis';
|
||||||
|
import { getChart } from '@openpanel/trpc/src/routers/chart.helpers';
|
||||||
|
import { zChartInputAI } from '@openpanel/validation';
|
||||||
|
import { tool } from 'ai';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export function getReport({
|
||||||
|
projectId,
|
||||||
|
}: {
|
||||||
|
projectId: string;
|
||||||
|
}) {
|
||||||
|
return tool({
|
||||||
|
description: `Generate a report (a chart) for
|
||||||
|
- ${chartTypes.area}
|
||||||
|
- ${chartTypes.linear}
|
||||||
|
- ${chartTypes.pie}
|
||||||
|
- ${chartTypes.histogram}
|
||||||
|
- ${chartTypes.metric}
|
||||||
|
- ${chartTypes.bar}
|
||||||
|
`,
|
||||||
|
parameters: zChartInputAI,
|
||||||
|
execute: async (report) => {
|
||||||
|
return {
|
||||||
|
type: 'report',
|
||||||
|
report: {
|
||||||
|
...report,
|
||||||
|
projectId,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// try {
|
||||||
|
// const data = await getChart({
|
||||||
|
// ...report,
|
||||||
|
// projectId,
|
||||||
|
// });
|
||||||
|
|
||||||
|
// return {
|
||||||
|
// type: 'report',
|
||||||
|
// data: `Avg: ${data.metrics.average}, Min: ${data.metrics.min}, Max: ${data.metrics.max}, Sum: ${data.metrics.sum}
|
||||||
|
// X-Axis: ${data.series[0]?.data.map((i) => i.date).join(',')}
|
||||||
|
// Series:
|
||||||
|
// ${data.series
|
||||||
|
// .slice(0, 5)
|
||||||
|
// .map((item) => {
|
||||||
|
// return `- ${item.names.join(' ')} | Sum: ${item.metrics.sum} | Avg: ${item.metrics.average} | Min: ${item.metrics.min} | Max: ${item.metrics.max} | Data: ${item.data.map((i) => i.count).join(',')}`;
|
||||||
|
// })
|
||||||
|
// .join('\n')}
|
||||||
|
// `,
|
||||||
|
// report,
|
||||||
|
// };
|
||||||
|
// } catch (error) {
|
||||||
|
// return {
|
||||||
|
// error: 'Failed to generate report',
|
||||||
|
// };
|
||||||
|
// }
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export function getConversionReport({
|
||||||
|
projectId,
|
||||||
|
}: {
|
||||||
|
projectId: string;
|
||||||
|
}) {
|
||||||
|
return tool({
|
||||||
|
description:
|
||||||
|
'Generate a report (a chart) for conversions between two actions a unique user took.',
|
||||||
|
parameters: zChartInputAI,
|
||||||
|
execute: async (report) => {
|
||||||
|
return {
|
||||||
|
type: 'report',
|
||||||
|
// data: await conversionService.getConversion(report),
|
||||||
|
report: {
|
||||||
|
...report,
|
||||||
|
projectId,
|
||||||
|
chartType: 'conversion',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export function getFunnelReport({
|
||||||
|
projectId,
|
||||||
|
}: {
|
||||||
|
projectId: string;
|
||||||
|
}) {
|
||||||
|
return tool({
|
||||||
|
description:
|
||||||
|
'Generate a report (a chart) for funnel between two or more actions a unique user (session_id or profile_id) took.',
|
||||||
|
parameters: zChartInputAI,
|
||||||
|
execute: async (report) => {
|
||||||
|
return {
|
||||||
|
type: 'report',
|
||||||
|
// data: await funnelService.getFunnel(report),
|
||||||
|
report: {
|
||||||
|
...report,
|
||||||
|
projectId,
|
||||||
|
chartType: 'funnel',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProfiles({
|
||||||
|
projectId,
|
||||||
|
}: {
|
||||||
|
projectId: string;
|
||||||
|
}) {
|
||||||
|
return tool({
|
||||||
|
description: 'Get profiles',
|
||||||
|
parameters: z.object({
|
||||||
|
projectId: z.string(),
|
||||||
|
limit: z.number().optional(),
|
||||||
|
email: z.string().optional(),
|
||||||
|
firstName: z.string().optional(),
|
||||||
|
lastName: z.string().optional(),
|
||||||
|
country: z.string().describe('ISO 3166-1 alpha-2').optional(),
|
||||||
|
city: z.string().optional(),
|
||||||
|
region: z.string().optional(),
|
||||||
|
device: z.string().optional(),
|
||||||
|
browser: z.string().optional(),
|
||||||
|
}),
|
||||||
|
execute: async (input) => {
|
||||||
|
const builder = clix(ch)
|
||||||
|
.select<IClickhouseProfile>([
|
||||||
|
'id',
|
||||||
|
'email',
|
||||||
|
'first_name',
|
||||||
|
'last_name',
|
||||||
|
'properties',
|
||||||
|
])
|
||||||
|
.from(TABLE_NAMES.profiles)
|
||||||
|
.where('project_id', '=', projectId);
|
||||||
|
|
||||||
|
if (input.email) {
|
||||||
|
builder.where('email', 'LIKE', `%${input.email}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.firstName) {
|
||||||
|
builder.where('first_name', 'LIKE', `%${input.firstName}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.lastName) {
|
||||||
|
builder.where('last_name', 'LIKE', `%${input.lastName}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.country) {
|
||||||
|
builder.where(`properties['country']`, '=', input.country);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.city) {
|
||||||
|
builder.where(`properties['city']`, '=', input.city);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.region) {
|
||||||
|
builder.where(`properties['region']`, '=', input.region);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.device) {
|
||||||
|
builder.where(`properties['device']`, '=', input.device);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.browser) {
|
||||||
|
builder.where(`properties['browser']`, '=', input.browser);
|
||||||
|
}
|
||||||
|
|
||||||
|
const profiles = await builder.limit(input.limit ?? 5).execute();
|
||||||
|
|
||||||
|
return profiles;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProfile({
|
||||||
|
projectId,
|
||||||
|
}: {
|
||||||
|
projectId: string;
|
||||||
|
}) {
|
||||||
|
return tool({
|
||||||
|
description: 'Get a specific profile',
|
||||||
|
parameters: z.object({
|
||||||
|
projectId: z.string(),
|
||||||
|
email: z.string().optional(),
|
||||||
|
firstName: z.string().optional(),
|
||||||
|
lastName: z.string().optional(),
|
||||||
|
country: z.string().describe('ISO 3166-1 alpha-2').optional(),
|
||||||
|
city: z.string().optional(),
|
||||||
|
region: z.string().optional(),
|
||||||
|
device: z.string().optional(),
|
||||||
|
browser: z.string().optional(),
|
||||||
|
}),
|
||||||
|
execute: async (input) => {
|
||||||
|
const builder = clix(ch)
|
||||||
|
.select<IClickhouseProfile>([
|
||||||
|
'id',
|
||||||
|
'email',
|
||||||
|
'first_name',
|
||||||
|
'last_name',
|
||||||
|
'properties',
|
||||||
|
])
|
||||||
|
.from(TABLE_NAMES.profiles)
|
||||||
|
.where('project_id', '=', projectId);
|
||||||
|
|
||||||
|
if (input.email) {
|
||||||
|
builder.where('email', 'LIKE', `%${input.email}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.firstName) {
|
||||||
|
builder.where('first_name', 'LIKE', `%${input.firstName}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.lastName) {
|
||||||
|
builder.where('last_name', 'LIKE', `%${input.lastName}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.country) {
|
||||||
|
builder.where(`properties['country']`, '=', input.country);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.city) {
|
||||||
|
builder.where(`properties['city']`, '=', input.city);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.region) {
|
||||||
|
builder.where(`properties['region']`, '=', input.region);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.device) {
|
||||||
|
builder.where(`properties['device']`, '=', input.device);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.browser) {
|
||||||
|
builder.where(`properties['browser']`, '=', input.browser);
|
||||||
|
}
|
||||||
|
|
||||||
|
const profiles = await builder.limit(1).execute();
|
||||||
|
|
||||||
|
const profile = profiles[0];
|
||||||
|
if (!profile) {
|
||||||
|
return {
|
||||||
|
error: 'Profile not found',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = await clix(ch)
|
||||||
|
.select<IClickhouseEvent>([])
|
||||||
|
.from(TABLE_NAMES.events)
|
||||||
|
.where('project_id', '=', input.projectId)
|
||||||
|
.where('profile_id', '=', profile.id)
|
||||||
|
.limit(5)
|
||||||
|
.orderBy('created_at', 'DESC')
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
return {
|
||||||
|
profile,
|
||||||
|
events,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEvents({
|
||||||
|
projectId,
|
||||||
|
}: {
|
||||||
|
projectId: string;
|
||||||
|
}) {
|
||||||
|
return tool({
|
||||||
|
description: 'Get events for a project or specific profile',
|
||||||
|
parameters: z.object({
|
||||||
|
projectId: z.string(),
|
||||||
|
profileId: z.string().optional(),
|
||||||
|
take: z.number().optional().default(10),
|
||||||
|
eventNames: z.array(z.string()).optional(),
|
||||||
|
referrer: z.string().optional(),
|
||||||
|
referrerName: z.string().optional(),
|
||||||
|
referrerType: z.string().optional(),
|
||||||
|
device: z.string().optional(),
|
||||||
|
country: z.string().optional(),
|
||||||
|
city: z.string().optional(),
|
||||||
|
os: z.string().optional(),
|
||||||
|
browser: z.string().optional(),
|
||||||
|
properties: z.record(z.string(), z.string()).optional(),
|
||||||
|
startDate: z.string().optional().describe('ISO date string'),
|
||||||
|
endDate: z.string().optional().describe('ISO date string'),
|
||||||
|
}),
|
||||||
|
execute: async (input) => {
|
||||||
|
const builder = clix(ch)
|
||||||
|
.select<IClickhouseEvent>([])
|
||||||
|
.from(TABLE_NAMES.events)
|
||||||
|
.where('project_id', '=', projectId);
|
||||||
|
|
||||||
|
if (input.profileId) {
|
||||||
|
builder.where('profile_id', '=', input.profileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.eventNames) {
|
||||||
|
builder.where('name', 'IN', input.eventNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.referrer) {
|
||||||
|
builder.where('referrer', '=', input.referrer);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.referrerName) {
|
||||||
|
builder.where('referrer_name', '=', input.referrerName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.referrerType) {
|
||||||
|
builder.where('referrer_type', '=', input.referrerType);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.device) {
|
||||||
|
builder.where('device', '=', input.device);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.country) {
|
||||||
|
builder.where('country', '=', input.country);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.city) {
|
||||||
|
builder.where('city', '=', input.city);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.os) {
|
||||||
|
builder.where('os', '=', input.os);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.browser) {
|
||||||
|
builder.where('browser', '=', input.browser);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.properties) {
|
||||||
|
for (const [key, value] of Object.entries(input.properties)) {
|
||||||
|
builder.where(`properties['${key}']`, '=', value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.startDate && input.endDate) {
|
||||||
|
builder.where('created_at', 'BETWEEN', [
|
||||||
|
clix.datetime(input.startDate),
|
||||||
|
clix.datetime(input.endDate),
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
builder.where('created_at', 'BETWEEN', [
|
||||||
|
clix.datetime(new Date(Date.now() - 1000 * 60 * 60 * 24 * 7)),
|
||||||
|
clix.datetime(new Date()),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await builder.limit(input.take).execute();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSessions({
|
||||||
|
projectId,
|
||||||
|
}: {
|
||||||
|
projectId: string;
|
||||||
|
}) {
|
||||||
|
return tool({
|
||||||
|
description: 'Get sessions for a project or specific profile',
|
||||||
|
parameters: z.object({
|
||||||
|
projectId: z.string(),
|
||||||
|
profileId: z.string().optional(),
|
||||||
|
take: z.number().optional().default(10),
|
||||||
|
referrer: z.string().optional(),
|
||||||
|
referrerName: z.string().optional(),
|
||||||
|
referrerType: z.string().optional(),
|
||||||
|
device: z.string().optional(),
|
||||||
|
country: z.string().optional(),
|
||||||
|
city: z.string().optional(),
|
||||||
|
os: z.string().optional(),
|
||||||
|
browser: z.string().optional(),
|
||||||
|
properties: z.record(z.string(), z.string()).optional(),
|
||||||
|
startDate: z.string().optional().describe('ISO date string'),
|
||||||
|
endDate: z.string().optional().describe('ISO date string'),
|
||||||
|
}),
|
||||||
|
execute: async (input) => {
|
||||||
|
const builder = clix(ch)
|
||||||
|
.select<IClickhouseSession>([])
|
||||||
|
.from(TABLE_NAMES.sessions)
|
||||||
|
.where('project_id', '=', projectId)
|
||||||
|
.where('sign', '=', 1);
|
||||||
|
|
||||||
|
if (input.profileId) {
|
||||||
|
builder.where('profile_id', '=', input.profileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.referrer) {
|
||||||
|
builder.where('referrer', '=', input.referrer);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.referrerName) {
|
||||||
|
builder.where('referrer_name', '=', input.referrerName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.referrerType) {
|
||||||
|
builder.where('referrer_type', '=', input.referrerType);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.device) {
|
||||||
|
builder.where('device', '=', input.device);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.country) {
|
||||||
|
builder.where('country', '=', input.country);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.city) {
|
||||||
|
builder.where('city', '=', input.city);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.os) {
|
||||||
|
builder.where('os', '=', input.os);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.browser) {
|
||||||
|
builder.where('browser', '=', input.browser);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.properties) {
|
||||||
|
for (const [key, value] of Object.entries(input.properties)) {
|
||||||
|
builder.where(`properties['${key}']`, '=', value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.startDate && input.endDate) {
|
||||||
|
builder.where('created_at', 'BETWEEN', [
|
||||||
|
clix.datetime(input.startDate),
|
||||||
|
clix.datetime(input.endDate),
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
builder.where('created_at', 'BETWEEN', [
|
||||||
|
clix.datetime(new Date(Date.now() - 1000 * 60 * 60 * 24 * 7)),
|
||||||
|
clix.datetime(new Date()),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await builder.limit(input.take).execute();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllEventNames({
|
||||||
|
projectId,
|
||||||
|
}: {
|
||||||
|
projectId: string;
|
||||||
|
}) {
|
||||||
|
return tool({
|
||||||
|
description: 'Get the top 50 event names in a comma separated list',
|
||||||
|
parameters: z.object({}),
|
||||||
|
execute: async () => {
|
||||||
|
return getCache(`top-event-names:${projectId}`, 60 * 10, async () => {
|
||||||
|
const events = await clix(ch)
|
||||||
|
.select<IClickhouseEvent>(['name', 'count() as count'])
|
||||||
|
.from(TABLE_NAMES.event_names_mv)
|
||||||
|
.where('project_id', '=', projectId)
|
||||||
|
.groupBy(['name'])
|
||||||
|
.orderBy('count', 'DESC')
|
||||||
|
.limit(50)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
return events.map((event) => event.name).join(',');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
115
apps/api/src/utils/ai.ts
Normal file
115
apps/api/src/utils/ai.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { anthropic } from '@ai-sdk/anthropic';
|
||||||
|
import { openai } from '@ai-sdk/openai';
|
||||||
|
import { chartTypes, operators, timeWindows } from '@openpanel/constants';
|
||||||
|
import { mapKeys } from '@openpanel/validation';
|
||||||
|
|
||||||
|
export const getChatModel = () => {
|
||||||
|
switch (process.env.AI_MODEL) {
|
||||||
|
case 'gpt-4o':
|
||||||
|
return openai('gpt-4o');
|
||||||
|
case 'claude-3-5':
|
||||||
|
return anthropic('claude-3-5-haiku-latest');
|
||||||
|
default:
|
||||||
|
return openai('gpt-4.1-mini');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getChatSystemPrompt = ({
|
||||||
|
projectId,
|
||||||
|
}: {
|
||||||
|
projectId: string;
|
||||||
|
}) => {
|
||||||
|
return `You're an product and web analytics expert. Don't generate more than the user asks for. Follow all rules listed below!
|
||||||
|
## General:
|
||||||
|
- projectId: \`${projectId}\`
|
||||||
|
- Do not hallucinate, if you can't make a report based on the user's request, just say so.
|
||||||
|
- Today is ${new Date().toISOString()}
|
||||||
|
- \`range\` should always be \`custom\`
|
||||||
|
- if range is \`custom\`, make sure to have \`startDate\` and \`endDate\`
|
||||||
|
- Available intervals: ${Object.values(timeWindows)
|
||||||
|
.map((t) => t.key)
|
||||||
|
.join(', ')}
|
||||||
|
- Try to figure out a time window, ${Object.values(timeWindows)
|
||||||
|
.map((t) => t.key)
|
||||||
|
.join(', ')}. If no match always use \`custom\` with a start and end date.
|
||||||
|
- Pick corresponding chartType from \`${Object.keys(chartTypes).join(', ')}\`, match with your best effort.
|
||||||
|
- Always add a name to the report.
|
||||||
|
- Never do a summary!
|
||||||
|
|
||||||
|
### Formatting
|
||||||
|
- Never generate images
|
||||||
|
- If you use katex, please wrap the equation in $$
|
||||||
|
- Use tables when showing lists of data.
|
||||||
|
|
||||||
|
### Events
|
||||||
|
- Tool: \`getAllEventNames\`, use this tool *before* calling any other tool if the user's request mentions an event but you are unsure of the exact event name stored in the system. Only call this once!
|
||||||
|
- \`screen_view\` is a page view event
|
||||||
|
- If you see any paths you should pick \`screen_view\` event and use a \`path\` filter
|
||||||
|
- To find referrers you can use \`referrer\`, \`referrer_name\` and \`referrer_type\` columns
|
||||||
|
- Use unique IDs for each event and each filter
|
||||||
|
|
||||||
|
### Filters
|
||||||
|
- If you see a '*' in the filters value, depending on where it is you can split it up and do 'startsWith' together with 'endsWith'. Eg: '/path/*' -> 'path startsWith /path/', or '*/path' -> 'path endsWith /path/', or '/path/*/something' -> 'path startsWith /path/ and endsWith /something'
|
||||||
|
- If user asks for several events you can use this tool once (with all events)
|
||||||
|
- Example: path is /path/*/something \`{"id":"1","name":"screen_view","displayName":"Path is something","segment":"user","filters":[{"id":"1","name":"path","operator":"startsWith","value":["/path/"]},{"id":"1","name":"path","operator":"endsWith","value":["/something"]}]}\`
|
||||||
|
- Other examples for filters:
|
||||||
|
- Available operators: ${mapKeys(operators).join(', ')}
|
||||||
|
- {"id":"1","name":"path","operator":"endsWith","value":["/foo", "/bar"]}
|
||||||
|
- {"id":"1","name":"path","operator":"isNot","value":["/","/a","/b"]}
|
||||||
|
- {"id":"1","name":"path","operator":"contains","value":["nuke"]}
|
||||||
|
- {"id":"1","name":"path","operator":"regex","value":["/onboarding/.+/verify/?"]}
|
||||||
|
- {"id":"1","name":"path","operator":"isNull","value":[]}
|
||||||
|
|
||||||
|
## Conversion Report
|
||||||
|
|
||||||
|
Tool: \`getConversionReport\`
|
||||||
|
Rules:
|
||||||
|
- Use this when ever a user wants any conversion rate over time.
|
||||||
|
- Needs two events
|
||||||
|
|
||||||
|
## Funnel Report
|
||||||
|
|
||||||
|
Tool: \`getFunnelReport\`
|
||||||
|
Rules:
|
||||||
|
- Use this when ever a user wants to see a funnel between two or more events.
|
||||||
|
- Needs two or more events
|
||||||
|
|
||||||
|
## Other reports
|
||||||
|
|
||||||
|
Tool: \`getReport\`
|
||||||
|
Rules:
|
||||||
|
- Use this when ever a user wants any other report than a conversion, funnel or retention.
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
#### Active users the last 30min
|
||||||
|
\`\`\`
|
||||||
|
{"events":[{"id":"1","name":"*","displayName":"Active users","segment":"user","filters":[{"id":"1","name":"name","operator":"is","value":["screen_view","session_start"]}]}],"breakdowns":[]}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
#### How to get most events with breakdown by title
|
||||||
|
\`\`\`
|
||||||
|
{"events":[{"id":"1","name":"screen_view","segment":"event","filters":[{"id":"1","name":"path","operator":"is","value":["Article"]}]}],"breakdowns":[{"id":"1","name":"properties.params.title"}]}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
#### Get popular referrers
|
||||||
|
\`\`\`
|
||||||
|
{"events":[{"id":"1","name":"session_start","segment":"event","filters":[]}],"breakdowns":[{"id":"1","name":"referrer_name"}]}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
#### Popular screen views
|
||||||
|
\`\`\`
|
||||||
|
{"chartType":"bar","events":[{"id":"1","name":"screen_view","segment":"event","filters":[]}],"breakdowns":[{"id":"1","name":"path"}]}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
#### Popular screen views from X,Y,Z referrers
|
||||||
|
\`\`\`
|
||||||
|
{"chartType":"bar","events":[{"id":"1","name":"screen_view","segment":"event","filters":[{"id":"1","name":"referrer_name","operator":"is","value":["Google","Bing","Yahoo!"]}]}],"breakdowns":[{"id":"1","name":"path"}]}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
#### Bounce rate (use session_end together with formula)
|
||||||
|
\`\`\`
|
||||||
|
{"chartType":"linear","formula":"B/A*100","events":[{"id":"1","name":"session_end","segment":"event","filters":[]},{"id":"2","name":"session_end","segment":"event","filters":[{"id":"3","name":"properties.__bounce","operator":"is","value":["true"]}]}],"breakdowns":[]}
|
||||||
|
\`\`\`
|
||||||
|
`;
|
||||||
|
};
|
||||||
@@ -13,3 +13,27 @@ export class LogError extends Error {
|
|||||||
Object.setPrototypeOf(this, new.target.prototype);
|
Object.setPrototypeOf(this, new.target.prototype);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class HttpError extends Error {
|
||||||
|
public readonly status: number;
|
||||||
|
public readonly fingerprint?: string;
|
||||||
|
public readonly extra?: Record<string, unknown>;
|
||||||
|
public readonly error?: Error | unknown;
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
options?: {
|
||||||
|
status?: number;
|
||||||
|
fingerprint?: string;
|
||||||
|
extra?: Record<string, unknown>;
|
||||||
|
error?: Error | unknown;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'HttpError';
|
||||||
|
this.status = options?.status ?? 500;
|
||||||
|
this.fingerprint = options?.fingerprint;
|
||||||
|
this.extra = options?.extra;
|
||||||
|
this.error = options?.error;
|
||||||
|
Object.setPrototypeOf(this, new.target.prototype);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { getRedisCache } from '@openpanel/redis';
|
import { getRedisCache } from '@openpanel/redis';
|
||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||||
|
|
||||||
export async function activateRateLimiter({
|
export async function activateRateLimiter<T extends FastifyRequest>({
|
||||||
fastify,
|
fastify,
|
||||||
max,
|
max,
|
||||||
timeWindow,
|
timeWindow,
|
||||||
|
keyGenerator,
|
||||||
}: {
|
}: {
|
||||||
fastify: FastifyInstance;
|
fastify: FastifyInstance;
|
||||||
max: number;
|
max: number;
|
||||||
timeWindow?: string;
|
timeWindow?: string;
|
||||||
|
keyGenerator?: (req: T) => string | undefined;
|
||||||
}) {
|
}) {
|
||||||
await fastify.register(import('@fastify/rate-limit'), {
|
await fastify.register(import('@fastify/rate-limit'), {
|
||||||
max,
|
max,
|
||||||
@@ -22,6 +24,12 @@ export async function activateRateLimiter({
|
|||||||
},
|
},
|
||||||
redis: getRedisCache(),
|
redis: getRedisCache(),
|
||||||
keyGenerator(req) {
|
keyGenerator(req) {
|
||||||
|
if (keyGenerator) {
|
||||||
|
const key = keyGenerator(req as T);
|
||||||
|
if (key) {
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
}
|
||||||
return (req.headers['openpanel-client-id'] ||
|
return (req.headers['openpanel-client-id'] ||
|
||||||
req.headers['x-real-ip'] ||
|
req.headers['x-real-ip'] ||
|
||||||
req.headers['x-client-ip'] ||
|
req.headers['x-client-ip'] ||
|
||||||
|
|||||||
@@ -6,11 +6,12 @@
|
|||||||
"dev": "rm -rf .next && pnpm with-env next dev",
|
"dev": "rm -rf .next && pnpm with-env next dev",
|
||||||
"testing": "pnpm dev",
|
"testing": "pnpm dev",
|
||||||
"build": "pnpm with-env next build",
|
"build": "pnpm with-env next build",
|
||||||
"start": "next start",
|
"start": "pnpm with-env next start",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"with-env": "dotenv -e ../../.env -c --"
|
"with-env": "dotenv -e ../../.env -c --"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ai-sdk/react": "^1.2.5",
|
||||||
"@clickhouse/client": "^1.2.0",
|
"@clickhouse/client": "^1.2.0",
|
||||||
"@hookform/resolvers": "^3.3.4",
|
"@hookform/resolvers": "^3.3.4",
|
||||||
"@hyperdx/node-opentelemetry": "^0.8.1",
|
"@hyperdx/node-opentelemetry": "^0.8.1",
|
||||||
@@ -57,6 +58,7 @@
|
|||||||
"@trpc/react-query": "^10.45.2",
|
"@trpc/react-query": "^10.45.2",
|
||||||
"@trpc/server": "^10.45.2",
|
"@trpc/server": "^10.45.2",
|
||||||
"@types/d3": "^7.4.3",
|
"@types/d3": "^7.4.3",
|
||||||
|
"ai": "^4.2.10",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"bind-event-listener": "^3.0.0",
|
"bind-event-listener": "^3.0.0",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
@@ -71,6 +73,7 @@
|
|||||||
"hamburger-react": "^2.5.0",
|
"hamburger-react": "^2.5.0",
|
||||||
"input-otp": "^1.2.4",
|
"input-otp": "^1.2.4",
|
||||||
"javascript-time-ago": "^2.5.9",
|
"javascript-time-ago": "^2.5.9",
|
||||||
|
"katex": "^0.16.21",
|
||||||
"lodash.debounce": "^4.0.8",
|
"lodash.debounce": "^4.0.8",
|
||||||
"lodash.isequal": "^4.5.0",
|
"lodash.isequal": "^4.5.0",
|
||||||
"lodash.throttle": "^4.1.1",
|
"lodash.throttle": "^4.1.1",
|
||||||
@@ -95,6 +98,7 @@
|
|||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-hook-form": "^7.50.1",
|
"react-hook-form": "^7.50.1",
|
||||||
"react-in-viewport": "1.0.0-alpha.30",
|
"react-in-viewport": "1.0.0-alpha.30",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"react-redux": "^8.1.3",
|
"react-redux": "^8.1.3",
|
||||||
"react-responsive": "^9.0.2",
|
"react-responsive": "^9.0.2",
|
||||||
"react-simple-maps": "3.0.0",
|
"react-simple-maps": "3.0.0",
|
||||||
@@ -103,6 +107,12 @@
|
|||||||
"react-use-websocket": "^4.7.0",
|
"react-use-websocket": "^4.7.0",
|
||||||
"react-virtualized-auto-sizer": "^1.0.22",
|
"react-virtualized-auto-sizer": "^1.0.22",
|
||||||
"recharts": "^2.12.0",
|
"recharts": "^2.12.0",
|
||||||
|
"rehype-katex": "^7.0.1",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
|
"remark-highlight": "^0.1.1",
|
||||||
|
"remark-math": "^6.0.0",
|
||||||
|
"remark-parse": "^11.0.0",
|
||||||
|
"remark-rehype": "^11.1.2",
|
||||||
"short-unique-id": "^5.0.3",
|
"short-unique-id": "^5.0.3",
|
||||||
"slugify": "^1.6.6",
|
"slugify": "^1.6.6",
|
||||||
"sonner": "^1.4.0",
|
"sonner": "^1.4.0",
|
||||||
@@ -111,7 +121,7 @@
|
|||||||
"tailwind-merge": "^1.14.0",
|
"tailwind-merge": "^1.14.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"usehooks-ts": "^2.14.0",
|
"usehooks-ts": "^2.14.0",
|
||||||
"zod": "^3.22.4"
|
"zod": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@openpanel/payments": "workspace:*",
|
"@openpanel/payments": "workspace:*",
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import Chat from '@/components/chat/chat';
|
||||||
|
import { db, getOrganizationBySlug } from '@openpanel/db';
|
||||||
|
import type { UIMessage } from 'ai';
|
||||||
|
|
||||||
|
export default async function ChatPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: { organizationSlug: string; projectId: string };
|
||||||
|
}) {
|
||||||
|
const { projectId } = await params;
|
||||||
|
const [organization, chat] = await Promise.all([
|
||||||
|
getOrganizationBySlug(params.organizationSlug),
|
||||||
|
db.chat.findFirst({
|
||||||
|
where: {
|
||||||
|
projectId,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const messages = ((chat?.messages as UIMessage[]) || []).slice(-10);
|
||||||
|
return (
|
||||||
|
<Chat
|
||||||
|
projectId={projectId}
|
||||||
|
initialMessages={messages}
|
||||||
|
organization={organization}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { cn } from '@/utils/cn';
|
||||||
import { useSelectedLayoutSegments } from 'next/navigation';
|
import { useSelectedLayoutSegments } from 'next/navigation';
|
||||||
|
|
||||||
const NOT_MIGRATED_PAGES = ['reports'];
|
const NOT_MIGRATED_PAGES = ['reports'];
|
||||||
@@ -16,6 +17,13 @@ export default function LayoutContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pb-20 transition-all max-lg:mt-12 lg:pl-72">{children}</div>
|
<div
|
||||||
|
className={cn(
|
||||||
|
'pb-20 transition-all max-lg:mt-12 lg:pl-72',
|
||||||
|
segments.includes('chat') && 'pb-0',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
PlusIcon,
|
PlusIcon,
|
||||||
ScanEyeIcon,
|
ScanEyeIcon,
|
||||||
ServerIcon,
|
ServerIcon,
|
||||||
|
SparklesIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
WallpaperIcon,
|
WallpaperIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
@@ -23,6 +24,7 @@ import { usePathname } from 'next/navigation';
|
|||||||
|
|
||||||
import { ProjectLink } from '@/components/links';
|
import { ProjectLink } from '@/components/links';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
|
import { CommandShortcut } from '@/components/ui/command';
|
||||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||||
import type { IServiceDashboards, IServiceOrganization } from '@openpanel/db';
|
import type { IServiceDashboards, IServiceOrganization } from '@openpanel/db';
|
||||||
import { differenceInDays, format } from 'date-fns';
|
import { differenceInDays, format } from 'date-fns';
|
||||||
@@ -174,15 +176,25 @@ export default function LayoutMenu({
|
|||||||
</div>
|
</div>
|
||||||
</ProjectLink>
|
</ProjectLink>
|
||||||
)}
|
)}
|
||||||
|
<ProjectLink
|
||||||
|
href={'/chat'}
|
||||||
|
className={cn('rounded p-2 row gap-2 hover:bg-def-200 items-center')}
|
||||||
|
>
|
||||||
|
<SparklesIcon size={20} />
|
||||||
|
<div className="flex-1 col gap-1">
|
||||||
|
<div className="font-medium">Ask AI</div>
|
||||||
|
</div>
|
||||||
|
<CommandShortcut>⌘K</CommandShortcut>
|
||||||
|
</ProjectLink>
|
||||||
<ProjectLink
|
<ProjectLink
|
||||||
href={'/reports'}
|
href={'/reports'}
|
||||||
className={cn('rounded p-2 row gap-2 hover:bg-def-200')}
|
className={cn('rounded p-2 row gap-2 hover:bg-def-200 items-center')}
|
||||||
>
|
>
|
||||||
<ChartLineIcon size={20} />
|
<ChartLineIcon size={20} />
|
||||||
<div className="flex-1 col gap-1">
|
<div className="flex-1 col gap-1">
|
||||||
<div className="font-medium">Create report</div>
|
<div className="font-medium">Create report</div>
|
||||||
</div>
|
</div>
|
||||||
<PlusIcon size={16} className="text-muted-foreground" />
|
<CommandShortcut>⌘J</CommandShortcut>
|
||||||
</ProjectLink>
|
</ProjectLink>
|
||||||
</div>
|
</div>
|
||||||
<LinkWithIcon icon={WallpaperIcon} label="Overview" href={'/'} />
|
<LinkWithIcon icon={WallpaperIcon} label="Overview" href={'/'} />
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import { ReportInterval } from '@/components/report/ReportInterval';
|
|||||||
import { ReportLineType } from '@/components/report/ReportLineType';
|
import { ReportLineType } from '@/components/report/ReportLineType';
|
||||||
import { ReportSaveButton } from '@/components/report/ReportSaveButton';
|
import { ReportSaveButton } from '@/components/report/ReportSaveButton';
|
||||||
import {
|
import {
|
||||||
|
changeChartType,
|
||||||
changeDateRanges,
|
changeDateRanges,
|
||||||
changeEndDate,
|
changeEndDate,
|
||||||
|
changeInterval,
|
||||||
changeStartDate,
|
changeStartDate,
|
||||||
ready,
|
ready,
|
||||||
reset,
|
reset,
|
||||||
@@ -74,7 +76,13 @@ export default function ReportEditor({
|
|||||||
</div>
|
</div>
|
||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
<div className="col-span-4 grid grid-cols-2 gap-2 md:grid-cols-4">
|
<div className="col-span-4 grid grid-cols-2 gap-2 md:grid-cols-4">
|
||||||
<ReportChartType className="min-w-0 flex-1" />
|
<ReportChartType
|
||||||
|
className="min-w-0 flex-1"
|
||||||
|
onChange={(type) => {
|
||||||
|
dispatch(changeChartType(type));
|
||||||
|
}}
|
||||||
|
value={report.chartType}
|
||||||
|
/>
|
||||||
<TimeWindowPicker
|
<TimeWindowPicker
|
||||||
className="min-w-0 flex-1"
|
className="min-w-0 flex-1"
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
@@ -90,7 +98,13 @@ export default function ReportEditor({
|
|||||||
endDate={report.endDate}
|
endDate={report.endDate}
|
||||||
startDate={report.startDate}
|
startDate={report.startDate}
|
||||||
/>
|
/>
|
||||||
<ReportInterval className="min-w-0 flex-1" />
|
<ReportInterval
|
||||||
|
className="min-w-0 flex-1"
|
||||||
|
interval={report.interval}
|
||||||
|
onChange={(newInterval) => dispatch(changeInterval(newInterval))}
|
||||||
|
range={report.range}
|
||||||
|
chartType={report.chartType}
|
||||||
|
/>
|
||||||
<ReportLineType className="min-w-0 flex-1" />
|
<ReportLineType className="min-w-0 flex-1" />
|
||||||
</div>
|
</div>
|
||||||
<div className="col-start-2 row-start-1 text-right md:col-start-6">
|
<div className="col-start-2 row-start-1 text-right md:col-start-6">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import Providers from './providers';
|
|||||||
|
|
||||||
import '@/styles/globals.css';
|
import '@/styles/globals.css';
|
||||||
import 'flag-icons/css/flag-icons.min.css';
|
import 'flag-icons/css/flag-icons.min.css';
|
||||||
|
import 'katex/dist/katex.min.css';
|
||||||
|
|
||||||
import { GeistMono } from 'geist/font/mono';
|
import { GeistMono } from 'geist/font/mono';
|
||||||
import { GeistSans } from 'geist/font/sans';
|
import { GeistSans } from 'geist/font/sans';
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { MetadataRoute } from 'next';
|
import type { MetadataRoute } from 'next';
|
||||||
|
|
||||||
|
export const dynamic = 'static';
|
||||||
|
|
||||||
export default function manifest(): MetadataRoute.Manifest {
|
export default function manifest(): MetadataRoute.Manifest {
|
||||||
return {
|
return {
|
||||||
id: process.env.NEXT_PUBLIC_DASHBOARD_URL,
|
id: process.env.NEXT_PUBLIC_DASHBOARD_URL,
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ function AllProviders({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<NuqsAdapter>
|
||||||
<ThemeProvider
|
<ThemeProvider
|
||||||
attribute="class"
|
attribute="class"
|
||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
@@ -70,18 +71,17 @@ function AllProviders({ children }: { children: React.ReactNode }) {
|
|||||||
<ReduxProvider store={storeRef.current}>
|
<ReduxProvider store={storeRef.current}>
|
||||||
<api.Provider client={trpcClient} queryClient={queryClient}>
|
<api.Provider client={trpcClient} queryClient={queryClient}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<NuqsAdapter>
|
|
||||||
<TooltipProvider delayDuration={200}>
|
<TooltipProvider delayDuration={200}>
|
||||||
{children}
|
{children}
|
||||||
<NotificationProvider />
|
<NotificationProvider />
|
||||||
<Toaster />
|
<Toaster />
|
||||||
<ModalProvider />
|
<ModalProvider />
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</NuqsAdapter>
|
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</api.Provider>
|
</api.Provider>
|
||||||
</ReduxProvider>
|
</ReduxProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
</NuqsAdapter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
70
apps/dashboard/src/components/chat/chat-form.tsx
Normal file
70
apps/dashboard/src/components/chat/chat-form.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { cn } from '@/utils/cn';
|
||||||
|
import type { useChat } from '@ai-sdk/react';
|
||||||
|
import { useLocalStorage } from 'usehooks-ts';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
|
import { ScrollArea } from '../ui/scroll-area';
|
||||||
|
|
||||||
|
type Props = Pick<
|
||||||
|
ReturnType<typeof useChat>,
|
||||||
|
'handleSubmit' | 'handleInputChange' | 'input' | 'append'
|
||||||
|
> & {
|
||||||
|
projectId: string;
|
||||||
|
isLimited: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ChatForm({
|
||||||
|
handleSubmit: handleSubmitProp,
|
||||||
|
input,
|
||||||
|
handleInputChange,
|
||||||
|
append,
|
||||||
|
projectId,
|
||||||
|
isLimited,
|
||||||
|
}: Props) {
|
||||||
|
const [quickActions, setQuickActions] = useLocalStorage<string[]>(
|
||||||
|
`chat-quick-actions:${projectId}`,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
handleSubmitProp(e);
|
||||||
|
setQuickActions([input, ...quickActions].slice(0, 5));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-def-100 to-def-100/50 backdrop-blur-sm z-20">
|
||||||
|
<ScrollArea orientation="horizontal">
|
||||||
|
<div className="row gap-2 px-4">
|
||||||
|
{quickActions.map((q) => (
|
||||||
|
<Button
|
||||||
|
disabled={isLimited}
|
||||||
|
key={q}
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
append({
|
||||||
|
role: 'user',
|
||||||
|
content: q,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{q}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
<form onSubmit={handleSubmit} className="p-4 pt-2">
|
||||||
|
<input
|
||||||
|
disabled={isLimited}
|
||||||
|
className={cn(
|
||||||
|
'w-full h-12 px-4 outline-none border border-border text-foreground rounded-md font-mono placeholder:text-foreground/50 bg-background/50',
|
||||||
|
isLimited && 'opacity-50 cursor-not-allowed',
|
||||||
|
)}
|
||||||
|
value={input}
|
||||||
|
placeholder="Ask me anything..."
|
||||||
|
onChange={handleInputChange}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
144
apps/dashboard/src/components/chat/chat-message.tsx
Normal file
144
apps/dashboard/src/components/chat/chat-message.tsx
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import { Markdown } from '@/components/markdown';
|
||||||
|
import { cn } from '@/utils/cn';
|
||||||
|
import { zChartInputAI } from '@openpanel/validation';
|
||||||
|
import type { UIMessage } from 'ai';
|
||||||
|
import { Loader2Icon, UserIcon } from 'lucide-react';
|
||||||
|
import { Fragment, memo } from 'react';
|
||||||
|
import { Card } from '../card';
|
||||||
|
import { LogoSquare } from '../logo';
|
||||||
|
import { Skeleton } from '../skeleton';
|
||||||
|
import Syntax from '../syntax';
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from '../ui/accordion';
|
||||||
|
import { ChatReport } from './chat-report';
|
||||||
|
|
||||||
|
export const ChatMessage = memo(
|
||||||
|
({
|
||||||
|
message,
|
||||||
|
isLast,
|
||||||
|
isStreaming,
|
||||||
|
debug,
|
||||||
|
}: {
|
||||||
|
message: UIMessage;
|
||||||
|
isLast: boolean;
|
||||||
|
isStreaming: boolean;
|
||||||
|
debug: boolean;
|
||||||
|
}) => {
|
||||||
|
const showIsStreaming = isLast && isStreaming;
|
||||||
|
return (
|
||||||
|
<div className="max-w-xl w-full">
|
||||||
|
<div className="row">
|
||||||
|
<div className="w-8 shrink-0">
|
||||||
|
<div className="size-6 relative">
|
||||||
|
{message.role === 'assistant' ? (
|
||||||
|
<LogoSquare className="size-full rounded-full" />
|
||||||
|
) : (
|
||||||
|
<div className="size-full bg-black text-white rounded-full center-center">
|
||||||
|
<UserIcon className="size-4" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute inset-0 bg-background rounded-full center-center opacity-0',
|
||||||
|
showIsStreaming && 'opacity-100',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Loader2Icon className="size-4 animate-spin text-foreground" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col gap-4 flex-1">
|
||||||
|
{message.parts.map((p, index) => {
|
||||||
|
const key = index.toString() + p.type;
|
||||||
|
const isToolInvocation = p.type === 'tool-invocation';
|
||||||
|
|
||||||
|
if (p.type === 'step-start') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isToolInvocation && p.type !== 'text') {
|
||||||
|
return <Debug enabled={debug} json={p} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p.type === 'text') {
|
||||||
|
return (
|
||||||
|
<div className="prose dark:prose-invert prose-sm" key={key}>
|
||||||
|
<Markdown>{p.text}</Markdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isToolInvocation && p.toolInvocation.state === 'result') {
|
||||||
|
const { result } = p.toolInvocation;
|
||||||
|
|
||||||
|
if (result.type === 'report') {
|
||||||
|
const report = zChartInputAI.safeParse(result.report);
|
||||||
|
if (report.success) {
|
||||||
|
return (
|
||||||
|
<Fragment key={key}>
|
||||||
|
<Debug json={result} enabled={debug} />
|
||||||
|
<ChatReport report={report.data} lazy={!isLast} />
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Debug
|
||||||
|
key={key}
|
||||||
|
json={p.toolInvocation.result}
|
||||||
|
enabled={debug}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
{showIsStreaming && (
|
||||||
|
<div className="w-full col gap-2">
|
||||||
|
<Skeleton className="w-3/5 h-4" />
|
||||||
|
<Skeleton className="w-4/5 h-4" />
|
||||||
|
<Skeleton className="w-2/5 h-4" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!isLast && (
|
||||||
|
<div className="w-full shrink-0 pl-8 mt-4">
|
||||||
|
<div className="h-px bg-border" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function Debug({ enabled, json }: { enabled?: boolean; json?: any }) {
|
||||||
|
if (!enabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Accordion type="single" collapsible>
|
||||||
|
<Card>
|
||||||
|
<AccordionItem value={'json'}>
|
||||||
|
<AccordionTrigger className="text-left p-4 py-2 w-full font-medium font-mono row items-center">
|
||||||
|
<span className="flex-1">Show JSON result</span>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="p-2">
|
||||||
|
<Syntax
|
||||||
|
wrapLines
|
||||||
|
language="json"
|
||||||
|
code={JSON.stringify(json, null, 2)}
|
||||||
|
/>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Card>
|
||||||
|
</Accordion>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
apps/dashboard/src/components/chat/chat-messages.tsx
Normal file
84
apps/dashboard/src/components/chat/chat-messages.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { useScrollAnchor } from '@/hooks/use-scroll-anchor';
|
||||||
|
import type { IServiceOrganization, Organization } from '@openpanel/db';
|
||||||
|
import type { UIMessage } from 'ai';
|
||||||
|
import { Loader2Icon } from 'lucide-react';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { ProjectLink } from '../links';
|
||||||
|
import { Markdown } from '../markdown';
|
||||||
|
import { Skeleton } from '../skeleton';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '../ui/alert';
|
||||||
|
import { Button, LinkButton } from '../ui/button';
|
||||||
|
import { ScrollArea } from '../ui/scroll-area';
|
||||||
|
import { ChatMessage } from './chat-message';
|
||||||
|
|
||||||
|
export function ChatMessages({
|
||||||
|
messages,
|
||||||
|
debug,
|
||||||
|
status,
|
||||||
|
isLimited,
|
||||||
|
}: {
|
||||||
|
messages: UIMessage[];
|
||||||
|
debug: boolean;
|
||||||
|
status: 'submitted' | 'streaming' | 'ready' | 'error';
|
||||||
|
isLimited: boolean;
|
||||||
|
}) {
|
||||||
|
const { messagesRef, scrollRef, visibilityRef, scrollToBottom } =
|
||||||
|
useScrollAnchor();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scrollToBottom();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const lastMessage = messages[messages.length - 1];
|
||||||
|
if (lastMessage?.role === 'user') {
|
||||||
|
scrollToBottom();
|
||||||
|
}
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollArea className="h-full" ref={scrollRef}>
|
||||||
|
<div ref={messagesRef} className="p-8 col gap-2">
|
||||||
|
{messages.map((m, index) => {
|
||||||
|
return (
|
||||||
|
<ChatMessage
|
||||||
|
key={m.id}
|
||||||
|
message={m}
|
||||||
|
isStreaming={status === 'streaming'}
|
||||||
|
isLast={index === messages.length - 1}
|
||||||
|
debug={debug}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{status === 'submitted' && (
|
||||||
|
<div className="card p-4 center-center max-w-xl pl-8">
|
||||||
|
<Loader2Icon className="w-4 h-4 animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isLimited && (
|
||||||
|
<div className="max-w-xl pl-8 mt-8">
|
||||||
|
<Alert variant={'warning'}>
|
||||||
|
<AlertTitle>Upgrade your account</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
<p>
|
||||||
|
To keep using this feature you need to upgrade your account.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<ProjectLink
|
||||||
|
href="/settings/organization?tab=billing"
|
||||||
|
className="font-medium underline"
|
||||||
|
>
|
||||||
|
Visit Billing
|
||||||
|
</ProjectLink>{' '}
|
||||||
|
to upgrade.
|
||||||
|
</p>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="h-20 p-4 w-full" />
|
||||||
|
<div className="w-full h-px" ref={visibilityRef} />
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
apps/dashboard/src/components/chat/chat-report.tsx
Normal file
90
apps/dashboard/src/components/chat/chat-report.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { pushModal } from '@/modals';
|
||||||
|
import type {
|
||||||
|
IChartInputAi,
|
||||||
|
IChartRange,
|
||||||
|
IChartType,
|
||||||
|
IInterval,
|
||||||
|
} from '@openpanel/validation';
|
||||||
|
import { endOfDay, startOfDay } from 'date-fns';
|
||||||
|
import { SaveIcon } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { ReportChart } from '../report-chart';
|
||||||
|
import { ReportChartType } from '../report/ReportChartType';
|
||||||
|
import { ReportInterval } from '../report/ReportInterval';
|
||||||
|
import { TimeWindowPicker } from '../time-window-picker';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
|
|
||||||
|
export function ChatReport({
|
||||||
|
lazy,
|
||||||
|
...props
|
||||||
|
}: { report: IChartInputAi; lazy: boolean }) {
|
||||||
|
const [chartType, setChartType] = useState<IChartType>(
|
||||||
|
props.report.chartType,
|
||||||
|
);
|
||||||
|
const [startDate, setStartDate] = useState<string>(props.report.startDate);
|
||||||
|
const [endDate, setEndDate] = useState<string>(props.report.endDate);
|
||||||
|
const [range, setRange] = useState<IChartRange>(props.report.range);
|
||||||
|
const [interval, setInterval] = useState<IInterval>(props.report.interval);
|
||||||
|
const report = {
|
||||||
|
...props.report,
|
||||||
|
lineType: 'linear' as const,
|
||||||
|
chartType,
|
||||||
|
startDate: range === 'custom' ? startDate : null,
|
||||||
|
endDate: range === 'custom' ? endDate : null,
|
||||||
|
range,
|
||||||
|
interval,
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<div className="text-center text-sm font-mono font-medium pt-4">
|
||||||
|
{props.report.name}
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
<ReportChart lazy={lazy} report={report} />
|
||||||
|
</div>
|
||||||
|
<div className="row justify-between gap-1 border-t border-border p-2">
|
||||||
|
<div className="col md:row gap-1">
|
||||||
|
<TimeWindowPicker
|
||||||
|
className="min-w-0"
|
||||||
|
onChange={setRange}
|
||||||
|
value={report.range}
|
||||||
|
onStartDateChange={(date) =>
|
||||||
|
setStartDate(startOfDay(date).toISOString())
|
||||||
|
}
|
||||||
|
onEndDateChange={(date) => setEndDate(endOfDay(date).toISOString())}
|
||||||
|
endDate={report.endDate}
|
||||||
|
startDate={report.startDate}
|
||||||
|
/>
|
||||||
|
<ReportInterval
|
||||||
|
className="min-w-0"
|
||||||
|
interval={interval}
|
||||||
|
range={range}
|
||||||
|
chartType={chartType}
|
||||||
|
onChange={setInterval}
|
||||||
|
/>
|
||||||
|
<ReportChartType
|
||||||
|
value={chartType}
|
||||||
|
onChange={(type) => {
|
||||||
|
setChartType(type);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
icon={SaveIcon}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
pushModal('SaveReport', {
|
||||||
|
report,
|
||||||
|
disableRedirect: true,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save report
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
apps/dashboard/src/components/chat/chat.tsx
Normal file
76
apps/dashboard/src/components/chat/chat.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ChatForm } from '@/components/chat/chat-form';
|
||||||
|
import { ChatMessages } from '@/components/chat/chat-messages';
|
||||||
|
import { useChat } from '@ai-sdk/react';
|
||||||
|
import type { IServiceOrganization } from '@openpanel/db';
|
||||||
|
import type { UIMessage } from 'ai';
|
||||||
|
import { parseAsBoolean, useQueryState } from 'nuqs';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
const getErrorMessage = (error: Error) => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(error.message);
|
||||||
|
return parsed.message || error.message;
|
||||||
|
} catch (e) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
export default function Chat({
|
||||||
|
initialMessages,
|
||||||
|
projectId,
|
||||||
|
organization,
|
||||||
|
}: {
|
||||||
|
initialMessages?: UIMessage[];
|
||||||
|
projectId: string;
|
||||||
|
organization: IServiceOrganization;
|
||||||
|
}) {
|
||||||
|
const { messages, input, handleInputChange, handleSubmit, status, append } =
|
||||||
|
useChat({
|
||||||
|
onError(error) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
toast.error(message);
|
||||||
|
},
|
||||||
|
api: `${process.env.NEXT_PUBLIC_API_URL}/ai/chat?projectId=${projectId}`,
|
||||||
|
initialMessages: (initialMessages ?? []) as any,
|
||||||
|
fetch: (url, options) => {
|
||||||
|
return fetch(url, {
|
||||||
|
...options,
|
||||||
|
credentials: 'include',
|
||||||
|
mode: 'cors',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [debug, setDebug] = useQueryState(
|
||||||
|
'debug',
|
||||||
|
parseAsBoolean.withDefault(false),
|
||||||
|
);
|
||||||
|
const isLimited = Boolean(
|
||||||
|
messages.length > 5 &&
|
||||||
|
(organization.isCanceled ||
|
||||||
|
organization.isTrial ||
|
||||||
|
organization.isWillBeCanceled ||
|
||||||
|
organization.isExceeded ||
|
||||||
|
organization.isExpired),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-screen w-full col relative">
|
||||||
|
<ChatMessages
|
||||||
|
messages={messages}
|
||||||
|
debug={debug}
|
||||||
|
status={status}
|
||||||
|
isLimited={isLimited}
|
||||||
|
/>
|
||||||
|
<ChatForm
|
||||||
|
handleSubmit={handleSubmit}
|
||||||
|
input={input}
|
||||||
|
handleInputChange={handleInputChange}
|
||||||
|
append={append}
|
||||||
|
projectId={projectId}
|
||||||
|
isLimited={isLimited}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
apps/dashboard/src/components/markdown.tsx
Normal file
26
apps/dashboard/src/components/markdown.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
import ReactMarkdown, { type Options } from 'react-markdown';
|
||||||
|
import rehypeKatex from 'rehype-katex';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
import remarkHighlight from 'remark-highlight';
|
||||||
|
import remarkMath from 'remark-math';
|
||||||
|
import remarkParse from 'remark-parse';
|
||||||
|
import remarkRehype from 'remark-rehype';
|
||||||
|
import 'katex/dist/katex.min.css';
|
||||||
|
|
||||||
|
export const Markdown = memo<Options>(
|
||||||
|
(props) => (
|
||||||
|
<ReactMarkdown
|
||||||
|
{...props}
|
||||||
|
remarkPlugins={[remarkParse, remarkHighlight, remarkMath, remarkGfm]}
|
||||||
|
rehypePlugins={[rehypeKatex, remarkRehype]}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
(prevProps, nextProps) =>
|
||||||
|
prevProps.children === nextProps.children &&
|
||||||
|
'className' in prevProps &&
|
||||||
|
'className' in nextProps &&
|
||||||
|
prevProps.className === nextProps.className,
|
||||||
|
);
|
||||||
|
|
||||||
|
Markdown.displayName = 'Markdown';
|
||||||
@@ -39,6 +39,7 @@ type ReportChartContextProviderProps = ReportChartContextType & {
|
|||||||
|
|
||||||
export type ReportChartProps = Partial<ReportChartContextType> & {
|
export type ReportChartProps = Partial<ReportChartContextType> & {
|
||||||
report: IChartInput;
|
report: IChartInput;
|
||||||
|
lazy?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const context = createContext<ReportChartContextType | null>(null);
|
const context = createContext<ReportChartContextType | null>(null);
|
||||||
|
|||||||
@@ -157,7 +157,11 @@ const { Tooltip, TooltipProvider } = createChartTooltip<
|
|||||||
interval: IInterval;
|
interval: IInterval;
|
||||||
}
|
}
|
||||||
>(({ data, context }) => {
|
>(({ data, context }) => {
|
||||||
const { date } = data[0]!;
|
if (!data[0]) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { date } = data[0];
|
||||||
const formatDate = useFormatDateInterval(context.interval);
|
const formatDate = useFormatDateInterval(context.interval);
|
||||||
const number = useNumber();
|
const number = useNumber();
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { mergeDeepRight } from 'ramda';
|
import { mergeDeepRight } from 'ramda';
|
||||||
import React, { useEffect, useRef } from 'react';
|
import React, { memo, useEffect, useRef } from 'react';
|
||||||
import { useInViewport } from 'react-in-viewport';
|
import { useInViewport } from 'react-in-viewport';
|
||||||
|
|
||||||
|
import { shallowEqual } from 'react-redux';
|
||||||
import { ReportAreaChart } from './area';
|
import { ReportAreaChart } from './area';
|
||||||
import { ReportBarChart } from './bar';
|
import { ReportBarChart } from './bar';
|
||||||
import type { ReportChartProps } from './context';
|
import type { ReportChartProps } from './context';
|
||||||
@@ -17,7 +18,7 @@ import { ReportMetricChart } from './metric';
|
|||||||
import { ReportPieChart } from './pie';
|
import { ReportPieChart } from './pie';
|
||||||
import { ReportRetentionChart } from './retention';
|
import { ReportRetentionChart } from './retention';
|
||||||
|
|
||||||
export function ReportChart(props: ReportChartProps) {
|
export const ReportChart = ({ lazy = true, ...props }: ReportChartProps) => {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const once = useRef(false);
|
const once = useRef(false);
|
||||||
const { inViewport } = useInViewport(ref, undefined, {
|
const { inViewport } = useInViewport(ref, undefined, {
|
||||||
@@ -30,7 +31,7 @@ export function ReportChart(props: ReportChartProps) {
|
|||||||
}
|
}
|
||||||
}, [inViewport]);
|
}, [inViewport]);
|
||||||
|
|
||||||
const loaded = once.current || inViewport;
|
const loaded = lazy ? once.current || inViewport : true;
|
||||||
|
|
||||||
const renderReportChart = () => {
|
const renderReportChart = () => {
|
||||||
switch (props.report.chartType) {
|
switch (props.report.chartType) {
|
||||||
@@ -69,4 +70,4 @@ export function ReportChart(props: ReportChartProps) {
|
|||||||
</ReportChartProvider>
|
</ReportChartProvider>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import { chartTypes } from '@openpanel/constants';
|
import { chartTypes } from '@openpanel/constants';
|
||||||
import { objectToZodEnums } from '@openpanel/validation';
|
import { type IChartType, objectToZodEnums } from '@openpanel/validation';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -32,10 +32,14 @@ import { changeChartType } from './reportSlice';
|
|||||||
|
|
||||||
interface ReportChartTypeProps {
|
interface ReportChartTypeProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
value: IChartType;
|
||||||
|
onChange: (type: IChartType) => void;
|
||||||
}
|
}
|
||||||
export function ReportChartType({ className }: ReportChartTypeProps) {
|
export function ReportChartType({
|
||||||
const dispatch = useDispatch();
|
className,
|
||||||
const type = useSelector((state) => state.report.chartType);
|
value,
|
||||||
|
onChange,
|
||||||
|
}: ReportChartTypeProps) {
|
||||||
const items = objectToZodEnums(chartTypes).map((key) => ({
|
const items = objectToZodEnums(chartTypes).map((key) => ({
|
||||||
label: chartTypes[key],
|
label: chartTypes[key],
|
||||||
value: key,
|
value: key,
|
||||||
@@ -61,10 +65,10 @@ export function ReportChartType({ className }: ReportChartTypeProps) {
|
|||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
icon={Icons[type]}
|
icon={Icons[value]}
|
||||||
className={cn('justify-start', className)}
|
className={cn('justify-start', className)}
|
||||||
>
|
>
|
||||||
{items.find((item) => item.value === type)?.label}
|
{items.find((item) => item.value === value)?.label}
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent className="w-56">
|
<DropdownMenuContent className="w-56">
|
||||||
@@ -77,7 +81,7 @@ export function ReportChartType({ className }: ReportChartTypeProps) {
|
|||||||
return (
|
return (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={item.value}
|
key={item.value}
|
||||||
onClick={() => dispatch(changeChartType(item.value))}
|
onClick={() => onChange(item.value)}
|
||||||
className="group"
|
className="group"
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
|
|||||||
@@ -6,17 +6,36 @@ import {
|
|||||||
isMinuteIntervalEnabledByRange,
|
isMinuteIntervalEnabledByRange,
|
||||||
} from '@openpanel/constants';
|
} from '@openpanel/constants';
|
||||||
|
|
||||||
import { Combobox } from '../ui/combobox';
|
import { cn } from '@/utils/cn';
|
||||||
|
import type { IChartRange, IChartType, IInterval } from '@openpanel/validation';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
|
import { CommandShortcut } from '../ui/command';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '../ui/dropdown-menu';
|
||||||
import { changeInterval } from './reportSlice';
|
import { changeInterval } from './reportSlice';
|
||||||
|
|
||||||
interface ReportIntervalProps {
|
interface ReportIntervalProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
interval: IInterval;
|
||||||
|
onChange: (range: IInterval) => void;
|
||||||
|
chartType: IChartType;
|
||||||
|
range: IChartRange;
|
||||||
}
|
}
|
||||||
export function ReportInterval({ className }: ReportIntervalProps) {
|
export function ReportInterval({
|
||||||
const dispatch = useDispatch();
|
className,
|
||||||
const interval = useSelector((state) => state.report.interval);
|
interval,
|
||||||
const range = useSelector((state) => state.report.range);
|
onChange,
|
||||||
const chartType = useSelector((state) => state.report.chartType);
|
chartType,
|
||||||
|
range,
|
||||||
|
}: ReportIntervalProps) {
|
||||||
if (
|
if (
|
||||||
chartType !== 'linear' &&
|
chartType !== 'linear' &&
|
||||||
chartType !== 'histogram' &&
|
chartType !== 'histogram' &&
|
||||||
@@ -28,16 +47,7 @@ export function ReportInterval({ className }: ReportIntervalProps) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const items = [
|
||||||
<Combobox
|
|
||||||
icon={ClockIcon}
|
|
||||||
className={className}
|
|
||||||
placeholder="Interval"
|
|
||||||
onChange={(value) => {
|
|
||||||
dispatch(changeInterval(value));
|
|
||||||
}}
|
|
||||||
value={interval}
|
|
||||||
items={[
|
|
||||||
{
|
{
|
||||||
value: 'minute',
|
value: 'minute',
|
||||||
label: 'Minute',
|
label: 'Minute',
|
||||||
@@ -55,10 +65,48 @@ export function ReportInterval({ className }: ReportIntervalProps) {
|
|||||||
{
|
{
|
||||||
value: 'month',
|
value: 'month',
|
||||||
label: 'Month',
|
label: 'Month',
|
||||||
disabled:
|
disabled: range === 'today' || range === 'lastHour' || range === '30min',
|
||||||
range === 'today' || range === 'lastHour' || range === '30min',
|
|
||||||
},
|
},
|
||||||
]}
|
];
|
||||||
/>
|
|
||||||
|
const selectedItem = items.find((item) => item.value === interval);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
icon={ClockIcon}
|
||||||
|
className={cn('justify-start', className)}
|
||||||
|
>
|
||||||
|
{items.find((item) => item.value === interval)?.label || 'Interval'}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent className="w-56">
|
||||||
|
<DropdownMenuLabel className="row items-center justify-between">
|
||||||
|
Select interval
|
||||||
|
{!!selectedItem && (
|
||||||
|
<CommandShortcut>{selectedItem?.label}</CommandShortcut>
|
||||||
|
)}
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
{items.map((item) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={item.value}
|
||||||
|
onClick={() => onChange(item.value as IInterval)}
|
||||||
|
disabled={item.disabled}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
{item.value === interval && (
|
||||||
|
<DropdownMenuShortcut>
|
||||||
|
<ClockIcon className="size-4" />
|
||||||
|
</DropdownMenuShortcut>
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,22 +5,26 @@ import { cn } from '@/utils/cn';
|
|||||||
import { CopyIcon } from 'lucide-react';
|
import { CopyIcon } from 'lucide-react';
|
||||||
import { Light as SyntaxHighlighter } from 'react-syntax-highlighter';
|
import { Light as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||||
import bash from 'react-syntax-highlighter/dist/cjs/languages/hljs/bash';
|
import bash from 'react-syntax-highlighter/dist/cjs/languages/hljs/bash';
|
||||||
|
import json from 'react-syntax-highlighter/dist/cjs/languages/hljs/json';
|
||||||
import ts from 'react-syntax-highlighter/dist/cjs/languages/hljs/typescript';
|
import ts from 'react-syntax-highlighter/dist/cjs/languages/hljs/typescript';
|
||||||
import docco from 'react-syntax-highlighter/dist/cjs/styles/hljs/vs2015';
|
import docco from 'react-syntax-highlighter/dist/cjs/styles/hljs/vs2015';
|
||||||
|
|
||||||
SyntaxHighlighter.registerLanguage('typescript', ts);
|
SyntaxHighlighter.registerLanguage('typescript', ts);
|
||||||
|
SyntaxHighlighter.registerLanguage('json', json);
|
||||||
SyntaxHighlighter.registerLanguage('bash', bash);
|
SyntaxHighlighter.registerLanguage('bash', bash);
|
||||||
|
|
||||||
interface SyntaxProps {
|
interface SyntaxProps {
|
||||||
code: string;
|
code: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
language?: 'typescript' | 'bash';
|
language?: 'typescript' | 'bash' | 'json';
|
||||||
|
wrapLines?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Syntax({
|
export default function Syntax({
|
||||||
code,
|
code,
|
||||||
className,
|
className,
|
||||||
language = 'typescript',
|
language = 'typescript',
|
||||||
|
wrapLines = false,
|
||||||
}: SyntaxProps) {
|
}: SyntaxProps) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('group relative rounded-lg', className)}>
|
<div className={cn('group relative rounded-lg', className)}>
|
||||||
@@ -35,7 +39,7 @@ export default function Syntax({
|
|||||||
<CopyIcon size={12} />
|
<CopyIcon size={12} />
|
||||||
</button>
|
</button>
|
||||||
<SyntaxHighlighter
|
<SyntaxHighlighter
|
||||||
// wrapLongLines
|
wrapLongLines={wrapLines}
|
||||||
style={docco}
|
style={docco}
|
||||||
language={language}
|
language={language}
|
||||||
customStyle={{
|
customStyle={{
|
||||||
|
|||||||
@@ -6,17 +6,21 @@ import * as React from 'react';
|
|||||||
|
|
||||||
const ScrollArea = React.forwardRef<
|
const ScrollArea = React.forwardRef<
|
||||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> & {
|
||||||
>(({ className, children, ...props }, ref) => (
|
orientation?: 'vertical' | 'horizontal';
|
||||||
|
}
|
||||||
|
>(({ className, children, orientation = 'vertical', ...props }, ref) => (
|
||||||
<ScrollAreaPrimitive.Root
|
<ScrollAreaPrimitive.Root
|
||||||
ref={ref}
|
|
||||||
className={cn('relative overflow-hidden', className)}
|
className={cn('relative overflow-hidden', className)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
<ScrollAreaPrimitive.Viewport
|
||||||
|
className="h-full w-full rounded-[inherit]"
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</ScrollAreaPrimitive.Viewport>
|
</ScrollAreaPrimitive.Viewport>
|
||||||
<ScrollBar />
|
<ScrollBar orientation={orientation} />
|
||||||
<ScrollAreaPrimitive.Corner />
|
<ScrollAreaPrimitive.Corner />
|
||||||
</ScrollAreaPrimitive.Root>
|
</ScrollAreaPrimitive.Root>
|
||||||
));
|
));
|
||||||
|
|||||||
82
apps/dashboard/src/hooks/use-scroll-anchor.ts
Normal file
82
apps/dashboard/src/hooks/use-scroll-anchor.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
export const useScrollAnchor = () => {
|
||||||
|
const messagesRef = useRef<HTMLDivElement>(null);
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const visibilityRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
|
const scrollToBottom = useCallback(() => {
|
||||||
|
if (messagesRef.current) {
|
||||||
|
messagesRef.current.scrollIntoView({
|
||||||
|
block: 'end',
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (messagesRef.current) {
|
||||||
|
if (isAtBottom && !isVisible) {
|
||||||
|
messagesRef.current.scrollIntoView({
|
||||||
|
block: 'end',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isAtBottom, isVisible]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const { current } = scrollRef;
|
||||||
|
|
||||||
|
if (current) {
|
||||||
|
const handleScroll = (event: Event) => {
|
||||||
|
const target = event.target as HTMLDivElement;
|
||||||
|
const offset = 20;
|
||||||
|
const isAtBottom =
|
||||||
|
target.scrollTop + target.clientHeight >=
|
||||||
|
target.scrollHeight - offset;
|
||||||
|
|
||||||
|
setIsAtBottom(isAtBottom);
|
||||||
|
};
|
||||||
|
|
||||||
|
current.addEventListener('scroll', handleScroll, {
|
||||||
|
passive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
current.removeEventListener('scroll', handleScroll);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visibilityRef.current) {
|
||||||
|
const observer = new IntersectionObserver((entries) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
setIsVisible(true);
|
||||||
|
} else {
|
||||||
|
setIsVisible(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(visibilityRef.current);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
messagesRef,
|
||||||
|
scrollRef,
|
||||||
|
visibilityRef,
|
||||||
|
scrollToBottom,
|
||||||
|
isAtBottom,
|
||||||
|
isVisible,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -18,7 +18,7 @@ import { ModalContent, ModalHeader } from './Modal/Container';
|
|||||||
|
|
||||||
type SaveReportProps = {
|
type SaveReportProps = {
|
||||||
report: IChartProps;
|
report: IChartProps;
|
||||||
reportId?: string;
|
disableRedirect?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const validator = z.object({
|
const validator = z.object({
|
||||||
@@ -28,7 +28,10 @@ const validator = z.object({
|
|||||||
|
|
||||||
type IForm = z.infer<typeof validator>;
|
type IForm = z.infer<typeof validator>;
|
||||||
|
|
||||||
export default function SaveReport({ report }: SaveReportProps) {
|
export default function SaveReport({
|
||||||
|
report,
|
||||||
|
disableRedirect,
|
||||||
|
}: SaveReportProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { organizationId, projectId } = useAppParams();
|
const { organizationId, projectId } = useAppParams();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
@@ -37,15 +40,27 @@ export default function SaveReport({ report }: SaveReportProps) {
|
|||||||
const save = api.report.create.useMutation({
|
const save = api.report.create.useMutation({
|
||||||
onError: handleError,
|
onError: handleError,
|
||||||
onSuccess(res) {
|
onSuccess(res) {
|
||||||
toast('Success', {
|
const goToReport = () => {
|
||||||
description: 'Report saved.',
|
|
||||||
});
|
|
||||||
popModal();
|
|
||||||
router.push(
|
router.push(
|
||||||
`/${organizationId}/${projectId}/reports/${
|
`/${organizationId}/${projectId}/reports/${
|
||||||
res.id
|
res.id
|
||||||
}?${searchParams?.toString()}`,
|
}?${searchParams?.toString()}`,
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
toast('Report created', {
|
||||||
|
description: <div>Hello world</div>,
|
||||||
|
action: {
|
||||||
|
label: 'View report',
|
||||||
|
onClick: () => goToReport(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!disableRedirect) {
|
||||||
|
goToReport();
|
||||||
|
}
|
||||||
|
|
||||||
|
popModal();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
"rehype-external-links": "3.0.0",
|
"rehype-external-links": "3.0.0",
|
||||||
"tailwind-merge": "1.14.0",
|
"tailwind-merge": "1.14.0",
|
||||||
"tailwindcss-animate": "1.0.7",
|
"tailwindcss-animate": "1.0.7",
|
||||||
"zod": "^3.22.4"
|
"zod": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/mdx": "^2.0.13",
|
"@types/mdx": "^2.0.13",
|
||||||
|
|||||||
@@ -34,9 +34,6 @@
|
|||||||
"typescript": "^5.2.2",
|
"typescript": "^5.2.2",
|
||||||
"winston": "^3.14.2"
|
"winston": "^3.14.2"
|
||||||
},
|
},
|
||||||
"resolutions": {
|
|
||||||
"zod": "3.22.4"
|
|
||||||
},
|
|
||||||
"trustedDependencies": [
|
"trustedDependencies": [
|
||||||
"@biomejs/biome",
|
"@biomejs/biome",
|
||||||
"@prisma/client",
|
"@prisma/client",
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
"p-limit": "^6.1.0",
|
"p-limit": "^6.1.0",
|
||||||
"progress": "^2.0.3",
|
"progress": "^2.0.3",
|
||||||
"ramda": "^0.29.1",
|
"ramda": "^0.29.1",
|
||||||
"zod": "^3.22.4"
|
"zod": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@openpanel/db": "workspace:^",
|
"@openpanel/db": "workspace:^",
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
"sqlstring": "^2.3.3",
|
"sqlstring": "^2.3.3",
|
||||||
"superjson": "^1.13.3",
|
"superjson": "^1.13.3",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
"zod": "^3.22.4"
|
"zod": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@openpanel/tsconfig": "workspace:*",
|
"@openpanel/tsconfig": "workspace:*",
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "chats" (
|
||||||
|
"id" TEXT NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
"messages" JSONB NOT NULL,
|
||||||
|
"projectId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "chats_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "chats" ADD CONSTRAINT "chats_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -30,6 +30,17 @@ enum ProjectType {
|
|||||||
backend
|
backend
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Chat {
|
||||||
|
id String @id @default(dbgenerated("gen_random_uuid()"))
|
||||||
|
messages Json
|
||||||
|
projectId String
|
||||||
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
|
|
||||||
|
@@map("chats")
|
||||||
|
}
|
||||||
|
|
||||||
model Organization {
|
model Organization {
|
||||||
id String @id @default(dbgenerated("gen_random_uuid()"))
|
id String @id @default(dbgenerated("gen_random_uuid()"))
|
||||||
name String
|
name String
|
||||||
@@ -184,6 +195,7 @@ model Project {
|
|||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @default(now()) @updatedAt
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
|
Chat Chat[]
|
||||||
|
|
||||||
@@map("projects")
|
@@map("projects")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"resend": "^4.0.1",
|
"resend": "^4.0.1",
|
||||||
"responsive-react-email": "^0.0.5",
|
"responsive-react-email": "^0.0.5",
|
||||||
"zod": "^3.22.4"
|
"zod": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@openpanel/tsconfig": "workspace:*",
|
"@openpanel/tsconfig": "workspace:*",
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
"sqlstring": "^2.3.3",
|
"sqlstring": "^2.3.3",
|
||||||
"superjson": "^1.13.3",
|
"superjson": "^1.13.3",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
"zod": "^3.22.4"
|
"zod": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@openpanel/tsconfig": "workspace:*",
|
"@openpanel/tsconfig": "workspace:*",
|
||||||
|
|||||||
@@ -28,7 +28,12 @@ import {
|
|||||||
} from 'date-fns';
|
} from 'date-fns';
|
||||||
import { getProjectAccessCached } from '../access';
|
import { getProjectAccessCached } from '../access';
|
||||||
import { TRPCAccessError } from '../errors';
|
import { TRPCAccessError } from '../errors';
|
||||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc';
|
import {
|
||||||
|
cacheMiddleware,
|
||||||
|
createTRPCRouter,
|
||||||
|
protectedProcedure,
|
||||||
|
publicProcedure,
|
||||||
|
} from '../trpc';
|
||||||
import {
|
import {
|
||||||
getChart,
|
getChart,
|
||||||
getChartPrevStartEndDate,
|
getChartPrevStartEndDate,
|
||||||
@@ -42,6 +47,8 @@ function utc(date: string | Date) {
|
|||||||
return formatISO(date).replace('T', ' ').slice(0, 19);
|
return formatISO(date).replace('T', ' ').slice(0, 19);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cacher = cacheMiddleware(60);
|
||||||
|
|
||||||
export const chartRouter = createTRPCRouter({
|
export const chartRouter = createTRPCRouter({
|
||||||
events: protectedProcedure
|
events: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
@@ -220,7 +227,10 @@ export const chartRouter = createTRPCRouter({
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
chart: publicProcedure.input(zChartInput).query(async ({ input, ctx }) => {
|
chart: publicProcedure
|
||||||
|
.use(cacher)
|
||||||
|
.input(zChartInput)
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
if (ctx.session.userId) {
|
if (ctx.session.userId) {
|
||||||
const access = await getProjectAccessCached({
|
const access = await getProjectAccessCached({
|
||||||
projectId: input.projectId,
|
projectId: input.projectId,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@openpanel/constants": "workspace:*",
|
"@openpanel/constants": "workspace:*",
|
||||||
"zod": "^3.22.4"
|
"zod": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@openpanel/tsconfig": "workspace:*",
|
"@openpanel/tsconfig": "workspace:*",
|
||||||
|
|||||||
@@ -19,18 +19,34 @@ export function objectToZodEnums<K extends string>(
|
|||||||
export const mapKeys = objectToZodEnums;
|
export const mapKeys = objectToZodEnums;
|
||||||
|
|
||||||
export const zChartEventFilter = z.object({
|
export const zChartEventFilter = z.object({
|
||||||
id: z.string().optional(),
|
id: z.string().optional().describe('Unique identifier for the filter'),
|
||||||
name: z.string(),
|
name: z.string().describe('The property name to filter on'),
|
||||||
operator: z.enum(objectToZodEnums(operators)),
|
operator: z
|
||||||
value: z.array(z.string().or(z.number()).or(z.boolean()).or(z.null())),
|
.enum(objectToZodEnums(operators))
|
||||||
|
.describe('The operator to use for the filter'),
|
||||||
|
value: z
|
||||||
|
.array(z.string().or(z.number()).or(z.boolean()).or(z.null()))
|
||||||
|
.describe('The values to filter on'),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const zChartEvent = z.object({
|
export const zChartEvent = z.object({
|
||||||
id: z.string().optional(),
|
id: z
|
||||||
name: z.string(),
|
.string()
|
||||||
displayName: z.string().optional(),
|
.optional()
|
||||||
property: z.string().optional(),
|
.describe('Unique identifier for the chart event configuration'),
|
||||||
segment: z.enum([
|
name: z.string().describe('The name of the event as tracked in the system'),
|
||||||
|
displayName: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('A user-friendly name for display purposes'),
|
||||||
|
property: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
'Optional property of the event used for specific segment calculations (e.g., value for property_sum/average)',
|
||||||
|
),
|
||||||
|
segment: z
|
||||||
|
.enum([
|
||||||
'event',
|
'event',
|
||||||
'user',
|
'user',
|
||||||
'session',
|
'session',
|
||||||
@@ -38,8 +54,13 @@ export const zChartEvent = z.object({
|
|||||||
'one_event_per_user',
|
'one_event_per_user',
|
||||||
'property_sum',
|
'property_sum',
|
||||||
'property_average',
|
'property_average',
|
||||||
]),
|
])
|
||||||
filters: z.array(zChartEventFilter).default([]),
|
.default('event')
|
||||||
|
.describe('Defines how the event data should be segmented or aggregated'),
|
||||||
|
filters: z
|
||||||
|
.array(zChartEventFilter)
|
||||||
|
.default([])
|
||||||
|
.describe('Filters applied specifically to this event'),
|
||||||
});
|
});
|
||||||
export const zChartBreakdown = z.object({
|
export const zChartBreakdown = z.object({
|
||||||
id: z.string().optional(),
|
id: z.string().optional(),
|
||||||
@@ -62,28 +83,93 @@ export const zRange = z.enum(objectToZodEnums(timeWindows));
|
|||||||
export const zCriteria = z.enum(['on_or_after', 'on']);
|
export const zCriteria = z.enum(['on_or_after', 'on']);
|
||||||
|
|
||||||
export const zChartInput = z.object({
|
export const zChartInput = z.object({
|
||||||
chartType: zChartType.default('linear'),
|
chartType: zChartType
|
||||||
interval: zTimeInterval.default('day'),
|
.default('linear')
|
||||||
events: zChartEvents,
|
.describe('What type of chart should be displayed'),
|
||||||
breakdowns: zChartBreakdowns.default([]),
|
interval: zTimeInterval
|
||||||
range: zRange.default('30d'),
|
.default('day')
|
||||||
previous: z.boolean().default(false),
|
.describe(
|
||||||
formula: z.string().optional(),
|
'The time interval for data aggregation (e.g., day, week, month)',
|
||||||
metric: zMetric.default('sum'),
|
),
|
||||||
projectId: z.string(),
|
events: zChartEvents.describe(
|
||||||
startDate: z.string().nullish(),
|
'Array of events to be tracked and displayed in the chart',
|
||||||
endDate: z.string().nullish(),
|
),
|
||||||
limit: z.number().optional(),
|
breakdowns: zChartBreakdowns
|
||||||
offset: z.number().optional(),
|
.default([])
|
||||||
criteria: zCriteria.optional(),
|
.describe('Array of dimensions to break down the data by'),
|
||||||
funnelGroup: z.string().optional(),
|
range: zRange
|
||||||
funnelWindow: z.number().optional(),
|
.default('30d')
|
||||||
|
.describe('The time range for which data should be displayed'),
|
||||||
|
previous: z
|
||||||
|
.boolean()
|
||||||
|
.default(false)
|
||||||
|
.describe('Whether to show data from the previous period for comparison'),
|
||||||
|
formula: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Custom formula for calculating derived metrics'),
|
||||||
|
metric: zMetric
|
||||||
|
.default('sum')
|
||||||
|
.describe(
|
||||||
|
'The aggregation method for the metric (e.g., sum, count, average)',
|
||||||
|
),
|
||||||
|
projectId: z.string().describe('The ID of the project this chart belongs to'),
|
||||||
|
startDate: z
|
||||||
|
.string()
|
||||||
|
.nullish()
|
||||||
|
.describe(
|
||||||
|
'Custom start date for the data range (overrides range if provided)',
|
||||||
|
),
|
||||||
|
endDate: z
|
||||||
|
.string()
|
||||||
|
.nullish()
|
||||||
|
.describe(
|
||||||
|
'Custom end date for the data range (overrides range if provided)',
|
||||||
|
),
|
||||||
|
limit: z
|
||||||
|
.number()
|
||||||
|
.optional()
|
||||||
|
.describe('Limit how many series should be returned'),
|
||||||
|
offset: z
|
||||||
|
.number()
|
||||||
|
.optional()
|
||||||
|
.describe('Skip how many series should be returned'),
|
||||||
|
criteria: zCriteria
|
||||||
|
.optional()
|
||||||
|
.describe('Filtering criteria for retention chart (e.g., on_or_after, on)'),
|
||||||
|
funnelGroup: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
'Group identifier for funnel analysis, e.g. "profile_id" or "session_id"',
|
||||||
|
),
|
||||||
|
funnelWindow: z
|
||||||
|
.number()
|
||||||
|
.optional()
|
||||||
|
.describe('Time window in hours for funnel analysis'),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const zReportInput = zChartInput.extend({
|
export const zReportInput = zChartInput.extend({
|
||||||
name: z.string(),
|
name: z.string().describe('The user-defined name for the report'),
|
||||||
lineType: zLineType,
|
lineType: zLineType.describe('The visual style of the line in the chart'),
|
||||||
unit: z.string().optional(),
|
unit: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Optional unit of measurement for the chart's Y-axis (e.g., $, %, users)",
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const zChartInputAI = zReportInput
|
||||||
|
.omit({
|
||||||
|
startDate: true,
|
||||||
|
endDate: true,
|
||||||
|
lineType: true,
|
||||||
|
unit: true,
|
||||||
|
})
|
||||||
|
.extend({
|
||||||
|
startDate: z.string().describe('The start date for the report'),
|
||||||
|
endDate: z.string().describe('The end date for the report'),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const zInviteUser = z.object({
|
export const zInviteUser = z.object({
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
zChartBreakdown,
|
zChartBreakdown,
|
||||||
zChartEvent,
|
zChartEvent,
|
||||||
zChartInput,
|
zChartInput,
|
||||||
|
zChartInputAI,
|
||||||
zChartType,
|
zChartType,
|
||||||
zCriteria,
|
zCriteria,
|
||||||
zLineType,
|
zLineType,
|
||||||
@@ -14,6 +15,7 @@ import type {
|
|||||||
} from './index';
|
} from './index';
|
||||||
|
|
||||||
export type IChartInput = z.infer<typeof zChartInput>;
|
export type IChartInput = z.infer<typeof zChartInput>;
|
||||||
|
export type IChartInputAi = z.infer<typeof zChartInputAI>;
|
||||||
export type IChartProps = z.infer<typeof zReportInput> & {
|
export type IChartProps = z.infer<typeof zReportInput> & {
|
||||||
name: string;
|
name: string;
|
||||||
lineType: IChartLineType;
|
lineType: IChartLineType;
|
||||||
|
|||||||
2068
pnpm-lock.yaml
generated
2068
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,8 @@
|
|||||||
packages:
|
packages:
|
||||||
- 'apps/*'
|
- "apps/*"
|
||||||
- 'packages/**'
|
- "packages/**"
|
||||||
- 'tooling/*'
|
- "tooling/*"
|
||||||
|
|
||||||
|
# Define a catalog of version ranges.
|
||||||
|
catalog:
|
||||||
|
zod: ^3.24.2
|
||||||
|
|||||||
Reference in New Issue
Block a user