new selfhosted version

This commit is contained in:
antonio
2025-11-28 14:11:51 +01:00
parent afda29997d
commit 951860f67e
1046 changed files with 72586 additions and 574750 deletions

View File

@@ -0,0 +1,106 @@
import OpenAI from "openai";
import { AiNewChatModel } from "~/shared/schema/ai/AiNewChatSchema";
import { Types } from 'mongoose';
import { Agent } from "./entities/Agent";
import { InsightAgent } from "./entities/InsightAgent";
export const OPENAI_MODEL: OpenAI.Chat.ChatModel = 'gpt-4o-mini';
export type AiMessage =
OpenAI.Chat.Completions.ChatCompletionMessage |
OpenAI.Chat.Completions.ChatCompletionMessageParam |
OpenAI.Chat.Completions.ChatCompletionAssistantMessageParam |
OpenAI.Chat.Completions.ChatCompletionDeveloperMessageParam;
export type AiTool<T extends string = any> =
OpenAI.Chat.Completions.ChatCompletionTool
& { function: { name: T } }
export type AiPlugin<T extends string = any> = {
name: T,
handler: (...args: any[]) => any,
tool: AiTool<T>
}
export type AiHandleUserMessageOptions = {
pid: string,
text: string,
name: string,
chat_id?: string
}
export class AiService {
private static openai: OpenAI;
static init() {
if (this.openai) return this.openai;
const { AI_KEY, AI_ORG, AI_PROJECT } = useRuntimeConfig();
const openai = new OpenAI({ apiKey: AI_KEY, organization: AI_ORG, project: AI_PROJECT });
this.openai = openai;
return openai;
}
static async generateInsight(pid: string) {
const timestamp = ['month', 'week'];
const data = ['visits', 'referrers', 'browsers', 'devices'];
const agent = new InsightAgent(pid);
const dataType = data[Math.floor(Math.random() * data.length)];
const timeFrame = timestamp[Math.floor(Math.random() * timestamp.length)];
const PROMPT = 'Give me one concise, anomaly-focused insight on [DATA_TYPE] for last [TIME_FRAME], compared to 2 [TIME_FRAME] ago. Respond with only the single insight, in plain text, no fluff, no emojis, no extra wording..'
.replace('[DATA_TYPE]', dataType)
.replace('[TIME_FRAME]', timeFrame)
.replace('[TIME_FRAME]', timeFrame);
const res = await agent.reply(PROMPT, []);
return res.at(-1)?.content ?? 'ERROR_GENERATING_INSIGHT';
}
static async handleUserMessage(options: AiHandleUserMessageOptions) {
let chat;
try {
if (options.chat_id) {
const chatUUID = new Types.ObjectId(options.chat_id);
chat = await AiNewChatModel.findOne({ _id: chatUUID });
}
} catch (ex) {
}
let currentMessages: AiMessage[] = [];
if (!chat) {
chat = await AiNewChatModel.create({
title: options.text.substring(0, 60),
status: 'PROCESSING',
messages: [],
deleted: false,
project_id: options.pid
});
} else {
if (!chat.status.startsWith('COMPLETED') && !chat.status.startsWith('ERRORED')) return;
if (chat.messages.length >= 100) {
await AiNewChatModel.updateOne({ _id: chat._id }, { status: 'ERRORED' });
return chat._id.toString();
}
currentMessages.push(...chat.messages);
}
const agent = new Agent({ documentId: chat._id.toString(), pid: options.pid, userName: options.name });
agent.reply(options.text, currentMessages);
return chat._id.toString();
}
}

View File

@@ -0,0 +1,52 @@
import * as fns from 'date-fns'
import dateServiceInstance, { Slice } from '~/shared/services/DateService';
export type AiToolTyped<FunctionName extends string, Args extends string[]> = {
type: 'function',
function: {
name: FunctionName,
description?: string,
parameters: {
type: string,
required: Args[number][],
properties: {
[K in Args[number]]: {
type: string,
description: string,
items?: any
}
}
},
strict?: boolean | null
}
}
export function getFirstAvailableSliceFromDates(from: string, to: string) {
const fromTime = new Date(from).getTime();
const toTime = new Date(to).getTime() + 1000;
const days = fns.differenceInDays(toTime, fromTime);
const keys = Object.keys(dateServiceInstance.sliceAvailabilityMap);
const targetKey = keys.find((key: any) => {
const data = ((dateServiceInstance.sliceAvailabilityMap as any)[key]) as [number, number];
return days > data[0] && days < data[1];
});
return targetKey as Slice;
}
type AiPluginHandlerData<Args extends string[]> = { project_id: string } & { [K in Args[number]]: any }
export class AiPlugin<Name extends string = any, Args extends string[] = any> {
constructor(
public name: Name,
public tool: AiToolTyped<Name, Args>,
public handler: (data: AiPluginHandlerData<Args>) => any
) { }
}

