From f50f3a0f4c56d6a9ded5fc6e64f7986e7bb23473 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Tue, 21 Apr 2026 14:59:53 +0100 Subject: [PATCH 01/21] Add new AI tools, improve UI --- backend/apps/cloud/src/ai/ai.controller.ts | 119 ++ backend/apps/cloud/src/ai/ai.module.ts | 4 + backend/apps/cloud/src/ai/ai.service.ts | 1101 +++++++++++++++-- backend/apps/cloud/src/ai/dto/chat.dto.ts | 25 + web/app/pages/Project/tabs/AskAI/AIChart.tsx | 184 ++- .../pages/Project/tabs/AskAI/AskAIView.tsx | 1098 +++++++++++----- web/app/routes/projects.$id.tsx | 61 + web/app/styles/ProjectViewStyle.css | 38 + web/public/locales/en.json | 27 +- 9 files changed, 2205 insertions(+), 452 deletions(-) diff --git a/backend/apps/cloud/src/ai/ai.controller.ts b/backend/apps/cloud/src/ai/ai.controller.ts index a6f8fab12..2a696c1dd 100644 --- a/backend/apps/cloud/src/ai/ai.controller.ts +++ b/backend/apps/cloud/src/ai/ai.controller.ts @@ -35,6 +35,7 @@ import { UpdateChatDto, GetRecentChatsQueryDto, GetAllChatsQueryDto, + FeedbackDto, } from './dto/chat.dto' import { trackCustom } from '../common/analytics' @@ -522,6 +523,27 @@ export class AiController { }, ) + if ( + !createChatDto.name && + createChatDto.messages?.length && + process.env.OPENROUTER_API_KEY + ) { + const firstUserMsg = createChatDto.messages.find( + (m) => m.role === 'user', + )?.content + if (firstUserMsg) { + this.aiService + .generateChatTitle(firstUserMsg) + .then((title) => this.aiChatService.update(chat.id, { name: title })) + .catch((err) => + this.logger.warn( + { err, chatId: chat.id }, + 'Background title generation failed', + ), + ) + } + } + return { id: chat.id, name: chat.name, @@ -531,6 +553,103 @@ export class AiController { } } + @ApiBearerAuth() + @Post(':pid/chats/:chatId/title') + @Auth(false, true) + @ApiOperation({ + summary: + 'Generate (or regenerate) a concise AI-generated title for a chat from its first user message', + }) + @ApiResponse({ status: 200, description: 'Generated chat title' }) + async generateChatTitle( + @Param('pid') pid: string, + @Param('chatId') chatId: string, + @CurrentUserId() uid: string | null, + @Headers() headers: Record, + ) { + this.logger.log({ uid, pid, chatId }, 'POST /ai/:pid/chats/:chatId/title') + + await this.applyRateLimit(uid, headers, 'write') + + if (!process.env.OPENROUTER_API_KEY) { + throw new HttpException( + 'AI features are not configured. Please set OPENROUTER_API_KEY.', + HttpStatus.SERVICE_UNAVAILABLE, + ) + } + + const project = await this.projectService.getFullProject(pid) + if (_isEmpty(project)) { + throw new NotFoundException('Project not found') + } + this.projectService.allowedToView(project, uid) + + const chat = await this.aiChatService.verifyOwnerAccess(chatId, pid, uid) + if (!chat) { + throw new NotFoundException('Chat not found') + } + + const firstUserMsg = chat.messages.find((m) => m.role === 'user')?.content + + if (!firstUserMsg) { + return { id: chat.id, name: chat.name } + } + + const title = await this.aiService.generateChatTitle(firstUserMsg) + const updated = await this.aiChatService.update(chatId, { name: title }) + + return { + id: chatId, + name: updated?.name || title, + } + } + + @ApiBearerAuth() + @Post(':pid/chats/:chatId/feedback') + @Auth(false, true) + @ApiOperation({ summary: 'Submit feedback on an AI response' }) + @ApiResponse({ status: 200, description: 'Feedback recorded' }) + async submitChatFeedback( + @Param('pid') pid: string, + @Param('chatId') chatId: string, + @Body() feedbackDto: FeedbackDto, + @CurrentUserId() uid: string | null, + @Headers() headers: Record, + ) { + this.logger.log( + { uid, pid, chatId, rating: feedbackDto.rating }, + 'POST /ai/:pid/chats/:chatId/feedback', + ) + + await this.applyRateLimit(uid, headers, 'write') + + const project = await this.projectService.getFullProject(pid) + if (_isEmpty(project)) { + throw new NotFoundException('Project not found') + } + this.projectService.allowedToView(project, uid) + + const chat = await this.aiChatService.verifyProjectAccess(chatId, pid) + if (!chat) { + throw new NotFoundException('Chat not found') + } + + await trackCustom( + getIPFromHeaders(headers) || 'unknown', + headers['user-agent'], + { + ev: `AI_CHAT_FEEDBACK_${feedbackDto.rating.toUpperCase()}`, + meta: { + chatId, + messageIndex: feedbackDto.messageIndex, + hasComment: !!feedbackDto.comment, + }, + }, + ) + + return { success: true } + } + @ApiBearerAuth() @Post(':pid/chats/:chatId') @Auth(false, true) // Allow optional auth for public projects diff --git a/backend/apps/cloud/src/ai/ai.module.ts b/backend/apps/cloud/src/ai/ai.module.ts index a38a43f92..96c4c3b8b 100644 --- a/backend/apps/cloud/src/ai/ai.module.ts +++ b/backend/apps/cloud/src/ai/ai.module.ts @@ -5,6 +5,8 @@ import { ProjectModule } from '../project/project.module' import { AppLoggerModule } from '../logger/logger.module' import { AnalyticsModule } from '../analytics/analytics.module' import { GoalModule } from '../goal/goal.module' +import { FeatureFlagModule } from '../feature-flag/feature-flag.module' +import { ExperimentModule } from '../experiment/experiment.module' import { AiService } from './ai.service' import { AiChatService } from './ai-chat.service' import { AiController } from './ai.controller' @@ -17,6 +19,8 @@ import { AiChat } from './entity/ai-chat.entity' AppLoggerModule, AnalyticsModule, GoalModule, + FeatureFlagModule, + ExperimentModule, ], providers: [AiService, AiChatService], controllers: [AiController], diff --git a/backend/apps/cloud/src/ai/ai.service.ts b/backend/apps/cloud/src/ai/ai.service.ts index 3f7093ee9..1dd3e722a 100644 --- a/backend/apps/cloud/src/ai/ai.service.ts +++ b/backend/apps/cloud/src/ai/ai.service.ts @@ -3,6 +3,7 @@ import { createOpenAI } from '@ai-sdk/openai' import { streamText, tool, + generateText, ModelMessage, StreamTextResult, stepCountIs, @@ -10,7 +11,6 @@ import { import { z } from 'zod' import _isEmpty from 'lodash/isEmpty' import _map from 'lodash/map' -import _pick from 'lodash/pick' import dayjs from 'dayjs' import { ProjectService } from '../project/project.service' @@ -19,6 +19,8 @@ import { getLowestPossibleTimeBucket, } from '../analytics/analytics.service' import { GoalService } from '../goal/goal.service' +import { FeatureFlagService } from '../feature-flag/feature-flag.service' +import { ExperimentService } from '../experiment/experiment.service' import { AppLoggerService } from '../logger/logger.service' import { clickhouse } from '../common/integrations/clickhouse' import { Project } from '../project/entity/project.entity' @@ -26,6 +28,9 @@ import { TimeBucketType } from '../analytics/dto/getData.dto' const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1' +const PRIMARY_MODEL = 'anthropic/claude-haiku-4.5' +const TITLE_MODEL = 'google/gemini-3.1-flash-lite-preview' + const ALLOWED_FILTER_COLUMNS = new Set([ 'pg', 'cc', @@ -77,6 +82,8 @@ export class AiService { private readonly projectService: ProjectService, private readonly analyticsService: AnalyticsService, private readonly goalService: GoalService, + private readonly featureFlagService: FeatureFlagService, + private readonly experimentService: ExperimentService, private readonly logger: AppLoggerService, ) { this.openrouter = createOpenAI({ @@ -112,69 +119,145 @@ export class AiService { ) const result = streamText({ - model: this.openrouter.chat('anthropic/claude-haiku-4.5'), + model: this.openrouter.chat(PRIMARY_MODEL), system: systemPrompt, messages, tools: this.buildTools(project, timezone), - stopWhen: stepCountIs(10), + stopWhen: stepCountIs(15), }) return result } + /** + * Generates a short, descriptive chat title from the first user message + * using a small/fast model so it doesn't slow down the main response. + */ + async generateChatTitle(firstUserMessage: string): Promise { + const trimmed = firstUserMessage.trim() + if (!trimmed) return 'New conversation' + + const fallback = + trimmed.length > 60 ? `${trimmed.slice(0, 57)}...` : trimmed + + if (!process.env.OPENROUTER_API_KEY) { + return fallback + } + + try { + const { text } = await generateText({ + model: this.openrouter.chat(TITLE_MODEL), + system: + 'You are a title generator. Produce a concise, descriptive chat title (max 6 words, no quotes, no trailing punctuation, Title Case) that captures the essence of the user message. Reply with the title only.', + prompt: trimmed.slice(0, 800), + temperature: 0.3, + }) + + const cleaned = (text || '') + .replace(/^["'`]+|["'`]+$/g, '') + .replace(/[\r\n]+/g, ' ') + .trim() + + if (!cleaned) return fallback + return cleaned.length > 80 ? `${cleaned.slice(0, 77)}...` : cleaned + } catch (error) { + this.logger.warn( + { error, preview: trimmed.slice(0, 80) }, + 'Failed to generate chat title, falling back', + ) + return fallback + } + } + private buildSystemPrompt(project: Project, timezone: string): string { const currentDate = dayjs().tz(timezone).format('YYYY-MM-DD HH:mm:ss') - return `You are an AI assistant for Swetrix, a privacy-focused web analytics platform. You help users understand their website analytics data. + return `You are Swetrix Copilot, the in-product AI assistant for Swetrix - a privacy-focused, GDPR-compliant web analytics platform. You act as a senior product analyst and growth advisor for the user, helping them understand their data and make decisions. Current context: - Project: "${project.name}" (ID: ${project.id}) - Current date/time: ${currentDate} (timezone: ${timezone}) -You have access to tools that can query analytics data for this project. Use them to answer user questions about: -- Traffic and pageviews -- Visitors and sessions -- Performance metrics +You have tools that can query the project's analytics data. Use them to answer questions about: +- Traffic, pageviews, sessions, unique visitors +- Performance metrics (page load, TTFB, DNS, etc.) - Custom events -- Goals and conversions -- Errors -- Geographic data -- Device and browser statistics -- Referrer sources +- Goals & conversions +- Funnels (step-by-step conversion analysis) +- Errors / exceptions +- CAPTCHA challenges (if enabled) +- Feature flags (configuration + evaluation stats) +- A/B Experiments (variants, exposures, conversions) +- User sessions (recent sessions list) +- Geographic, device, browser, OS, referrer breakdowns + +Time periods: +- Predefined: 1h, today, yesterday, 1d, 7d, 4w, 3M, 12M, 24M, all +- Custom range: pass "from" and "to" as YYYY-MM-DD (or full ISO timestamps). When the user mentions a specific date range ("between Jan 1 and Mar 5", "since last Tuesday", "for Q1", "from 2025-01-01 to 2025-03-31"), translate it to from/to and pass them. Use this for any range that doesn't map cleanly to a preset. Guidelines: -1. ALWAYS use tools to fetch real data before answering questions about analytics. NEVER make up or hallucinate data. +1. ALWAYS use tools to fetch real data before answering questions about analytics. NEVER make up, hallucinate, or estimate numbers. 2. If a tool call fails or returns an error, tell the user there was an issue fetching the data. Do not invent numbers. -3. When presenting data, be clear and concise -4. If the user asks for charts or visualizations, include a chart in your response using the special JSON format -5. Use appropriate time periods based on context (default to last 7 days if not specified) -6. Round percentages and large numbers for readability -7. Explain trends and provide actionable insights when relevant -8. If no data is available for the requested period, say so clearly instead of making up data -9. If user asks technical questions, like how to set up a tracking script, or use the platform, refer them to the documentation at https://swetrix.com/docs/ +3. Be concise, but proactive. Summarise key takeaways, surface anomalies/trends, and suggest follow-ups. +4. Default to the last 7 days when the user doesn't specify a period. +5. Prefer charts when comparing series, showing trends over time, or breaking down distributions. Use tables/lists for short rankings. +6. Round large numbers and percentages for readability. +7. If no data is available for the requested period or feature isn't configured (e.g. no CAPTCHA, no experiments), say so clearly. +8. If the user asks how to set up tracking or platform features, refer them to https://swetrix.com/docs/ +9. Place each chart inline at the point in your response where it is most relevant. Do NOT batch all charts at the end of the message. -To include a chart in your response, use this exact JSON format on its own line: +To include a chart, emit this exact JSON on its own line at the position you want it rendered: -For time-series charts (line, bar, area): +Time-series (line, bar, area): {"type":"chart","chartType":"line","title":"Chart Title","data":{"x":["2024-01-01","2024-01-02"],"pageviews":[100,150],"visitors":[80,120]}} -For pie/donut charts (showing proportions): +Pie / donut (proportions): {"type":"chart","chartType":"pie","title":"Device Distribution","data":{"labels":["Desktop","Mobile","Tablet"],"values":[650,280,70]}} {"type":"chart","chartType":"donut","title":"Traffic Sources","data":{"labels":["Organic","Direct","Referral"],"values":[450,300,150]}} Supported chart types: "line", "bar", "area", "pie", "donut" -- For line/bar/area: data object should have "x" for x-axis labels and named arrays for each series -- For pie/donut: data object should have "labels" array and "values" array (use raw numbers, not percentages)` +- For line/bar/area: "x" for x-axis labels and named numeric arrays for each series. +- For pie/donut: "labels" + "values" (raw numbers, NOT percentages).` } private buildTools(project: Project, timezone: string) { + const periodSchema = z + .enum([ + '1h', + 'today', + 'yesterday', + '1d', + '7d', + '4w', + '3M', + '12M', + '24M', + 'all', + ]) + .optional() + .describe( + 'Predefined time period. Omit when using a custom from/to range.', + ) + + const fromSchema = z + .string() + .optional() + .describe( + 'Start of custom range (YYYY-MM-DD or full ISO datetime). Pair with "to".', + ) + + const toSchema = z + .string() + .optional() + .describe( + 'End of custom range (YYYY-MM-DD or full ISO datetime). Pair with "from".', + ) + return { getProjectInfo: tool({ description: - 'Get basic information about the current project including name, settings, and available funnels/goals', - inputSchema: z.object({ - // Empty object schema - no parameters needed - }), + 'Get basic information about the current project: name, created date, whether CAPTCHA is enabled, plus the available funnels, goals, feature flags and experiments. Call this early in a conversation to discover what entities exist before asking for stats on them.', + inputSchema: z.object({}), execute: async () => { this.logger.log({ pid: project.id }, 'Tool: getProjectInfo called') @@ -186,6 +269,8 @@ Supported chart types: "line", "bar", "area", "pie", "donut" pid: project.id, funnelCount: result.funnels?.length, goalCount: result.goals?.length, + flagCount: result.featureFlags?.length, + experimentCount: result.experiments?.length, }, 'Tool: getProjectInfo completed', ) @@ -201,9 +286,18 @@ Supported chart types: "line", "bar", "area", "pie", "donut" }), getData: tool({ - description: `Query analytics data for the project. Returns chart data and panel breakdowns (top pages, countries, browsers, etc.). - -Available columns for filters: + description: `Query analytics-style data for the project. Returns overall counts plus chart data and panel breakdowns (top pages, countries, browsers, etc.). + +Use dataType to choose the dataset: +- "analytics": pageviews, sessions, geo/device/referrer breakdowns +- "performance": page load timings (pageLoad, ttfb, etc.) +- "captcha": CAPTCHA challenge events (only meaningful if CAPTCHA is enabled for the project) +- "errors": error events with totals + top errors +- "customEvents": top custom event names with counts + +Supports either a predefined period OR a custom from/to range. + +Available filter columns: - pg: page path - cc: country code (2-letter ISO) - rg: region @@ -219,19 +313,17 @@ Available columns for filters: - host: hostname`, inputSchema: z.object({ dataType: z - .enum(['analytics', 'performance', 'captcha', 'errors']) + .enum([ + 'analytics', + 'performance', + 'captcha', + 'errors', + 'customEvents', + ]) .describe('Type of data to query'), - period: z - .string() - .optional() - .describe( - 'Time period: 1h, today, yesterday, 1d, 7d, 4w, 3M, 12M, 24M', - ), - from: z - .string() - .optional() - .describe('Start date (YYYY-MM-DD format)'), - to: z.string().optional().describe('End date (YYYY-MM-DD format)'), + period: periodSchema, + from: fromSchema, + to: toSchema, timeBucket: z .enum(['minute', 'hour', 'day', 'month']) .optional() @@ -263,6 +355,8 @@ Available columns for filters: pid: project.id, dataType: params.dataType, period: params.period, + from: params.from, + to: params.to, filters: params.filters, }, 'Tool: getData called', @@ -294,31 +388,15 @@ Available columns for filters: getGoalStats: tool({ description: - 'Get goal conversion statistics including conversions, conversion rate, and trends', + 'Get goal conversion statistics including conversions, unique sessions, and per-goal totals. Call without goalId to get totals for every active goal.', inputSchema: z.object({ goalId: z .string() .optional() .describe('Specific goal ID, or omit to get all goals'), - period: z - .enum([ - '1h', - 'today', - 'yesterday', - '1d', - '7d', - '4w', - '3M', - '12M', - '24M', - ]) - .optional() - .describe('Time period (default: 7d)'), - from: z - .string() - .optional() - .describe('Start date (YYYY-MM-DD format)'), - to: z.string().optional().describe('End date (YYYY-MM-DD format)'), + period: periodSchema, + from: fromSchema, + to: toSchema, }), execute: async (params) => { this.logger.log( @@ -347,28 +425,12 @@ Available columns for filters: getFunnelData: tool({ description: - 'Get funnel analysis data showing step-by-step conversions', + 'Get funnel analysis data showing step-by-step conversions and drop-off for a specific funnel.', inputSchema: z.object({ funnelId: z.string().describe('Funnel ID to query'), - period: z - .enum([ - '1h', - 'today', - 'yesterday', - '1d', - '7d', - '4w', - '3M', - '12M', - '24M', - ]) - .optional() - .describe('Time period (default: 7d)'), - from: z - .string() - .optional() - .describe('Start date (YYYY-MM-DD format)'), - to: z.string().optional().describe('End date (YYYY-MM-DD format)'), + period: periodSchema, + from: fromSchema, + to: toSchema, }), execute: async (params) => { if (!params.funnelId) { @@ -409,18 +471,144 @@ Available columns for filters: } }, }), + + getFeatureFlagStats: tool({ + description: + 'Get evaluation stats for feature flags: total evaluations, unique profiles exposed, true vs false counts and percentages. Pass a flagId for a specific flag, or omit to get a summary of all flags in the project.', + inputSchema: z.object({ + flagId: z + .string() + .optional() + .describe('Feature flag ID, or omit for all flags in the project'), + period: periodSchema, + from: fromSchema, + to: toSchema, + }), + execute: async (params) => { + this.logger.log( + { pid: project.id, flagId: params.flagId }, + 'Tool: getFeatureFlagStats called', + ) + try { + return await this.getFeatureFlagStats(project.id, params, timezone) + } catch (error) { + this.logger.error( + { error, pid: project.id }, + 'Tool getFeatureFlagStats failed', + ) + return { error: 'Failed to fetch feature flag stats.' } + } + }, + }), + + getExperimentResults: tool({ + description: + 'Get exposures and conversion counts per variant for an A/B experiment. Returns variant exposures, conversions, and conversion rate so you can advise on which variant is winning. Omit experimentId to list all experiments with their basic config.', + inputSchema: z.object({ + experimentId: z + .string() + .optional() + .describe( + 'Experiment ID. Omit to list all experiments in the project.', + ), + period: periodSchema, + from: fromSchema, + to: toSchema, + }), + execute: async (params) => { + this.logger.log( + { pid: project.id, experimentId: params.experimentId }, + 'Tool: getExperimentResults called', + ) + try { + return await this.getExperimentResults(project.id, params, timezone) + } catch (error) { + this.logger.error( + { error, pid: project.id }, + 'Tool getExperimentResults failed', + ) + return { error: 'Failed to fetch experiment results.' } + } + }, + }), + + getSessionsList: tool({ + description: + 'Get a list of recent user sessions (psid, country, OS, browser, started/ended timestamps, page count) for inspection. Useful for finding examples of user behaviour or debugging. Capped at 25 sessions per call.', + inputSchema: z.object({ + period: periodSchema, + from: fromSchema, + to: toSchema, + take: z + .number() + .int() + .min(1) + .max(25) + .optional() + .describe('Number of sessions to return (max 25, default 10)'), + country: z + .string() + .optional() + .describe('Optional 2-letter country code filter'), + page: z + .string() + .optional() + .describe('Optional page path filter (exact match on pg)'), + }), + execute: async (params) => { + this.logger.log( + { pid: project.id, period: params.period }, + 'Tool: getSessionsList called', + ) + try { + return await this.getSessionsList(project.id, params, timezone) + } catch (error) { + this.logger.error( + { error, pid: project.id }, + 'Tool getSessionsList failed', + ) + return { error: 'Failed to fetch sessions.' } + } + }, + }), + + getProfilesOverview: tool({ + description: + 'Get a high-level overview of profiles (returning visitors): unique profile count, sessions per profile, and top page paths. Useful for understanding audience composition and stickiness.', + inputSchema: z.object({ + period: periodSchema, + from: fromSchema, + to: toSchema, + }), + execute: async (params) => { + this.logger.log( + { pid: project.id, period: params.period }, + 'Tool: getProfilesOverview called', + ) + try { + return await this.getProfilesOverview(project.id, params, timezone) + } catch (error) { + this.logger.error( + { error, pid: project.id }, + 'Tool getProfilesOverview failed', + ) + return { error: 'Failed to fetch profiles overview.' } + } + }, + }), } } private async getProjectInfo(project: Project) { - // Get funnels - const funnels = await this.projectService.getFunnels(project.id) - - // Get goals - const goals = await this.goalService.find({ - where: { project: { id: project.id }, active: true }, - order: { name: 'ASC' }, - }) + const [funnels, goals, featureFlags, experiments] = await Promise.all([ + this.projectService.getFunnels(project.id), + this.goalService.find({ + where: { project: { id: project.id }, active: true }, + order: { name: 'ASC' }, + }), + this.featureFlagService.findByProject(project.id).catch(() => []), + this.experimentService.findByProject(project.id).catch(() => []), + ]) return { id: project.id, @@ -438,13 +626,41 @@ Available columns for filters: type: g.type, value: g.value, })), + featureFlags: _map(featureFlags, (f) => ({ + id: f.id, + key: f.key, + description: f.description, + flagType: f.flagType, + rolloutPercentage: f.rolloutPercentage, + enabled: f.enabled, + })), + experiments: _map(experiments, (e) => ({ + id: e.id, + name: e.name, + description: e.description, + status: e.status, + startedAt: e.startedAt, + endedAt: e.endedAt, + variants: _map(e.variants, (v) => ({ + key: v.key, + name: v.name, + isControl: v.isControl, + rolloutPercentage: v.rolloutPercentage, + })), + goalId: e.goal?.id || null, + })), } } private async getData( pid: string, params: { - dataType: 'analytics' | 'performance' | 'captcha' | 'errors' + dataType: + | 'analytics' + | 'performance' + | 'captcha' + | 'errors' + | 'customEvents' period?: string from?: string to?: string @@ -528,6 +744,27 @@ Available columns for filters: return this.getErrorsData(pid, groupFromUTC, groupToUTC, safeTimezone) } + if (dataType === 'captcha') { + return this.getCaptchaData( + pid, + groupFromUTC, + groupToUTC, + timeBucket, + safeTimezone, + ) + } + + if (dataType === 'customEvents') { + return this.getCustomEventsData( + pid, + groupFromUTC, + groupToUTC, + timeBucket, + safeTimezone, + filters, + ) + } + return { error: 'Unsupported data type' } } catch (error) { this.logger.error({ error, pid, params }, 'Error fetching data for AI') @@ -1042,6 +1279,682 @@ Available columns for filters: return conversionMap[timeBucket] || conversionMap.day } + private async getCaptchaData( + pid: string, + groupFrom: string, + groupTo: string, + timeBucket: TimeBucketType, + timezone: string, + ) { + const overallQuery = ` + SELECT + count(*) as total, + countIf(manuallyPassed = 1) as manuallyPassed, + countIf(manuallyPassed = 0) as autoPassed + FROM captcha + WHERE pid = {pid:FixedString(12)} + AND created BETWEEN {groupFrom:String} AND {groupTo:String} + ` + + const chartQuery = ` + SELECT + ${this.getTimeBucketSelect(timeBucket, timezone)} as date, + count(*) as challenges, + countIf(manuallyPassed = 1) as manuallyPassed + FROM captcha + WHERE pid = {pid:FixedString(12)} + AND created BETWEEN {groupFrom:String} AND {groupTo:String} + GROUP BY date + ORDER BY date + ` + + const breakdownQuery = (column: string) => ` + SELECT ${column} as name, count(*) as count + FROM captcha + WHERE pid = {pid:FixedString(12)} + AND created BETWEEN {groupFrom:String} AND {groupTo:String} + AND ${column} IS NOT NULL AND ${column} != '' + GROUP BY ${column} + ORDER BY count DESC + LIMIT 10 + ` + + const params = { pid, groupFrom, groupTo, timezone } + + const [overall, chart, byCountry, byBrowser, byDevice] = await Promise.all([ + clickhouse + .query({ query: overallQuery, query_params: params }) + .then((r) => r.json()), + clickhouse + .query({ query: chartQuery, query_params: params }) + .then((r) => r.json()), + clickhouse + .query({ query: breakdownQuery('cc'), query_params: params }) + .then((r) => r.json()), + clickhouse + .query({ query: breakdownQuery('br'), query_params: params }) + .then((r) => r.json()), + clickhouse + .query({ query: breakdownQuery('dv'), query_params: params }) + .then((r) => r.json()), + ]) + + return { + overall: (overall.data as any)[0] || {}, + chart: { + x: _map(chart.data as any[], (d) => d.date), + challenges: _map(chart.data as any[], (d) => d.challenges), + manuallyPassed: _map(chart.data as any[], (d) => d.manuallyPassed), + }, + topCountries: byCountry.data, + topBrowsers: byBrowser.data, + devices: byDevice.data, + period: { from: groupFrom, to: groupTo }, + } + } + + private async getCustomEventsData( + pid: string, + groupFrom: string, + groupTo: string, + timeBucket: TimeBucketType, + timezone: string, + filters: Array<{ + column: string + filter: string + isExclusive?: boolean + }>, + ) { + const filterConditions = this.buildFilterConditions(filters) + + const overallQuery = ` + SELECT + count(*) as totalEvents, + uniqExact(ev) as uniqueEvents, + uniqExact(psid) as sessions + FROM customEV + WHERE pid = {pid:FixedString(12)} + AND created BETWEEN {groupFrom:String} AND {groupTo:String} + ${filterConditions.where} + ` + + const topEventsQuery = ` + SELECT ev as name, count(*) as count, uniqExact(psid) as sessions + FROM customEV + WHERE pid = {pid:FixedString(12)} + AND created BETWEEN {groupFrom:String} AND {groupTo:String} + ${filterConditions.where} + GROUP BY ev + ORDER BY count DESC + LIMIT 25 + ` + + const chartQuery = ` + SELECT + ${this.getTimeBucketSelect(timeBucket, timezone)} as date, + count(*) as events, + uniqExact(psid) as sessions + FROM customEV + WHERE pid = {pid:FixedString(12)} + AND created BETWEEN {groupFrom:String} AND {groupTo:String} + ${filterConditions.where} + GROUP BY date + ORDER BY date + ` + + const params = { + pid, + groupFrom, + groupTo, + timezone, + ...filterConditions.params, + } + + const [overall, topEvents, chart] = await Promise.all([ + clickhouse + .query({ query: overallQuery, query_params: params }) + .then((r) => r.json()), + clickhouse + .query({ query: topEventsQuery, query_params: params }) + .then((r) => r.json()), + clickhouse + .query({ query: chartQuery, query_params: params }) + .then((r) => r.json()), + ]) + + return { + overall: (overall.data as any)[0] || {}, + topEvents: topEvents.data, + chart: { + x: _map(chart.data as any[], (d) => d.date), + events: _map(chart.data as any[], (d) => d.events), + sessions: _map(chart.data as any[], (d) => d.sessions), + }, + period: { from: groupFrom, to: groupTo }, + } + } + + private async getFeatureFlagStats( + pid: string, + params: { + flagId?: string + period?: string + from?: string + to?: string + }, + timezone: string, + ) { + const { flagId, period = '7d', from, to } = params + + try { + const safeTimezone = this.analyticsService.getSafeTimezone(timezone) + const timeBucket = getLowestPossibleTimeBucket(period, from, to) + const { groupFromUTC, groupToUTC } = this.analyticsService.getGroupFromTo( + from, + to, + timeBucket, + period, + safeTimezone, + ) + + const queryFlag = async (flag: { + id: string + key: string + flagType: string + rolloutPercentage: number + enabled: boolean + }) => { + const statsQuery = ` + SELECT + count(*) as evaluations, + uniqExact(profileId) as profileCount, + countIf(result = 1) as trueCount, + countIf(result = 0) as falseCount + FROM feature_flag_evaluations + WHERE pid = {pid:FixedString(12)} + AND flagId = {flagId:String} + AND created BETWEEN {groupFrom:String} AND {groupTo:String} + ` + + try { + const { data } = await clickhouse + .query({ + query: statsQuery, + query_params: { + pid, + flagId: flag.id, + groupFrom: groupFromUTC, + groupTo: groupToUTC, + }, + }) + .then((r) => r.json()) + + const stats = (data as any)[0] || { + evaluations: 0, + profileCount: 0, + trueCount: 0, + falseCount: 0, + } + const evaluations = Number(stats.evaluations) || 0 + const trueCount = Number(stats.trueCount) || 0 + const truePercentage = + evaluations > 0 + ? Math.round((trueCount / evaluations) * 10000) / 100 + : 0 + + return { + id: flag.id, + key: flag.key, + flagType: flag.flagType, + rolloutPercentage: flag.rolloutPercentage, + enabled: flag.enabled, + evaluations, + profileCount: Number(stats.profileCount) || 0, + trueCount, + falseCount: Number(stats.falseCount) || 0, + truePercentage, + } + } catch (err) { + this.logger.warn( + { err, flagId: flag.id }, + 'Failed to fetch flag stats', + ) + return { + id: flag.id, + key: flag.key, + flagType: flag.flagType, + rolloutPercentage: flag.rolloutPercentage, + enabled: flag.enabled, + evaluations: 0, + profileCount: 0, + trueCount: 0, + falseCount: 0, + truePercentage: 0, + note: 'No evaluation data yet', + } + } + } + + if (flagId) { + const flag = await this.featureFlagService.findOne({ + where: { id: flagId, project: { id: pid } }, + }) + if (!flag) { + return { error: 'Feature flag not found' } + } + return { + flag: await queryFlag(flag), + period: { from: groupFromUTC, to: groupToUTC }, + } + } + + const flags = await this.featureFlagService.findByProject(pid) + if (!flags.length) { + return { + flags: [], + period: { from: groupFromUTC, to: groupToUTC }, + note: 'No feature flags configured for this project', + } + } + + const flagsWithStats = await Promise.all(flags.map(queryFlag)) + return { + flags: flagsWithStats, + period: { from: groupFromUTC, to: groupToUTC }, + } + } catch (error) { + this.logger.error( + { error, pid, params }, + 'Error fetching feature flag stats', + ) + return { error: 'Failed to fetch feature flag stats' } + } + } + + private async getExperimentResults( + pid: string, + params: { + experimentId?: string + period?: string + from?: string + to?: string + }, + timezone: string, + ) { + const { experimentId, period = '7d', from, to } = params + + try { + const safeTimezone = this.analyticsService.getSafeTimezone(timezone) + const timeBucket = getLowestPossibleTimeBucket(period, from, to) + const { groupFromUTC, groupToUTC } = this.analyticsService.getGroupFromTo( + from, + to, + timeBucket, + period, + safeTimezone, + ) + + if (!experimentId) { + const experiments = await this.experimentService.findByProject(pid) + return { + experiments: experiments.map((e) => ({ + id: e.id, + name: e.name, + status: e.status, + startedAt: e.startedAt, + endedAt: e.endedAt, + variants: e.variants?.map((v) => ({ + key: v.key, + name: v.name, + isControl: v.isControl, + rolloutPercentage: v.rolloutPercentage, + })), + goalId: e.goal?.id || null, + })), + note: 'Call this tool again with a specific experimentId to get exposure/conversion stats.', + period: { from: groupFromUTC, to: groupToUTC }, + } + } + + const experiment = await this.experimentService.findOne({ + where: { id: experimentId, project: { id: pid } }, + relations: ['variants', 'goal'], + }) + + if (!experiment) { + return { error: 'Experiment not found' } + } + + const exposuresQuery = ` + SELECT variantKey, uniqExact(profileId) as exposures + FROM experiment_exposures + WHERE pid = {pid:FixedString(12)} + AND experimentId = {experimentId:String} + AND created BETWEEN {groupFrom:String} AND {groupTo:String} + GROUP BY variantKey + ` + + let exposures: { variantKey: string; exposures: number }[] = [] + try { + const { data } = await clickhouse + .query({ + query: exposuresQuery, + query_params: { + pid, + experimentId, + groupFrom: groupFromUTC, + groupTo: groupToUTC, + }, + }) + .then((r) => r.json()) + exposures = data as any + } catch (err) { + this.logger.warn( + { err, experimentId }, + 'Failed to fetch experiment exposures', + ) + } + + let conversions: { variantKey: string; conversions: number }[] = [] + if (experiment.goal) { + const table = + experiment.goal.type === 'custom_event' ? 'customEV' : 'analytics' + const matchColumn = + experiment.goal.type === 'custom_event' ? 'ev' : 'pg' + const matchCondition = + experiment.goal.matchType === 'exact' + ? `c.${matchColumn} = {goalValue:String}` + : `c.${matchColumn} ILIKE concat('%', {goalValue:String}, '%')` + + const conversionsQuery = ` + SELECT e.variantKey, uniqExact(e.profileId) as conversions + FROM experiment_exposures e + INNER JOIN ${table} c ON e.pid = c.pid AND e.profileId = assumeNotNull(c.profileId) + WHERE e.pid = {pid:FixedString(12)} + AND e.experimentId = {experimentId:String} + AND e.created BETWEEN {groupFrom:String} AND {groupTo:String} + AND c.created BETWEEN {groupFrom:String} AND {groupTo:String} + AND c.created >= e.created + AND ${matchCondition} + GROUP BY e.variantKey + ` + + try { + const { data } = await clickhouse + .query({ + query: conversionsQuery, + query_params: { + pid, + experimentId, + groupFrom: groupFromUTC, + groupTo: groupToUTC, + goalValue: experiment.goal.value || '', + }, + }) + .then((r) => r.json()) + conversions = data as any + } catch (err) { + this.logger.warn( + { err, experimentId }, + 'Failed to fetch experiment conversions', + ) + } + } + + const exposuresMap = new Map( + exposures.map((e) => [e.variantKey, Number(e.exposures)]), + ) + const conversionsMap = new Map( + conversions.map((c) => [c.variantKey, Number(c.conversions)]), + ) + + const variantResults = (experiment.variants || []).map((v) => { + const exp = exposuresMap.get(v.key) || 0 + const conv = conversionsMap.get(v.key) || 0 + const rate = exp > 0 ? Math.round((conv / exp) * 10000) / 100 : 0 + return { + key: v.key, + name: v.name, + isControl: v.isControl, + rolloutPercentage: v.rolloutPercentage, + exposures: exp, + conversions: conv, + conversionRate: rate, + } + }) + + return { + experiment: { + id: experiment.id, + name: experiment.name, + status: experiment.status, + startedAt: experiment.startedAt, + endedAt: experiment.endedAt, + goal: experiment.goal + ? { + id: experiment.goal.id, + name: experiment.goal.name, + type: experiment.goal.type, + } + : null, + }, + variants: variantResults, + totals: { + exposures: variantResults.reduce((s, v) => s + v.exposures, 0), + conversions: variantResults.reduce((s, v) => s + v.conversions, 0), + }, + hasGoal: !!experiment.goal, + period: { from: groupFromUTC, to: groupToUTC }, + } + } catch (error) { + this.logger.error( + { error, pid, params }, + 'Error fetching experiment results', + ) + return { error: 'Failed to fetch experiment results' } + } + } + + private async getSessionsList( + pid: string, + params: { + period?: string + from?: string + to?: string + take?: number + country?: string + page?: string + }, + timezone: string, + ) { + const { period = '7d', from, to, take = 10, country, page } = params + + try { + const safeTimezone = this.analyticsService.getSafeTimezone(timezone) + const timeBucket = getLowestPossibleTimeBucket(period, from, to) + const { groupFromUTC, groupToUTC } = this.analyticsService.getGroupFromTo( + from, + to, + timeBucket, + period, + safeTimezone, + ) + + const safeTake = Math.min(Math.max(Math.floor(take || 10), 1), 25) + + const extraWhere: string[] = [] + const queryParams: Record = { + pid, + groupFrom: groupFromUTC, + groupTo: groupToUTC, + take: safeTake, + } + if (country) { + extraWhere.push(`AND cc = {country:String}`) + queryParams.country = country + } + if (page) { + extraWhere.push(`AND pg = {page:String}`) + queryParams.page = page + } + + const sessionsQuery = ` + SELECT + psid, + any(cc) as country, + any(rg) as region, + any(ct) as city, + any(os) as os, + any(br) as browser, + any(dv) as device, + any(ref) as referrer, + min(created) as startedAt, + max(created) as endedAt, + count(*) as pageviews + FROM analytics + WHERE pid = {pid:FixedString(12)} + AND created BETWEEN {groupFrom:String} AND {groupTo:String} + AND psid IS NOT NULL + ${extraWhere.join(' ')} + GROUP BY psid + ORDER BY endedAt DESC + LIMIT {take:UInt32} + ` + + const { data } = await clickhouse + .query({ query: sessionsQuery, query_params: queryParams }) + .then((r) => r.json()) + + return { + sessions: (data as any[]).map((s) => ({ + ...s, + durationSeconds: + s.startedAt && s.endedAt + ? Math.max( + 0, + Math.round( + (new Date(s.endedAt).getTime() - + new Date(s.startedAt).getTime()) / + 1000, + ), + ) + : 0, + })), + period: { from: groupFromUTC, to: groupToUTC }, + } + } catch (error) { + this.logger.error({ error, pid, params }, 'Error fetching sessions list') + return { error: 'Failed to fetch sessions' } + } + } + + private async getProfilesOverview( + pid: string, + params: { + period?: string + from?: string + to?: string + }, + timezone: string, + ) { + const { period = '7d', from, to } = params + + try { + const safeTimezone = this.analyticsService.getSafeTimezone(timezone) + const timeBucket = getLowestPossibleTimeBucket(period, from, to) + const { groupFromUTC, groupToUTC } = this.analyticsService.getGroupFromTo( + from, + to, + timeBucket, + period, + safeTimezone, + ) + + const overviewQuery = ` + SELECT + uniqExact(profileId) as uniqueProfiles, + uniqExactIf(profileId, profileId LIKE 'usr_%') as identifiedProfiles, + uniqExact(psid) as sessions, + count(*) as pageviews + FROM analytics + WHERE pid = {pid:FixedString(12)} + AND created BETWEEN {groupFrom:String} AND {groupTo:String} + AND profileId IS NOT NULL + ` + + const topProfilesQuery = ` + SELECT + profileId, + uniqExact(psid) as sessions, + count(*) as pageviews, + max(created) as lastSeen + FROM analytics + WHERE pid = {pid:FixedString(12)} + AND created BETWEEN {groupFrom:String} AND {groupTo:String} + AND profileId IS NOT NULL + GROUP BY profileId + ORDER BY pageviews DESC + LIMIT 10 + ` + + const topPagesQuery = ` + SELECT pg as name, uniqExact(profileId) as profiles + FROM analytics + WHERE pid = {pid:FixedString(12)} + AND created BETWEEN {groupFrom:String} AND {groupTo:String} + AND profileId IS NOT NULL + AND pg IS NOT NULL AND pg != '' + GROUP BY pg + ORDER BY profiles DESC + LIMIT 10 + ` + + const params_ = { pid, groupFrom: groupFromUTC, groupTo: groupToUTC } + + const [overview, topProfiles, topPages] = await Promise.all([ + clickhouse + .query({ query: overviewQuery, query_params: params_ }) + .then((r) => r.json()), + clickhouse + .query({ query: topProfilesQuery, query_params: params_ }) + .then((r) => r.json()), + clickhouse + .query({ query: topPagesQuery, query_params: params_ }) + .then((r) => r.json()), + ]) + + const stats = (overview.data as any)[0] || {} + const uniqueProfiles = Number(stats.uniqueProfiles) || 0 + const sessions = Number(stats.sessions) || 0 + + return { + overview: { + uniqueProfiles, + identifiedProfiles: Number(stats.identifiedProfiles) || 0, + sessions, + pageviews: Number(stats.pageviews) || 0, + sessionsPerProfile: + uniqueProfiles > 0 + ? Math.round((sessions / uniqueProfiles) * 100) / 100 + : 0, + }, + topProfiles: (topProfiles.data as any[]).map((p) => ({ + ...p, + isIdentified: + typeof p.profileId === 'string' && p.profileId.startsWith('usr_'), + })), + topPages: topPages.data, + period: { from: groupFromUTC, to: groupToUTC }, + } + } catch (error) { + this.logger.error( + { error, pid, params }, + 'Error fetching profiles overview', + ) + return { error: 'Failed to fetch profiles overview' } + } + } + private buildFilterConditions( filters: Array<{ column: string diff --git a/backend/apps/cloud/src/ai/dto/chat.dto.ts b/backend/apps/cloud/src/ai/dto/chat.dto.ts index 28c03be6e..7f9f0a578 100644 --- a/backend/apps/cloud/src/ai/dto/chat.dto.ts +++ b/backend/apps/cloud/src/ai/dto/chat.dto.ts @@ -122,6 +122,31 @@ export class GetRecentChatsQueryDto { limit?: number } +export class FeedbackDto { + @ApiProperty({ + enum: ['good', 'bad'], + description: 'User feedback on the AI response', + }) + @IsNotEmpty() + @IsIn(['good', 'bad']) + rating: 'good' | 'bad' + + @ApiProperty({ required: false, description: 'Optional comment' }) + @IsOptional() + @IsString() + @MaxLength(2000) + comment?: string + + @ApiProperty({ + required: false, + description: 'Index of the assistant message the rating refers to', + }) + @IsOptional() + @IsInt() + @Min(0) + messageIndex?: number +} + export class GetAllChatsQueryDto { @ApiProperty({ required: false, diff --git a/web/app/pages/Project/tabs/AskAI/AIChart.tsx b/web/app/pages/Project/tabs/AskAI/AIChart.tsx index 53bfc090e..6a23e7df2 100644 --- a/web/app/pages/Project/tabs/AskAI/AIChart.tsx +++ b/web/app/pages/Project/tabs/AskAI/AIChart.tsx @@ -36,6 +36,34 @@ const CHART_COLORS = [ '#db2777', // pink-600 ] +const escapeHtml = (str: string) => + str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + +const formatTooltipValue = (value: number): string => { + if (!Number.isFinite(value)) return '0' + if (Math.abs(value) >= 1_000_000) return `${(value / 1_000_000).toFixed(2)}M` + if (Math.abs(value) >= 1_000) return `${(value / 1_000).toFixed(2)}K` + return value.toLocaleString() +} + +const formatTooltipDate = (x: Date | string, granularity: number) => { + const d = dayjs(x) + if (!d.isValid()) return String(x) + if (granularity <= 24) return d.format('MMM D, YYYY HH:mm') + return d.format('MMM D, YYYY') +} + +const isLikelyDate = (val: string): boolean => { + if (dayjs(val).isValid()) return true + const parsed = new Date(val) + return !Number.isNaN(parsed.getTime()) +} + const calculateOptimalTicks = ( data: number[], targetCount: number = 6, @@ -168,19 +196,38 @@ const AIChart: React.FC = ({ chart }) => { legend: { show: true, position: 'right', - }, - tooltip: { - format: { - value: (value: number, ratio: number) => { - const percentage = (ratio * 100).toFixed(1) - if (value >= 1000000) - return `${(value / 1000000).toFixed(2)}M (${percentage}%)` - if (value >= 1000) - return `${(value / 1000).toFixed(2)}K (${percentage}%)` - return `${value.toLocaleString()} (${percentage}%)` + item: { + tile: { + type: 'circle', + width: 10, + r: 3, }, }, }, + tooltip: { + contents: (( + items: any[], + _t: any, + _v: any, + color: (id: string) => string, + ) => { + const rows = items + .map((el: any) => { + const ratio = typeof el.ratio === 'number' ? el.ratio : 0 + const percentage = (ratio * 100).toFixed(1) + return ` +
  • +
    +
    + ${escapeHtml(el.name)} +
    + ${formatTooltipValue(el.value)} (${percentage}%) +
  • ` + }) + .join('') + return `
      ${rows}
    ` + }) as any, + }, padding: { right: 20, }, @@ -197,10 +244,18 @@ const AIChart: React.FC = ({ chart }) => { (key) => key !== 'x' && key !== 'labels' && key !== 'values', ) + const isDateAxis = xData.length > 0 && isLikelyDate(xData[0]) + const columns: any[] = [ [ 'x', - ..._map(xData, (el) => (dayjs(el).isValid() ? dayjs(el).toDate() : el)), + ..._map(xData, (el) => { + if (!isDateAxis) return el + const d = dayjs(el) + if (d.isValid()) return d.toDate() + const parsed = new Date(el) + return Number.isNaN(parsed.getTime()) ? el : parsed + }), ], ] @@ -231,7 +286,7 @@ const AIChart: React.FC = ({ chart }) => { const optimalTicks = allYValues.length > 0 ? calculateOptimalTicks(allYValues) : undefined - const isDateAxis = xData.length > 0 && dayjs(xData[0]).isValid() + const isBar = chart.chartType === 'bar' return { data: { @@ -254,6 +309,7 @@ const AIChart: React.FC = ({ chart }) => { axis: { x: { type: isDateAxis ? 'timeseries' : 'category', + clipPath: false, tick: { fit: true, rotate: xData.length > 10 ? 45 : 0, @@ -262,11 +318,8 @@ const AIChart: React.FC = ({ chart }) => { const d = dayjs(x) if (xData.length <= 24) { return d.format('HH:mm') - } else if (xData.length <= 31) { - return d.format('MMM D') - } else { - return d.format('MMM D') } + return d.format('MMM D') } : undefined, }, @@ -275,47 +328,90 @@ const AIChart: React.FC = ({ chart }) => { tick: { values: optimalTicks, format: (d: number) => { - if (d >= 1000000) return `${(d / 1000000).toFixed(1)}M` - if (d >= 1000) return `${(d / 1000).toFixed(1)}K` + if (d >= 1_000_000) return `${(d / 1_000_000).toFixed(1)}M` + if (d >= 1_000) return `${(d / 1_000).toFixed(1)}K` return d.toFixed(0) }, }, min: 0, padding: { bottom: 0 }, + inner: true, + show: true, }, }, - point: { - r: 3, - focus: { - expand: { - r: 5, + point: isBar + ? {} + : { + focus: { + only: xData.length > 1, + }, + pattern: ['circle'], + r: 4, }, - }, - }, legend: { show: seriesKeys.length > 1, position: 'bottom', - inset: { - anchor: 'top-right', - x: 10, - y: 10, - step: 1, + item: { + tile: { + type: 'circle', + width: 10, + r: 3, + }, + }, + }, + area: { + linearGradient: true, + }, + bar: { + linearGradient: true, + radius: { + ratio: 0.15, }, }, tooltip: { - format: { - title: (x: Date | string) => { - if (x instanceof Date) { - return dayjs(x).format('MMM D, YYYY HH:mm') + contents: (( + items: any[], + _t: any, + _v: any, + color: (id: string) => string, + ) => { + let titleStr = '' + if (items.length) { + const first = items[0] + if (first.x instanceof Date) { + titleStr = formatTooltipDate(first.x, xData.length) + } else { + const idx = + typeof first.index === 'number' + ? first.index + : typeof first.x === 'number' + ? first.x + : 0 + const original = xData[idx] ?? first.x + titleStr = isDateAxis + ? formatTooltipDate(original as string, xData.length) + : String(original ?? '') } - return String(x) - }, - value: (value: number) => { - if (value >= 1000000) return `${(value / 1000000).toFixed(2)}M` - if (value >= 1000) return `${(value / 1000).toFixed(2)}K` - return value.toLocaleString() - }, - }, + } + const rows = items + .map((el: any) => { + const numVal = + typeof el.value === 'number' ? el.value : Number(el.value) || 0 + return ` +
  • +
    +
    + ${escapeHtml(el.name)} +
    + ${formatTooltipValue(numVal)} +
  • ` + }) + .join('') + return `
      +
    • ${escapeHtml(titleStr)}
    • + ${rows} +
    ` + }) as any, }, padding: { right: 20, @@ -340,13 +436,13 @@ const AIChart: React.FC = ({ chart }) => { } return ( -
    +
    {chart.title ? (

    {chart.title}

    ) : null} -
    +
    diff --git a/web/app/pages/Project/tabs/AskAI/AskAIView.tsx b/web/app/pages/Project/tabs/AskAI/AskAIView.tsx index cfcbacbec..9f962654d 100644 --- a/web/app/pages/Project/tabs/AskAI/AskAIView.tsx +++ b/web/app/pages/Project/tabs/AskAI/AskAIView.tsx @@ -2,7 +2,7 @@ import _filter from 'lodash/filter' import _isEmpty from 'lodash/isEmpty' import _map from 'lodash/map' import { - PaperPlaneIcon, + ArrowUpIcon, CaretDownIcon, CaretRightIcon, SpinnerGapIcon, @@ -19,6 +19,16 @@ import { TrashIcon, XIcon, LinkIcon, + CopyIcon, + ArrowCounterClockwiseIcon, + ThumbsUpIcon, + ThumbsDownIcon, + PencilSimpleIcon, + ShieldIcon, + FlagIcon, + FlaskIcon, + UsersIcon, + ListBulletsIcon, } from '@phosphor-icons/react' import { marked } from 'marked' import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react' @@ -71,24 +81,50 @@ marked.setOptions({ gfm: true, }) -const parseCharts = (content: string): { text: string; charts: any[] } => { - const charts: any[] = [] - let text = content +type ContentSegment = + | { kind: 'text'; text: string } + | { kind: 'chart'; chart: any; pending?: boolean } - const chartStartPattern = '{"type":"chart"' - let searchIndex = 0 +const CHART_START_PATTERN = '{"type":"chart"' - while (searchIndex < text.length) { - const startIndex = text.indexOf(chartStartPattern, searchIndex) - if (startIndex === -1) break +const parseSegments = (content: string): ContentSegment[] => { + const segments: ContentSegment[] = [] + let cursor = 0 + + while (cursor < content.length) { + const startIndex = content.indexOf(CHART_START_PATTERN, cursor) + if (startIndex === -1) { + const tail = content.slice(cursor) + if (tail) segments.push({ kind: 'text', text: tail }) + break + } + + if (startIndex > cursor) { + segments.push({ kind: 'text', text: content.slice(cursor, startIndex) }) + } let braceCount = 0 let endIndex = -1 + let inString = false + let escape = false - for (let i = startIndex; i < text.length; i++) { - if (text[i] === '{') { - braceCount++ - } else if (text[i] === '}') { + for (let i = startIndex; i < content.length; i++) { + const ch = content[i] + if (escape) { + escape = false + continue + } + if (ch === '\\') { + escape = true + continue + } + if (ch === '"') { + inString = !inString + continue + } + if (inString) continue + if (ch === '{') braceCount++ + else if (ch === '}') { braceCount-- if (braceCount === 0) { endIndex = i @@ -98,27 +134,43 @@ const parseCharts = (content: string): { text: string; charts: any[] } => { } if (endIndex === -1) { - searchIndex = startIndex + chartStartPattern.length - continue + // Chart JSON still streaming – keep raw text out of view to avoid showing JSON + segments.push({ kind: 'chart', chart: null, pending: true }) + cursor = content.length + break } - const jsonString = text.substring(startIndex, endIndex + 1) - + const jsonString = content.substring(startIndex, endIndex + 1) try { const chartData = JSON.parse(jsonString) - if (chartData.type === 'chart') { - charts.push(chartData) - text = text.substring(0, startIndex) + text.substring(endIndex + 1) - continue + if (chartData?.type === 'chart') { + segments.push({ kind: 'chart', chart: chartData }) + } else { + segments.push({ kind: 'text', text: jsonString }) } } catch { - // Invalid JSON, skip + segments.push({ kind: 'chart', chart: null, pending: true }) } + cursor = endIndex + 1 + } - searchIndex = startIndex + chartStartPattern.length + // Trim leading/trailing whitespace-only text segments + while ( + segments.length && + segments[0].kind === 'text' && + !segments[0].text.trim() + ) { + segments.shift() + } + while ( + segments.length && + segments[segments.length - 1].kind === 'text' && + !(segments[segments.length - 1] as { text: string }).text.trim() + ) { + segments.pop() } - return { text: text.trim(), charts } + return segments } const renderMarkdown = (content: string): string => { @@ -160,58 +212,26 @@ const getToolInfo = ( label: t('project.askAi.tools.getFunnelData'), icon: GitBranchIcon, }, + getFeatureFlagStats: { + label: t('project.askAi.tools.getFeatureFlagStats'), + icon: FlagIcon, + }, + getExperimentResults: { + label: t('project.askAi.tools.getExperimentResults'), + icon: FlaskIcon, + }, + getSessionsList: { + label: t('project.askAi.tools.getSessionsList'), + icon: ListBulletsIcon, + }, + getProfilesOverview: { + label: t('project.askAi.tools.getProfilesOverview'), + icon: UsersIcon, + }, } return toolMap[toolName] || { label: toolName, icon: InfoIcon } } -const getAvailableTools = (t: any) => [ - { - id: 'getData', - label: t('project.askAi.tools.queryData'), - icon: ChartBarIcon, - }, - { - id: 'getGoalStats', - label: t('project.askAi.tools.goalStats'), - icon: TargetIcon, - }, - { - id: 'getFunnelData', - label: t('project.askAi.tools.funnelData'), - icon: GitBranchIcon, - }, -] - -const ToolsTooltip = () => { - const { t } = useTranslation('common') - const AVAILABLE_TOOLS = getAvailableTools(t) - return ( -
    - {_map(AVAILABLE_TOOLS, (tool) => ( -
    - - {tool.label} -
    - ))} -
    - ) -} - -const ToolsIndicator = () => { - const { t } = useTranslation('common') - const AVAILABLE_TOOLS = getAvailableTools(t) - return ( - } - tooltipNode={ - - {t('project.askAi.tools.count', { count: AVAILABLE_TOOLS.length })} - - } - /> - ) -} - const AICapabilitiesTooltip = () => { const { t } = useTranslation('common') @@ -262,10 +282,54 @@ const AICapabilitiesTooltip = () => { +
  • + + + + {t('project.askAi.capabilities.captchaStats')} + + +
  • +
  • + + + + {t('project.askAi.capabilities.featureFlags')} + + +
  • +
  • + + + + {t('project.askAi.capabilities.experiments')} + + +
  • +
  • + + + + {t('project.askAi.capabilities.sessions')} + + +
  • +
  • + + + + {t('project.askAi.capabilities.customEvents')} + + +
  • {t('project.askAi.capabilities.trafficPatterns')}
  • +
  • + + {t('project.askAi.capabilities.customRanges')} +
  • @@ -399,39 +463,64 @@ const MessageContent = ({ content: string isStreaming?: boolean }) => { - const { text, charts } = useMemo(() => parseCharts(content), [content]) + const segments = useMemo(() => parseSegments(content), [content]) + const hasAnyContent = segments.length > 0 return ( - <> - {text ? ( -
    - ) : null} - {isStreaming && !text ? ( +
    + {_map(segments, (segment, idx) => { + if (segment.kind === 'text') { + if (!segment.text.trim()) return null + return ( +
    + ) + } + if (segment.kind === 'chart' && segment.chart) { + return + } + if (segment.kind === 'chart' && segment.pending) { + return ( +
    + + Rendering chart... +
    + ) + } + return null + })} + {isStreaming && !hasAnyContent ? ( ) : null} - {!_isEmpty(charts) ? ( -
    - {_map(charts, (chart, idx) => ( - - ))} -
    - ) : null} - +
    ) } const AssistantMessage = ({ message, isStreaming, + onRegenerate, + onFeedback, + feedback, + canRegenerate, }: { message: Message isStreaming?: boolean + onRegenerate?: () => void + onFeedback?: (rating: 'good' | 'bad') => void + feedback?: 'good' | 'bad' | null + canRegenerate?: boolean }) => { + const { t } = useTranslation('common') const [userToggled, setUserToggled] = useState(false) const [userExpandedState, setUserExpandedState] = useState(false) + const [copied, setCopied] = useState(false) const hasContent = Boolean(message.content && message.content.trim()) const isActivelyThinking = Boolean( @@ -444,16 +533,24 @@ const AssistantMessage = ({ setUserExpandedState(!isThoughtExpanded) } - // If we have parts, render them in sequence; otherwise fall back to old behavior + const handleCopy = () => { + if (!message.content) return + navigator.clipboard + .writeText(message.content) + .then(() => { + setCopied(true) + setTimeout(() => setCopied(false), 1500) + }) + .catch(() => toast.error(t('project.askAi.error'))) + } + const hasParts = message.parts && message.parts.length > 0 - // Determine if a tool call is still loading (it's the last part and we're streaming with no content after it) const isToolCallLoading = (partIndex: number) => { if (!isStreaming || !message.parts) return false const isLastPart = partIndex === message.parts.length - 1 const part = message.parts[partIndex] if (part.type !== 'toolCall') return false - // Check if there's any text content after this tool call const hasTextAfter = message.parts .slice(partIndex + 1) .some((p) => p.type === 'text' && p.text?.trim()) @@ -470,7 +567,6 @@ const AssistantMessage = ({ onToggle={handleToggle} /> {hasParts ? ( - // Render parts in sequence <> {_map(message.parts, (part, idx) => { if (part.type === 'text' && part.text) { @@ -502,7 +598,6 @@ const AssistantMessage = ({ })} ) : ( - // Fall back to old behavior for messages without parts (e.g., loaded from saved chats) <> {message.toolCalls && message.toolCalls.length > 0 ? (
    @@ -518,15 +613,200 @@ const AssistantMessage = ({ )} + + {!isStreaming && hasContent ? ( +
    + + {onRegenerate && canRegenerate ? ( + + ) : null} + {onFeedback ? ( + <> + + + + ) : null} +
    + ) : null}
    ) } -const UserMessage = ({ content }: { content: string }) => { +const UserMessage = ({ + content, + onEdit, + isLoading, +}: { + content: string + onEdit?: (newContent: string) => void + isLoading?: boolean +}) => { + const { t } = useTranslation('common') + const [copied, setCopied] = useState(false) + const [isEditing, setIsEditing] = useState(false) + const [draft, setDraft] = useState(content) + const editRef = useRef(null) + + useEffect(() => { + if (isEditing && editRef.current) { + editRef.current.focus() + editRef.current.style.height = 'auto' + editRef.current.style.height = `${editRef.current.scrollHeight}px` + } + }, [isEditing]) + + const handleCopy = () => { + navigator.clipboard + .writeText(content) + .then(() => { + setCopied(true) + setTimeout(() => setCopied(false), 1500) + }) + .catch(() => toast.error(t('project.askAi.error'))) + } + + const handleSaveEdit = () => { + const trimmed = draft.trim() + if (!trimmed || trimmed === content || !onEdit) { + setIsEditing(false) + setDraft(content) + return + } + onEdit(trimmed) + setIsEditing(false) + } + + const handleCancelEdit = () => { + setDraft(content) + setIsEditing(false) + } + + if (isEditing) { + return ( +
    +
    +