feat(ai): add ai chat to dashboard

This commit is contained in:
Carl-Gerhard Lindesvärd
2025-04-15 14:30:21 +02:00
parent 804a9c8056
commit 34769a5d58
46 changed files with 2624 additions and 1449 deletions

View 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
View 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":[]}
\`\`\`
`;
};

View File

@@ -13,3 +13,27 @@ export class LogError extends Error {
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);
}
}

View File

@@ -1,14 +1,16 @@
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,
max,
timeWindow,
keyGenerator,
}: {
fastify: FastifyInstance;
max: number;
timeWindow?: string;
keyGenerator?: (req: T) => string | undefined;
}) {
await fastify.register(import('@fastify/rate-limit'), {
max,
@@ -22,6 +24,12 @@ export async function activateRateLimiter({
},
redis: getRedisCache(),
keyGenerator(req) {
if (keyGenerator) {
const key = keyGenerator(req as T);
if (key) {
return key;
}
}
return (req.headers['openpanel-client-id'] ||
req.headers['x-real-ip'] ||
req.headers['x-client-ip'] ||