View File

@@ -0,0 +1,122 @@
import { AiMessage, AiPlugin, AiService, OPENAI_MODEL } from "../AiService";
import { AiNewChatModel } from "~/shared/schema/ai/AiNewChatSchema";
import { visitsPlugins } from "../plugins/VisitsPlugins";
import { sessionsPlugins } from "../plugins/SessionsPlugin";
import { dataPlugins } from "../plugins/DataPlugin";
import { chartPlugins } from "../plugins/ChartPlugin";
import { utmDataPlugins } from "../plugins/UTMData";
import { bouncingRatePlugins } from "../plugins/BouncingRatePlugin";
export const AI_PLUGINS: AiPlugin[] = [
...visitsPlugins,
...sessionsPlugins,
...dataPlugins,
// ...chartPlugins,
...utmDataPlugins,
...bouncingRatePlugins
];
const DEFAULT_PROMPT = `You are an AI analytics agent that transforms structured data from function calls into clear, growth-focused insights, acting like a startup growth analyst explaining results to a founder. You analyze data on visitors, page views, sessions, bounce rates, session duration, and traffic sources, highlighting trends, anomalies, and comparisons in plain language. You identify growth opportunities, funnel bottlenecks, user behavior patterns, and friction points, and suggest concrete, high-impact experiments, quick wins, or growth loops based strictly on the data. Your style is concise, actionable, and easy to understand; you are creative in insights but never in data, you avoid generic advice, and you tailor every suggestion to the dataset provided. Keep initial answers brief unless the user explicitly requests deeper detail, and always end with exactly one specific follow-up question in this format: “Would you like me to analyze [specific aspect]?” Stay strictly within the domain of website and product analytics, respond honestly if something goes beyond your scope, and always prioritize clarity, ROI, and relevance in every response.`
export type AgentConstructorOptions = {
userName: string,
pid: string,
documentId: string
}
export class Agent {
constructor(private options: AgentConstructorOptions) { }
async onStartThinking() {
await AiNewChatModel.updateOne({ _id: this.options.documentId }, { status: `THINKING:Agent` });
}
async onStartFunctionCall() {
await AiNewChatModel.updateOne({ _id: this.options.documentId }, { status: `FUNCTION:Agent` });
}
async onChatCompleted() {
await AiNewChatModel.updateOne({ _id: this.options.documentId }, { status: `COMPLETED` });
}
async onChatErrored(error: string) {
await AiNewChatModel.updateOne({ _id: this.options.documentId }, { status: `ERRORED` });
}
async onNewMessage(message: AiMessage) {
if (message.role === 'system') return;
const messageWithDate = { ...message, created_at: new Date() }
await AiNewChatModel.updateOne({ _id: this.options.documentId }, {
$push: { messages: messageWithDate }
});
}
async processTools(message: AiMessage, chat: AiMessage[]) {
if (message.role != 'assistant') return;
const tool_calls = message.tool_calls;
if (!tool_calls) return;
for (const toolCall of tool_calls) {
const functionName = toolCall.function.name;
const targetFunction = AI_PLUGINS.find(e => e.name === functionName);
if (!targetFunction) return;
const args = JSON.parse(toolCall.function.arguments);
const result = await targetFunction.handler({ ...args, project_id: this.options.pid });
const message: AiMessage = { role: 'tool', tool_call_id: toolCall.id, content: JSON.stringify(result) };
chat.push(message)
await this.onNewMessage(message);
}
}
async reply(userText: string, chat: AiMessage[]) {
chat.push({ role: 'system', content: `Current iso date is: ${new Date().toISOString()}` });
const user_message: AiMessage = {
role: 'user',
content: userText,
name: this.options.userName
};
chat.push(user_message);
await this.onNewMessage(user_message);
await this.onStartThinking();
chat.push({ role: 'system', content: DEFAULT_PROMPT + '. Reply in MD format if possible. Try to make the response short. Do not suggest analytics tools that are not Litlyx.' });
const openai = await AiService.init();
const response = await openai.chat.completions.create({
model: OPENAI_MODEL,
messages: chat,
tools: AI_PLUGINS.map(e => e.tool)
});
const choice = response.choices[0];
if (choice.finish_reason === 'tool_calls') {
await this.onStartFunctionCall();
const chatMessage: AiMessage = { ...choice.message, name: 'Agent' };
chat.push(chatMessage);
await this.onNewMessage(chatMessage);
await this.processTools(chatMessage, chat);
await this.onStartThinking();
const afterToolResponse = await openai.chat.completions.create({ model: OPENAI_MODEL, messages: chat });
const afterToolChatMessage: AiMessage = { ...afterToolResponse.choices[0].message, name: 'Agent' };
chat.push(afterToolChatMessage);
await this.onNewMessage(afterToolChatMessage);
} else {
const chatMessage: AiMessage = { ...choice.message, name: 'Agent' };
chat.push(chatMessage);
await this.onNewMessage(chatMessage);
}
await this.onChatCompleted();
}
}

