mirror of
https://github.com/Litlyx/litlyx
synced 2025-12-14 00:58:36 +01:00
new selfhosted version
This commit is contained in:
106
dashboard/server/services/ai/AiService.ts
Normal file
106
dashboard/server/services/ai/AiService.ts
Normal 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();
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
52
dashboard/server/services/ai/Plugin.ts
Normal file
52
dashboard/server/services/ai/Plugin.ts
Normal 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
|
||||
) { }
|
||||
|
||||
}
|
||||
122
dashboard/server/services/ai/entities/Agent.ts
Normal file
122
dashboard/server/services/ai/entities/Agent.ts
Normal 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();
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
74
dashboard/server/services/ai/entities/InsightAgent.ts
Normal file
74
dashboard/server/services/ai/entities/InsightAgent.ts
Normal 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;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
153
dashboard/server/services/ai/plugins/BouncingRatePlugin.ts
Normal file
153
dashboard/server/services/ai/plugins/BouncingRatePlugin.ts
Normal 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
|
||||
]
|
||||
63
dashboard/server/services/ai/plugins/ChartPlugin.ts
Normal file
63
dashboard/server/services/ai/plugins/ChartPlugin.ts
Normal 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
|
||||
]
|
||||
253
dashboard/server/services/ai/plugins/DataPlugin.ts
Normal file
253
dashboard/server/services/ai/plugins/DataPlugin.ts
Normal 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
|
||||
]
|
||||
52
dashboard/server/services/ai/plugins/SessionsPlugin.ts
Normal file
52
dashboard/server/services/ai/plugins/SessionsPlugin.ts
Normal 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
|
||||
]
|
||||
66
dashboard/server/services/ai/plugins/UTMData.ts
Normal file
66
dashboard/server/services/ai/plugins/UTMData.ts
Normal 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;
|
||||
58
dashboard/server/services/ai/plugins/VisitsPlugins.ts
Normal file
58
dashboard/server/services/ai/plugins/VisitsPlugins.ts
Normal 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
|
||||
]
|
||||
Reference in New Issue
Block a user