View File

@@ -0,0 +1,74 @@
import { AiMessage, AiPlugin, AiService, OPENAI_MODEL } from "../AiService";
import { visitsPlugins } from "../plugins/VisitsPlugins";
import { sessionsPlugins } from "../plugins/SessionsPlugin";
import { dataPlugins } from "../plugins/DataPlugin";
import { utmDataPlugins } from "../plugins/UTMData";
import { bouncingRatePlugins } from "../plugins/BouncingRatePlugin";
export const AI_PLUGINS: AiPlugin[] = [
...visitsPlugins,
...sessionsPlugins,
...dataPlugins,
...utmDataPlugins,
...bouncingRatePlugins
];
const DEFAULT_PROMPT = `No fluff, no emojis, no extra words. Compare with the previous period if possible and output only the single insight.`
export class InsightAgent {
constructor(private pid: string) { }
async processTools(message: AiMessage, chat: AiMessage[]) {
if (message.role != 'assistant') return;
const tool_calls = message.tool_calls;
if (!tool_calls) return;
for (const toolCall of tool_calls) {
const functionName = toolCall.function.name;
const targetFunction = AI_PLUGINS.find(e => e.name === functionName);
if (!targetFunction) return;
const args = JSON.parse(toolCall.function.arguments);
const result = await targetFunction.handler({ ...args, project_id: this.pid });
const message: AiMessage = { role: 'tool', tool_call_id: toolCall.id, content: JSON.stringify(result) };
chat.push(message)
}
}
async reply(userText: string, chat: AiMessage[]) {
chat.push({ role: 'system', content: `Current iso date is: ${new Date().toISOString()}` });
const user_message: AiMessage = {
role: 'user',
content: userText,
};
chat.push(user_message);
chat.push({ role: 'system', content: DEFAULT_PROMPT });
const openai = await AiService.init();
const response = await openai.chat.completions.create({
model: OPENAI_MODEL,
messages: chat,
tools: AI_PLUGINS.map(e => e.tool)
});
const choice = response.choices[0];
if (choice.finish_reason === 'tool_calls') {
const chatMessage: AiMessage = { ...choice.message, name: 'Agent' };
chat.push(chatMessage);
await this.processTools(chatMessage, chat);
const afterToolResponse = await openai.chat.completions.create({ model: OPENAI_MODEL, messages: chat });
const afterToolChatMessage: AiMessage = { ...afterToolResponse.choices[0].message, name: 'Agent' };
chat.push(afterToolChatMessage);
} else {
const chatMessage: AiMessage = { ...choice.message, name: 'Agent' };
chat.push(chatMessage);
}
return chat;
}
}

View File

@@ -0,0 +1,153 @@
import { Types } from "mongoose";
import { AiPlugin, getFirstAvailableSliceFromDates } from "../Plugin";
import { VisitModel } from "~/shared/schema/metrics/VisitSchema";
import { prepareTimelineAggregation } from "../../TimelineService";
const getBouncingRatePlugin = new AiPlugin<'getBouncingRate', ['from', 'to', 'domain', 'limit']>('getBouncingRate',
{
type: 'function',
function: {
name: 'getBouncingRate',
description: 'Gets an array of bouncing rate in the user website on a date range.',
parameters: {
type: 'object',
properties: {
from: { type: 'string', description: 'ISO string of start date' },
to: { type: 'string', description: 'ISO string of end date' },
domain: { type: 'string', description: 'Used only to filter a specific webdomain/website' },
limit: { type: 'number', description: 'Max number of items to return' }
},
required: ['from', 'to']
}
}
},
async (data) => {
const info = prepareTimelineAggregation({
model: VisitModel,
projectId: new Types.ObjectId(data.project_id),
from: new Date(data.from).getTime(),
to: new Date(data.to).getTime(),
slice: getFirstAvailableSliceFromDates(data.from, data.to),
domain: data.domain,
});
const aggregation = [
{
$match: {
project_id: new Types.ObjectId(data.project_id),
created_at: {
$gte: new Date(data.from),
$lte: new Date(data.to)
},
...info.domainMatch,
}
},
{
$project: {
created_at: 1, session: 1
}
},
{
$addFields: {
date: {
$dateTrunc: {
date: "$created_at",
unit: info.granularity,
timezone: "UTC"
}
}
}
},
{
$group: {
_id: {
date: "$date",
session: "$session"
},
pageViews: {
$sum: 1
}
}
},
{
$group: {
_id: {
date: "$_id.date"
},
totalSessions: {
$sum: 1
},
bouncedSessions: {
$sum: { $cond: [{ $eq: ["$pageViews", 1] }, 1, 0] }
}
}
},
{
$project: {
_id: 1,
totalSessions: 1,
bouncedSessions: 1,
bounceRate: {
$cond: [{ $eq: ["$totalSessions", 0] }, 0,
{
$multiply: [
{
$divide: [
"$bouncedSessions",
"$totalSessions"
]
},
100
]
}
]
}
}
},
{
$densify: {
field: "_id.date",
range: {
step: 1,
unit: info.granularity,
bounds: [
info.from,
info.to
]
}
}
},
{
$addFields: {
timestamp: {
$toLong: "$_id.date"
}
}
},
{
$set: {
count: {
$ifNull: ["$bounceRate", 0]
}
}
},
{
$sort: {
"_id.date": 1
}
},
{
$project: { _id: 1, count: 1, timestamp: 1 }
}
] as any[];
const result = await VisitModel.aggregate(aggregation, { allowDiskUse: true });
return result;
}
);
export const bouncingRatePlugins = [
getBouncingRatePlugin
]

View File

@@ -0,0 +1,63 @@
import { AiPlugin } from "../Plugin";
const createChartPlugin = new AiPlugin<'createChart', ['labels', 'title', 'datasets']>('createChart',
{
type: 'function',
function: {
name: 'createChart',
description: 'Creates a chart based on the provided datasets. Used to show a visual rappresentation of data to the user.',
parameters: {
type: 'object',
properties: {
labels: {
type: 'array',
items: { type: 'string' },
description: 'Labels for each data point in the chart'
},
title: {
type: 'string',
description: 'Title of the chart to let user understand what is displaying, not include dates'
},
datasets: {
type: 'array',
description: 'List of datasets',
items: {
type: 'object',
properties: {
chartType: {
type: 'string',
enum: ['line', 'bar'],
description: 'The type of chart to display the dataset, either "line" or "bar"'
},
points: {
type: 'array',
items: { type: 'number' },
description: 'Numerical values for each data point in the chart'
},
color: {
type: 'string',
description: 'Color used to represent the dataset in format "#RRGGBB"'
},
name: {
type: 'string',
description: 'Name of the dataset'
}
},
required: ['points', 'chartType', 'name'],
description: 'Data points and style information for the dataset'
}
}
},
required: ['labels', 'datasets', 'title']
}
}
},
async (data) => {
return { ok: true, chart: true }
}
);
export const chartPlugins = [
createChartPlugin
]

View File

@@ -0,0 +1,253 @@
import { Types } from "mongoose";
import { AiPlugin } from "../Plugin";
import { VisitModel } from "~/shared/schema/metrics/VisitSchema";
import { getDomainFromString } from "~/server/utils/getRequestContext";
const getReferrersPlugin = new AiPlugin<'getReferrers', ['from', 'to', 'domain', 'limit']>('getReferrers',
{
type: 'function',
function: {
name: 'getReferrers',
description: 'Gets an array of website referrers (visit sources) on a date range.',
parameters: {
type: 'object',
properties: {
from: { type: 'string', description: 'ISO string of start date' },
to: { type: 'string', description: 'ISO string of end date' },
domain: { type: 'string', description: 'Used only to filter a specific webdomain/website' },
limit: { type: 'number', description: 'Max number of items to return' }
},
required: ['from', 'to']
}
}
},
async (data) => {
const result = await VisitModel.aggregate([
{
$match: {
project_id: new Types.ObjectId(data.project_id),
created_at: {
$gte: new Date(data.from),
$lte: new Date(data.to)
},
website: getDomainFromString(data.domain ?? '*') ?? { $ne: null },
}
},
{ $group: { _id: "$referrer", count: { $sum: 1, } } },
{ $sort: { count: -1 } },
{ $limit: Math.min(data.limit ?? 500, 500) }
]);
return result as { _id: string, count: number }[];
}
);
const getContinentsPlugin = new AiPlugin<'getContinents', ['from', 'to', 'domain', 'limit']>('getContinents',
{
type: 'function',
function: {
name: 'getContinents',
description: 'Gets an array of continents that visited the user website on a date range.',
parameters: {
type: 'object',
properties: {
from: { type: 'string', description: 'ISO string of start date' },
to: { type: 'string', description: 'ISO string of end date' },
domain: { type: 'string', description: 'Used only to filter a specific webdomain/website' },
limit: { type: 'number', description: 'Max number of items to return' }
},
required: ['from', 'to']
}
}
},
async (data) => {
const result = await VisitModel.aggregate([
{
$match: {
project_id: new Types.ObjectId(data.project_id),
created_at: {
$gte: new Date(data.from),
$lte: new Date(data.to)
},
website: getDomainFromString(data.domain ?? '*') ?? { $ne: null },
}
},
{ $group: { _id: "$continent", count: { $sum: 1, } } },
{ $sort: { count: -1 } },
{ $limit: Math.min(data.limit ?? 500, 500) }
]);
return result as { _id: string, count: number }[];
}
);
const getCountriesPlugin = new AiPlugin<'getCountries', ['from', 'to', 'domain', 'limit']>('getCountries',
{
type: 'function',
function: {
name: 'getCountries',
description: 'Gets an array of countries that visited the user website on a date range.',
parameters: {
type: 'object',
properties: {
from: { type: 'string', description: 'ISO string of start date' },
to: { type: 'string', description: 'ISO string of end date' },
domain: { type: 'string', description: 'Used only to filter a specific webdomain/website' },
limit: { type: 'number', description: 'Max number of items to return' }
},
required: ['from', 'to']
}
}
},
async (data) => {
const result = await VisitModel.aggregate([
{
$match: {
project_id: new Types.ObjectId(data.project_id),
created_at: {
$gte: new Date(data.from),
$lte: new Date(data.to)
},
website: getDomainFromString(data.domain ?? '*') ?? { $ne: null },
}
},
{ $group: { _id: "$country", count: { $sum: 1, } } },
{ $sort: { count: -1 } },
{ $limit: Math.min(data.limit ?? 500, 500) }
]);
return result as { _id: string, count: number }[];
}
);
const getPagesPlugin = new AiPlugin<'getPages', ['from', 'to', 'domain', 'limit']>('getPages',
{
type: 'function',
function: {
name: 'getPages',
description: 'Gets an array of most visited pages on a date range.',
parameters: {
type: 'object',
properties: {
from: { type: 'string', description: 'ISO string of start date' },
to: { type: 'string', description: 'ISO string of end date' },
domain: { type: 'string', description: 'Used only to filter a specific webdomain/website' },
limit: { type: 'number', description: 'Max number of items to return' }
},
required: ['from', 'to']
}
}
},
async (data) => {
const result = await VisitModel.aggregate([
{
$match: {
project_id: new Types.ObjectId(data.project_id),
created_at: {
$gte: new Date(data.from),
$lte: new Date(data.to)
},
website: getDomainFromString(data.domain ?? '*') ?? { $ne: null },
}
},
{ $group: { _id: "$page", count: { $sum: 1, } } },
{ $sort: { count: -1 } },
{ $limit: Math.min(data.limit ?? 500, 500) }
]);
return result as { _id: string, count: number }[];
}
);
const getBrowsersPlugin = new AiPlugin<'getBrowsers', ['from', 'to', 'domain', 'limit']>('getBrowsers',
{
type: 'function',
function: {
name: 'getBrowsers',
description: 'Gets an array of browsers that visited the user website on a date range.',
parameters: {
type: 'object',
properties: {
from: { type: 'string', description: 'ISO string of start date' },
to: { type: 'string', description: 'ISO string of end date' },
domain: { type: 'string', description: 'Used only to filter a specific webdomain/website' },
limit: { type: 'number', description: 'Max number of items to return' }
},
required: ['from', 'to']
}
}
},
async (data) => {
const result = await VisitModel.aggregate([
{
$match: {
project_id: new Types.ObjectId(data.project_id),
created_at: {
$gte: new Date(data.from),
$lte: new Date(data.to)
},
website: getDomainFromString(data.domain ?? '*') ?? { $ne: null },
}
},
{ $group: { _id: "$browser", count: { $sum: 1, } } },
{ $sort: { count: -1 } },
{ $limit: Math.min(data.limit ?? 500, 500) }
]);
return result as { _id: string, count: number }[];
}
);
const getDevicesPlugin = new AiPlugin<'getDevices', ['from', 'to', 'domain', 'limit']>('getDevices',
{
type: 'function',
function: {
name: 'getDevices',
description: 'Gets an array of devices that visited the user website on a date range.',
parameters: {
type: 'object',
properties: {
from: { type: 'string', description: 'ISO string of start date' },
to: { type: 'string', description: 'ISO string of end date' },
domain: { type: 'string', description: 'Used only to filter a specific webdomain/website' },
limit: { type: 'number', description: 'Max number of items to return' }
},
required: ['from', 'to']
}
}
},
async (data) => {
const result = await VisitModel.aggregate([
{
$match: {
project_id: new Types.ObjectId(data.project_id),
created_at: {
$gte: new Date(data.from),
$lte: new Date(data.to)
},
website: getDomainFromString(data.domain ?? '*') ?? { $ne: null },
}
},
{ $group: { _id: "$device", count: { $sum: 1, } } },
{ $sort: { count: -1 } },
{ $limit: Math.min(data.limit ?? 500, 500) }
]);
return result as { _id: string, count: number }[];
}
);
export const dataPlugins = [
getReferrersPlugin,
getContinentsPlugin,
getCountriesPlugin,
getPagesPlugin,
getBrowsersPlugin,
getDevicesPlugin
]

View File

@@ -0,0 +1,52 @@
import { executeAdvancedTimelineAggregation } from "../../TimelineService";
import { Types } from "mongoose";
import { AiPlugin, getFirstAvailableSliceFromDates } from "../Plugin";
import { VisitModel } from "~/shared/schema/metrics/VisitSchema";
const getSessionsTimelinePlugin = new AiPlugin<'getSessionsTimeline', ['from', 'to', 'page', 'domain']>(
'getSessionsTimeline',
{
type: 'function',
function: {
name: 'getSessionsTimeline',
description: 'Gets an array of date and count for sessions (unique visitors) received on a date range.',
parameters: {
type: 'object',
properties: {
from: { type: 'string', description: 'ISO string of start date' },
to: { type: 'string', description: 'ISO string of end date' },
page: { type: 'string', description: 'The page of the visit' },
domain: { type: 'string', description: 'Used only to filter a specific domain/website' }
},
required: ['from', 'to']
}
}
},
async (data) => {
const timelineData = await executeAdvancedTimelineAggregation({
projectId: new Types.ObjectId(data.project_id),
model: VisitModel,
from: new Date(data.from).getTime(),
to: new Date(data.to).getTime(),
slice: getFirstAvailableSliceFromDates(data.from, data.to),
domain: data.domain,
customIdGroup: { count: '$session' },
customQueries: [
{
index: 2,
query: {
$group: { _id: { date: '$_id.date' }, count: { $sum: 1 } }
}
}
]
});
return timelineData;
}
);
export const sessionsPlugins = [
getSessionsTimelinePlugin
]

View File

@@ -0,0 +1,66 @@
import { Types } from "mongoose";
import { AiPlugin } from "../Plugin";
import { VisitModel } from "~/shared/schema/metrics/VisitSchema";
import { getDomainFromString } from "~/server/utils/getRequestContext";
import { UtmKey } from "~/components/complex/line-data/selectors/SelectRefer.vue";
const utmKeys = ["utm_medium", "utm_source", "utm_term", "utm_campaign", "utm_content"]
const plugins: AiPlugin[] = []
for (const utmKey of utmKeys) {
const getUtmPlugin = new AiPlugin<`get_${typeof utmKey}`, ['domain', 'from', 'to', 'limit']>(`get_${utmKey}`,
{
type: 'function',
function: {
name: `get_${utmKey}`,
parameters: {
type: 'object',
properties: {
from: { type: 'string', description: 'ISO string of start date' },
to: { type: 'string', description: 'ISO string of end date' },
domain: { type: 'string', description: 'Used only to filter a specific webdomain/website' },
limit: { type: 'number', description: 'Max number of items to return' }
},
required: ['from', 'to']
},
description: `Gets an array of all the ${utmKey} of the visits in a date range`,
}
},
async ({ domain, from, to, project_id, limit }) => {
const websiteMatch = domain ? { website: domain } : {};
const result = await VisitModel.aggregate([
{
$match: {
project_id: new Types.ObjectId(project_id),
created_at: { $gte: new Date(from), $lte: new Date(to) },
...websiteMatch,
[utmKey]: { $ne: null }
}
},
{
$group: {
_id: `$${utmKey}`,
count: { $sum: 1 }
}
},
{ $sort: { count: -1 } },
{ $limit: Math.min(limit ?? 500, 500) }
]);
return result as { _id: string, count: number }[];
});
plugins.push(getUtmPlugin);
}
export const utmDataPlugins = plugins;

View File

@@ -0,0 +1,58 @@
import { executeAdvancedTimelineAggregation } from "../../TimelineService";
import { Types } from "mongoose";
import { getFirstAvailableSliceFromDates } from "../Plugin";
import { AiPlugin } from "../Plugin";
import { VisitModel } from "~/shared/schema/metrics/VisitSchema";
const getVisitsTimelinePlugin = new AiPlugin<'getVisitsTimeline', ['from', 'to', 'page', 'domain', 'continent', 'country', 'region', 'city', 'device']>(
'getVisitsTimeline',
{
type: 'function',
function: {
name: 'getVisitsTimeline',
description: 'Gets an array of date and count for visits received on a date range. Can be filtered for domain, continent, country, region, city, devices.',
parameters: {
type: 'object',
properties: {
from: { type: 'string', description: 'ISO string of start date' },
to: { type: 'string', description: 'ISO string of end date' },
page: { type: 'string', description: 'The page of the visit' },
domain: { type: 'string', description: 'Used only to filter a specific domain/website' },
device: { type: 'string', description: 'Used only to filter a specific device' },
continent: { type: 'string', description: 'Used only to filter a specific continent - 2 letters' },
country: { type: 'string', description: 'Used only to filter a specific country - 2 letters' },
region: { type: 'string', description: 'Used only to filter a specific region - 2 letters' },
city: { type: 'string', description: 'Used only to filter a specific city - 2 letters' },
},
required: ['from', 'to']
}
}
},
async (data) => {
const match: Record<string, string> = {}
if (data.device) match.device = data.device;
if (data.continent) match.continent = data.continent;
if (data.country) match.country = data.country;
if (data.region) match.region = data.region;
if (data.city) match.city = data.city;
const timelineData = await executeAdvancedTimelineAggregation({
projectId: new Types.ObjectId(data.project_id),
model: VisitModel,
from: new Date(data.from).getTime(),
to: new Date(data.to).getTime(),
slice: getFirstAvailableSliceFromDates(data.from, data.to),
domain: data.domain,
customMatch: match
});
return timelineData;
}
);
export const visitsPlugins = [
getVisitsTimelinePlugin
]