diff --git a/backend/apps/cloud/src/ai/ai-chat.service.ts b/backend/apps/cloud/src/ai/ai-chat.service.ts index b03e079e6..5d9d1d76e 100644 --- a/backend/apps/cloud/src/ai/ai-chat.service.ts +++ b/backend/apps/cloud/src/ai/ai-chat.service.ts @@ -2,6 +2,16 @@ import { Injectable } from '@nestjs/common' import { InjectRepository } from '@nestjs/typeorm' import { Repository, FindManyOptions, FindOneOptions } from 'typeorm' import { AiChat, ChatMessage } from './entity/ai-chat.entity' +import { MAX_TAGS_PER_CHAT, MAX_TAG_LENGTH } from './dto/chat.dto' + +interface ListChatsOptions { + search?: string + tag?: string + pinned?: boolean + skip?: number + take?: number + orderByPinned?: boolean +} @Injectable() export class AiChatService { @@ -31,32 +41,142 @@ export class AiChatService { .createQueryBuilder('chat') .where('chat.projectId = :projectId', { projectId }) .andWhere('chat.userId = :userId', { userId }) - .orderBy('chat.updated', 'DESC') + .orderBy('chat.pinned', 'DESC') + .addOrderBy('chat.updated', 'DESC') .take(limit) return queryBuilder.getMany() } - async findAllByProject( + async listByProject( projectId: string, userId: string | null, - skip: number = 0, - take: number = 20, + options: ListChatsOptions = {}, ): Promise<{ chats: AiChat[]; total: number }> { if (!userId) { return { chats: [], total: 0 } } - const queryBuilder = this.aiChatRepository + const { + search, + tag, + pinned, + skip = 0, + take = 20, + orderByPinned = true, + } = options + + const baseQuery = () => + this.aiChatRepository + .createQueryBuilder('chat') + .where('chat.projectId = :projectId', { projectId }) + .andWhere('chat.userId = :userId', { userId }) + + const applyTagAndPinned = (qb: ReturnType) => { + if (typeof pinned === 'boolean') { + qb.andWhere('chat.pinned = :pinned', { pinned }) + } + if (tag) { + // simple-array stores tags as a comma-separated string + qb.andWhere('(FIND_IN_SET(:tag, chat.tags) > 0 OR chat.tags = :tag)', { + tag, + }) + } + return qb + } + + const orderAndPaginate = (qb: ReturnType) => { + if (orderByPinned) { + qb.orderBy('chat.pinned', 'DESC').addOrderBy('chat.updated', 'DESC') + } else { + qb.orderBy('chat.updated', 'DESC') + } + return qb.skip(skip).take(take) + } + + if (search && search.trim().length > 0) { + // Escape backslash first, then LIKE metacharacters, so a user typing + // '%' / '_' matches the literal characters instead of acting as wildcards. + const escaped = search + .trim() + .replace(/\\/g, '\\\\') + .replace(/%/g, '\\%') + .replace(/_/g, '\\_') + const term = `%${escaped}%` + + const nameQb = applyTagAndPinned(baseQuery()).andWhere( + "chat.name LIKE :term ESCAPE '\\\\'", + { term }, + ) + + const [nameChats, nameTotal] = + await orderAndPaginate(nameQb).getManyAndCount() + + if (nameTotal > 0) { + return { chats: nameChats, total: nameTotal } + } + + // Fallback to content search across messages JSON + const contentQb = applyTagAndPinned(baseQuery()).andWhere( + "CAST(chat.messages AS CHAR) LIKE :term ESCAPE '\\\\'", + { term }, + ) + + const [contentChats, contentTotal] = + await orderAndPaginate(contentQb).getManyAndCount() + return { chats: contentChats, total: contentTotal } + } + + const qb = orderAndPaginate(applyTagAndPinned(baseQuery())) + const [chats, total] = await qb.getManyAndCount() + return { chats, total } + } + + /** + * @deprecated Use {@link listByProject} instead. + */ + async findAllByProject( + projectId: string, + userId: string | null, + skip: number = 0, + take: number = 20, + ): Promise<{ chats: AiChat[]; total: number }> { + return this.listByProject(projectId, userId, { skip, take }) + } + + async listTagsByProject( + projectId: string, + userId: string | null, + ): Promise { + if (!userId) return [] + + const rows = await this.aiChatRepository .createQueryBuilder('chat') + .select('chat.tags', 'tags') .where('chat.projectId = :projectId', { projectId }) .andWhere('chat.userId = :userId', { userId }) - .orderBy('chat.updated', 'DESC') - .skip(skip) - .take(take) + .andWhere('chat.tags IS NOT NULL') + .andWhere("chat.tags <> ''") + .getRawMany<{ tags: string | null }>() - const [chats, total] = await queryBuilder.getManyAndCount() - return { chats, total } + // Dedupe case-insensitively while preserving the first-seen casing + const map = new Map() + for (const row of rows) { + if (!row.tags) continue + // simple-array is comma-separated + const parts = String(row.tags) + .split(',') + .map((t) => t.trim()) + .filter(Boolean) + for (const part of parts) { + const key = part.toLowerCase() + if (!map.has(key)) map.set(key, part) + } + } + + return Array.from(map.values()).sort((a, b) => + a.localeCompare(b, undefined, { sensitivity: 'base' }), + ) } async create(data: { @@ -64,16 +184,59 @@ export class AiChatService { userId: string | null messages: ChatMessage[] name?: string + parentChatId?: string | null }): Promise { const chat = this.aiChatRepository.create({ project: { id: data.projectId }, user: data.userId ? { id: data.userId } : null, messages: data.messages, name: data.name || this.generateChatName(data.messages), + parentChatId: data.parentChatId ?? null, }) return this.aiChatRepository.save(chat) } + async findParentSummary( + parentChatId: string, + projectId: string, + ): Promise<{ id: string; name: string | null } | null> { + const parent = await this.aiChatRepository + .createQueryBuilder('chat') + .select(['chat.id', 'chat.name']) + .where('chat.id = :parentChatId', { parentChatId }) + .andWhere('chat.projectId = :projectId', { projectId }) + .getOne() + if (!parent) return null + return { id: parent.id, name: parent.name } + } + + /** + * Atomically updates the chat name only when the current value still matches + * `expectedName`. Used by background title generation so it can't clobber a + * user-provided rename that happened concurrently. Returns true if the row + * was actually updated. + */ + async updateIfNameEquals( + id: string, + expectedName: string | null | undefined, + data: { name: string }, + ): Promise { + const qb = this.aiChatRepository + .createQueryBuilder() + .update(AiChat) + .set({ name: data.name }) + .where('id = :id', { id }) + + if (expectedName === null || expectedName === undefined) { + qb.andWhere('name IS NULL') + } else { + qb.andWhere('name = :expectedName', { expectedName }) + } + + const result = await qb.execute() + return (result.affected ?? 0) > 0 + } + async update( id: string, data: { messages?: ChatMessage[]; name?: string }, @@ -95,6 +258,55 @@ export class AiChatService { return this.aiChatRepository.save(chat) } + /** + * Sanitises a list of user-supplied tag labels: + * - trims, drops empty entries + * - enforces per-tag length cap + * - dedupes case-insensitively (keeping first occurrence) + * - caps total tags at MAX_TAGS_PER_CHAT + * + * Returns null for an empty result so the simple-array column persists as NULL + * (avoids round-tripping `[]` → `''` → `['']`). + */ + sanitiseTags(input: unknown): string[] | null { + if (!Array.isArray(input)) return null + const seen = new Set() + const out: string[] = [] + for (const raw of input) { + if (typeof raw !== 'string') continue + // simple-array uses comma as separator, so strip commas defensively + const trimmed = raw.replace(/,/g, '').trim().slice(0, MAX_TAG_LENGTH) + if (!trimmed) continue + const key = trimmed.toLowerCase() + if (seen.has(key)) continue + seen.add(key) + out.push(trimmed) + if (out.length >= MAX_TAGS_PER_CHAT) break + } + return out.length === 0 ? null : out + } + + async updateMeta( + id: string, + data: { pinned?: boolean; tags?: string[]; name?: string }, + ): Promise { + const chat = await this.aiChatRepository.findOne({ where: { id } }) + if (!chat) return null + + if (typeof data.pinned === 'boolean') { + chat.pinned = data.pinned + } + if (data.tags !== undefined) { + chat.tags = this.sanitiseTags(data.tags) + } + if (data.name !== undefined) { + const trimmed = data.name.trim() + chat.name = trimmed.length > 0 ? trimmed : null + } + + return this.aiChatRepository.save(chat) + } + async delete(id: string): Promise { const result = await this.aiChatRepository.delete(id) return (result.affected ?? 0) > 0 @@ -171,13 +383,18 @@ export class AiChatService { chatId: string, projectId: string, ): Promise { - return this.aiChatRepository.findOne({ - where: { - id: chatId, - project: { id: projectId }, - }, - relations: ['user'], - }) + return this.aiChatRepository + .createQueryBuilder('chat') + .leftJoinAndSelect('chat.user', 'user') + .leftJoin( + 'chat.parentChat', + 'parentChat', + 'parentChat.projectId = chat.projectId', + ) + .addSelect(['parentChat.id', 'parentChat.name']) + .where('chat.id = :chatId', { chatId }) + .andWhere('chat.projectId = :projectId', { projectId }) + .getOne() } /** diff --git a/backend/apps/cloud/src/ai/ai.controller.ts b/backend/apps/cloud/src/ai/ai.controller.ts index a6f8fab12..663a208a4 100644 --- a/backend/apps/cloud/src/ai/ai.controller.ts +++ b/backend/apps/cloud/src/ai/ai.controller.ts @@ -3,15 +3,18 @@ import { Post, Get, Delete, + Patch, Body, Param, Query, Res, Headers, + BadRequestException, NotFoundException, ForbiddenException, HttpException, HttpStatus, + ValidationPipe, } from '@nestjs/common' import { Response } from 'express' import { @@ -27,14 +30,16 @@ import { CurrentUserId } from '../auth/decorators/current-user-id.decorator' import { ProjectService } from '../project/project.service' import { AppLoggerService } from '../logger/logger.service' import { checkRateLimit, getIPFromHeaders } from '../common/utils' -import { AiService } from './ai.service' +import { AiService, sanitiseAssistantContent } from './ai.service' import { AiChatService } from './ai-chat.service' import { ChatDto, CreateChatDto, UpdateChatDto, + UpdateChatMetaDto, GetRecentChatsQueryDto, GetAllChatsQueryDto, + FeedbackDto, } from './dto/chat.dto' import { trackCustom } from '../common/analytics' @@ -174,7 +179,16 @@ export class AiController { let hasContent = false let toolCallCount = 0 let toolResultCount = 0 + let toolErrorCount = 0 let textDeltaCount = 0 + let reasoningDeltaCount = 0 + let assistantText = '' + let streamErrored = false + let streamErrorEventCount = 0 + let stepCount = 0 + let lastModelId: string | undefined + let lastProviderId: string | undefined + const streamStartedAt = Date.now() try { for await (const part of result.fullStream) { if (clientClosed) { @@ -189,6 +203,7 @@ export class AiController { if (part.type === 'text-delta') { hasContent = true textDeltaCount++ + assistantText += part.text res.write( `data: ${JSON.stringify({ type: 'text', content: part.text })}\n\n`, ) @@ -233,10 +248,13 @@ export class AiController { ) } else if (part.type === 'reasoning-delta') { hasContent = true + reasoningDeltaCount++ res.write( `data: ${JSON.stringify({ type: 'reasoning', content: part.text })}\n\n`, ) } else if (part.type === 'error') { + streamErrored = true + streamErrorEventCount++ this.logger.error( { error: part.error, pid, uid }, 'Error event during AI stream', @@ -245,33 +263,81 @@ export class AiController { `data: ${JSON.stringify({ type: 'error', content: 'A temporary error occurred, continuing...' })}\n\n`, ) } else if (part.type === 'finish') { + const totalUsage = (part as any)?.totalUsage ?? {} + const durationMs = Date.now() - streamStartedAt + const inputTokens = totalUsage.inputTokens ?? 0 + const outputTokens = totalUsage.outputTokens ?? 0 + const totalTokens = + totalUsage.totalTokens ?? inputTokens + outputTokens + const reasoningTokens = + totalUsage.outputTokenDetails?.reasoningTokens ?? + totalUsage.reasoningTokens ?? + 0 + const cachedInputTokens = + totalUsage.inputTokenDetails?.cacheReadTokens ?? + totalUsage.cachedInputTokens ?? + 0 + const cacheWriteTokens = + totalUsage.inputTokenDetails?.cacheWriteTokens ?? 0 + this.logger.log( { pid, finishReason: (part as any).finishReason, - usage: (part as any).usage, + totalUsage, + durationMs, }, 'AI stream finish event', ) + await trackCustom( getIPFromHeaders(headers) || 'unknown', headers['user-agent'], { ev: 'AI_CHAT_STREAM_FINISHED', meta: { - finishReason: (part as any)?.finishReason, - promptTokens: (part as any)?.usage?.promptTokens ?? 0, - completionTokens: (part as any)?.usage?.completionTokens ?? 0, - totalTokens: (part as any)?.usage?.totalTokens ?? 0, + finishReason: (part as any)?.finishReason ?? 'unknown', + modelId: lastModelId, + providerId: lastProviderId, + durationMs, + inputTokens, + outputTokens, + totalTokens, + reasoningTokens, + cachedInputTokens, + cacheWriteTokens, + stepCount, + toolCallCount, + toolResultCount, + toolErrorCount, + textDeltaCount, + reasoningDeltaCount, + assistantTextLength: assistantText.length, + inboundMessageCount: messages.length, + hasContent, + streamErrored, + streamErrorEventCount, + authed: Boolean(uid), }, }, ) } else if (part.type === 'finish-step') { + stepCount++ + const stepResponse = (part as any)?.response + if (stepResponse?.modelId) { + lastModelId = stepResponse.modelId + } + const stepProviderId = + stepResponse?.providerId ?? stepResponse?.provider + if (stepProviderId) { + lastProviderId = stepProviderId + } this.logger.log( { pid, finishReason: part.finishReason, usage: part.usage, + modelId: lastModelId, }, 'AI stream finish-step event', ) @@ -294,6 +360,7 @@ export class AiController { } else if (part.type === 'tool-input-end') { this.logger.log({ pid }, 'AI tool input end') } else if (part.type === 'tool-error') { + toolErrorCount++ this.logger.error( { pid, @@ -316,6 +383,7 @@ export class AiController { 'AI stream completed - summary', ) } catch (streamError) { + streamErrored = true this.logger.error( { error: streamError, pid, uid }, 'Exception during AI stream iteration', @@ -332,6 +400,48 @@ export class AiController { } } + // Generate follow-up suggestions only when the assistant produced a real + // textual answer and the client is still connected. Capped with a short + // timeout so a slow model never delays the `done` event; on timeout we + // abort the in-flight OpenRouter request to avoid wasting quota. + if (!clientClosed && !streamErrored && assistantText.trim().length > 0) { + const FOLLOW_UPS_TIMEOUT_MS = 5_000 + const controller = new AbortController() + const timeoutHandle = setTimeout( + () => controller.abort(), + FOLLOW_UPS_TIMEOUT_MS, + ) + const onClientClose = () => controller.abort() + res.on('close', onClientClose) + try { + const followUps = await Promise.race([ + this.aiService.generateFollowUps( + [...messages, { role: 'assistant', content: assistantText }], + project, + controller.signal, + ), + new Promise((resolve) => { + controller.signal.addEventListener('abort', () => resolve([]), { + once: true, + }) + }), + ]) + if (!clientClosed && followUps.length > 0) { + res.write( + `data: ${JSON.stringify({ type: 'followUps', data: followUps })}\n\n`, + ) + } + } catch (err) { + this.logger.warn( + { err, pid, uid }, + 'Follow-up suggestion generation threw', + ) + } finally { + clearTimeout(timeoutHandle) + res.off('close', onClientClose) + } + } + res.write(`data: ${JSON.stringify({ type: 'done' })}\n\n`) res.end() @@ -356,11 +466,15 @@ export class AiController { @ApiBearerAuth() @Get(':pid/chats') @Auth(false, true) // Allow optional auth for public projects - @ApiOperation({ summary: 'Get recent AI chats for a project' }) - @ApiResponse({ status: 200, description: 'List of recent chats' }) - async getRecentChats( + @ApiOperation({ + summary: + 'List AI chats for a project (supports search, tag, pinned filters and pagination)', + }) + @ApiResponse({ status: 200, description: 'List of chats' }) + async getChats( @Param('pid') pid: string, - @Query() query: GetRecentChatsQueryDto, + @Query(new ValidationPipe({ transform: true, whitelist: true })) + query: GetRecentChatsQueryDto, @CurrentUserId() uid: string | null, @Headers() headers: Record, ) { @@ -378,31 +492,81 @@ export class AiController { // Do not expose stored chat history to unauthenticated users (even on public projects). if (!uid) { - return [] + return { chats: [], total: 0 } } - const chats = await this.aiChatService.findRecentByProject( - pid, - uid, - query.limit ?? 5, - ) + const isLimitMode = + query.limit !== undefined && + query.skip === undefined && + query.take === undefined && + !query.search && + !query.tag && + query.pinned === undefined + + const take = isLimitMode ? (query.limit ?? 5) : (query.take ?? 20) + + const result = await this.aiChatService.listByProject(pid, uid, { + search: query.search, + tag: query.tag, + pinned: query.pinned, + skip: query.skip ?? 0, + take, + orderByPinned: query.orderByPinned, + }) - return chats.map((chat) => ({ - id: chat.id, - name: chat.name, - created: chat.created, - updated: chat.updated, - })) + return { + chats: result.chats.map((chat) => ({ + id: chat.id, + name: chat.name, + pinned: chat.pinned, + tags: chat.tags ?? [], + created: chat.created, + updated: chat.updated, + })), + total: result.total, + } + } + + @ApiBearerAuth() + @Get(':pid/chats/tags') + @Auth(false, true) + @ApiOperation({ summary: 'List distinct tags across the user’s chats' }) + @ApiResponse({ status: 200, description: 'Sorted list of tag labels' }) + async getChatTags( + @Param('pid') pid: string, + @CurrentUserId() uid: string | null, + @Headers() headers: Record, + ) { + this.logger.log({ uid, pid }, 'GET /ai/:pid/chats/tags') + + await this.applyRateLimit(uid, headers, 'read') + + const project = await this.projectService.getFullProject(pid) + if (_isEmpty(project)) { + throw new NotFoundException('Project not found') + } + this.projectService.allowedToView(project, uid) + + if (!uid) { + return { tags: [] } + } + + const tags = await this.aiChatService.listTagsByProject(pid, uid) + return { tags } } @ApiBearerAuth() @Get(':pid/chats/all') @Auth(false, true) // Allow optional auth for public projects - @ApiOperation({ summary: 'Get all AI chats for a project (paginated)' }) + @ApiOperation({ + summary: + 'Get all AI chats for a project (paginated). Deprecated: use GET /:pid/chats with skip/take.', + }) @ApiResponse({ status: 200, description: 'Paginated list of chats' }) async getAllChats( @Param('pid') pid: string, - @Query() query: GetAllChatsQueryDto, + @Query(new ValidationPipe({ transform: true, whitelist: true })) + query: GetAllChatsQueryDto, @CurrentUserId() uid: string | null, @Headers() headers: Record, ) { @@ -418,22 +582,21 @@ export class AiController { this.projectService.allowedToView(project, uid) - // Do not expose stored chat history to unauthenticated users (even on public projects). if (!uid) { return { chats: [], total: 0 } } - const result = await this.aiChatService.findAllByProject( - pid, - uid, - query.skip ?? 0, - query.take ?? 20, - ) + const result = await this.aiChatService.listByProject(pid, uid, { + skip: query.skip ?? 0, + take: query.take ?? 20, + }) return { chats: result.chats.map((chat) => ({ id: chat.id, name: chat.name, + pinned: chat.pinned, + tags: chat.tags ?? [], created: chat.created, updated: chat.updated, })), @@ -474,10 +637,18 @@ export class AiController { // Check if the current user is the owner of this chat const isOwner = uid && chat.user?.id === uid + const parentChat = chat.parentChat + ? { id: chat.parentChat.id, name: chat.parentChat.name } + : null + return { id: chat.id, name: chat.name, messages: chat.messages, + pinned: chat.pinned, + tags: chat.tags ?? [], + parentChatId: chat.parentChatId, + parentChat, created: chat.created, updated: chat.updated, isOwner, @@ -507,11 +678,32 @@ export class AiController { this.projectService.allowedToView(project, uid) + const sanitisedCreateMessages = createChatDto.messages.map((m) => + m.role === 'assistant' + ? { ...m, content: sanitiseAssistantContent(m.content) } + : m, + ) + + let parentChatId: string | null = null + if (createChatDto.parentChatId) { + const parent = await this.aiChatService.findParentSummary( + createChatDto.parentChatId, + pid, + ) + if (!parent) { + throw new BadRequestException( + 'Invalid parentChatId: parent chat does not exist in this project', + ) + } + parentChatId = parent.id + } + const chat = await this.aiChatService.create({ projectId: pid, userId: uid, - messages: createChatDto.messages, + messages: sanitisedCreateMessages, name: createChatDto.name, + parentChatId, }) await trackCustom( @@ -522,15 +714,139 @@ 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) { + const expectedName = chat.name + this.aiService + .generateChatTitle(firstUserMsg) + .then((title) => + this.aiChatService.updateIfNameEquals(chat.id, expectedName, { + name: title, + }), + ) + .catch((err) => + this.logger.warn( + { err, chatId: chat.id }, + 'Background title generation failed', + ), + ) + } + } + return { id: chat.id, name: chat.name, messages: chat.messages, + parentChatId: chat.parentChatId, created: chat.created, updated: chat.updated, } } + @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 @@ -568,10 +884,18 @@ export class AiController { // Check if the current user owns this chat const isOwner = this.aiChatService.isOwner(existingChat, uid) + const sanitisedUpdateMessages = updateChatDto.messages + ? updateChatDto.messages.map((m) => + m.role === 'assistant' + ? { ...m, content: sanitiseAssistantContent(m.content) } + : m, + ) + : undefined + if (isOwner) { // User owns the chat - update it directly const chat = await this.aiChatService.update(chatId, { - messages: updateChatDto.messages, + messages: sanitisedUpdateMessages, name: updateChatDto.name, }) @@ -593,8 +917,9 @@ export class AiController { const branchedChat = await this.aiChatService.create({ projectId: pid, userId: uid, - messages: updateChatDto.messages || existingChat.messages, + messages: sanitisedUpdateMessages || existingChat.messages, name: updateChatDto.name, + parentChatId: existingChat.id, }) this.logger.log( @@ -609,6 +934,56 @@ export class AiController { created: branchedChat.created, updated: branchedChat.updated, branched: true, + parentChatId: branchedChat.parentChatId ?? existingChat.id, + } + } + + @ApiBearerAuth() + @Patch(':pid/chats/:chatId') + @Auth(false, true) // Allow optional auth for public projects + @ApiOperation({ + summary: 'Update chat metadata (pinned, tags, name) — owner only', + }) + @ApiResponse({ status: 200, description: 'Chat metadata updated' }) + async updateChatMeta( + @Param('pid') pid: string, + @Param('chatId') chatId: string, + @Body() body: UpdateChatMetaDto, + @CurrentUserId() uid: string | null, + @Headers() headers: Record, + ) { + this.logger.log({ uid, pid, chatId }, 'PATCH /ai/:pid/chats/:chatId') + + 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.verifyOwnerAccess(chatId, pid, uid) + if (!chat) { + throw new NotFoundException('Chat not found') + } + + const updated = await this.aiChatService.updateMeta(chatId, { + pinned: body.pinned, + tags: body.tags, + name: body.name, + }) + + if (!updated) { + throw new NotFoundException('Chat not found') + } + + return { + id: updated.id, + name: updated.name, + pinned: updated.pinned, + tags: updated.tags ?? [], + created: updated.created, + updated: updated.updated, } } 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..8dfedd362 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', @@ -38,10 +43,267 @@ const ALLOWED_FILTER_COLUMNS = new Set([ 'so', 'me', 'ca', + 'te', + 'co', 'lc', 'host', ]) +const ALLOWED_CHART_LINK_TABS = new Set([ + 'traffic', + 'performance', + 'errors', + 'sessions', + 'funnels', + 'goals', + 'experiments', + 'featureFlags', + 'captcha', + 'profiles', +]) + +const ALLOWED_CHART_LINK_PERIODS = new Set([ + '1h', + 'today', + 'yesterday', + '1d', + '7d', + '4w', + '3M', + '12M', + '24M', + 'all', +]) + +const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/ + +interface SanitisedChartLink { + tab: string + period?: string + from?: string + to?: string + filters?: Array<{ + column: string + filter: string + isExclusive?: boolean + isContains?: boolean + }> +} + +/** + * Defensively validates an AI-emitted chart `link` object. Returns null when the + * link is missing/invalid/unsafe so the frontend simply hides the affordance. + */ +const sanitiseChartLink = (raw: unknown): SanitisedChartLink | null => { + if (!raw || typeof raw !== 'object') return null + const link = raw as Record + + const tab = typeof link.tab === 'string' ? link.tab : null + if (!tab || !ALLOWED_CHART_LINK_TABS.has(tab)) return null + + const out: SanitisedChartLink = { tab } + + if ( + typeof link.period === 'string' && + ALLOWED_CHART_LINK_PERIODS.has(link.period) + ) { + out.period = link.period + } + + if (typeof link.from === 'string' && ISO_DATE_PATTERN.test(link.from)) { + out.from = link.from + } + if (typeof link.to === 'string' && ISO_DATE_PATTERN.test(link.to)) { + out.to = link.to + } + + if (Array.isArray(link.filters)) { + const filters = link.filters + .filter((f): f is Record => !!f && typeof f === 'object') + .map((f) => ({ + column: typeof f.column === 'string' ? f.column : '', + filter: typeof f.filter === 'string' ? f.filter : '', + isExclusive: f.isExclusive === true ? true : undefined, + isContains: f.isContains === true ? true : undefined, + })) + .filter( + (f) => + f.column && + f.filter && + ALLOWED_FILTER_COLUMNS.has(f.column) && + f.filter.length <= 500, + ) + .slice(0, 10) + + if (filters.length > 0) out.filters = filters + } + + return out +} + +interface SeriesAnomaly { + x: string + value: number + deviation: number + kind: 'spike' | 'dip' +} + +/** + * Detects anomalies in a numeric time series using a median + MAD pass + * (modified z-score style). Flags any point whose absolute deviation from + * the median is more than 3.5 MADs and returns the top 3 by deviation. + * + * Returns an empty array when: + * - inputs are mismatched/too short (<5 points) + * - MAD is 0 (constant series, would divide by zero) + * - no point exceeds the threshold + */ +const detectAnomalies = ( + values: number[], + dates: string[], +): SeriesAnomaly[] => { + if (!Array.isArray(values) || !Array.isArray(dates)) return [] + if (values.length !== dates.length) return [] + if (values.length < 5) return [] + + const numeric = values + .map((v, i) => ({ v: typeof v === 'number' ? v : Number(v), i })) + .filter(({ v }) => Number.isFinite(v)) + + if (numeric.length < 5) return [] + + const median = (arr: number[]): number => { + const n = arr.length + if (n === 0) return 0 + const mid = Math.floor(n / 2) + return n % 2 === 0 ? (arr[mid - 1] + arr[mid]) / 2 : arr[mid] + } + + const sortedValues = numeric.map(({ v }) => v).sort((a, b) => a - b) + const med = median(sortedValues) + const sortedAbsDev = sortedValues + .map((v) => Math.abs(v - med)) + .sort((a, b) => a - b) + const mad = median(sortedAbsDev) + + if (mad === 0) return [] + + return numeric + .map(({ v, i }) => ({ + v, + i, + deviation: Math.abs(v - med) / mad, + })) + .filter(({ deviation }) => deviation > 3.5) + .sort((a, b) => b.deviation - a.deviation) + .slice(0, 3) + .map(({ v, i, deviation }) => ({ + x: dates[i], + value: v, + deviation: Math.round(deviation * 100) / 100, + kind: v >= med ? 'spike' : 'dip', + })) +} + +/** + * Computes anomalies for every named numeric series in a chart payload. + * Returns undefined when no series has any flagged points so callers can + * conditionally attach the `anomalies` key. + */ +const computeChartAnomalies = ( + dates: string[], + series: Record, +): Record | undefined => { + if (!Array.isArray(dates) || dates.length === 0) return undefined + + const out: Record = {} + for (const [name, values] of Object.entries(series)) { + const anomalies = detectAnomalies(values, dates) + if (anomalies.length > 0) { + out[name] = anomalies + } + } + + return Object.keys(out).length > 0 ? out : undefined +} + +/** + * Scans a piece of assistant content for embedded chart JSON blobs and rewrites + * each one to strip invalid/unknown chart `link` fields. Charts without a valid + * link will simply lack the field (rendering hides the affordance gracefully). + */ +export const sanitiseAssistantContent = (content: string): string => { + if (!content || !content.includes('{"type":"chart"')) return content + + let cursor = 0 + let result = '' + + while (cursor < content.length) { + const start = content.indexOf('{"type":"chart"', cursor) + if (start === -1) { + result += content.slice(cursor) + break + } + + result += content.slice(cursor, start) + + let braceCount = 0 + let inString = false + let escape = false + let end = -1 + for (let i = start; 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) { + end = i + break + } + } + } + + if (end === -1) { + result += content.slice(start) + break + } + + const jsonStr = content.substring(start, end + 1) + try { + const parsed = JSON.parse(jsonStr) + if (parsed && typeof parsed === 'object' && parsed.type === 'chart') { + const safeLink = sanitiseChartLink(parsed.link) + if (safeLink) { + parsed.link = safeLink + } else if ('link' in parsed) { + delete parsed.link + } + result += JSON.stringify(parsed) + } else { + result += jsonStr + } + } catch { + result += jsonStr + } + cursor = end + 1 + } + + return result +} + // Regex pattern to validate timezone strings (only allows safe characters) // Valid timezones: UTC, America/New_York, Europe/London, Asia/Tokyo, etc. const SAFE_TIMEZONE_PATTERN = @@ -77,6 +339,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 +376,288 @@ 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 0-3 short, project-specific follow-up prompts based on the just-completed + * assistant turn. Uses TITLE_MODEL so it doesn't add noticeable latency. + * Always resolves; on failure returns an empty array. + */ + async generateFollowUps( + messages: ModelMessage[], + project: Project, + abortSignal?: AbortSignal, + ): Promise { + if (!process.env.OPENROUTER_API_KEY) { + return [] + } + + if (!messages || messages.length === 0) { + return [] + } + + if (abortSignal?.aborted) { + return [] + } + + // Take the tail of the conversation to keep the prompt cheap + const tail = messages.slice(-8) + const transcript = tail + .map((m) => { + const role = + m.role === 'user' + ? 'User' + : m.role === 'assistant' + ? 'Assistant' + : m.role === 'system' + ? 'System' + : 'Tool' + const raw = m.content + let content: string + if (typeof raw === 'string') { + content = raw + } else if (Array.isArray(raw)) { + content = raw + .map((part: any) => { + if (typeof part === 'string') return part + if (part?.type === 'text' && typeof part.text === 'string') + return part.text + return '' + }) + .join(' ') + } else { + content = '' + } + content = content.trim().replace(/\s+/g, ' ') + if (content.length > 800) content = `${content.slice(0, 800)}...` + return `${role}: ${content}` + }) + .filter((line) => line.length > line.indexOf(':') + 2) + .join('\n\n') + + if (!transcript) return [] + + try { + const { text } = await generateText({ + model: this.openrouter.chat(TITLE_MODEL), + system: + 'Given this analytics conversation, suggest up to 3 short follow-up questions the user might naturally ask next. Each must be a complete question under 70 chars, specific to the data discussed, and answerable by the same toolset (analytics, performance, errors, goals, funnels, sessions, profiles, feature flags, A/B experiments, custom events). Avoid duplicates and avoid restating questions the user already asked. Return STRICT JSON: {"followUps": string[]}. Do not wrap in markdown.', + prompt: `Project: "${project.name}"\n\nConversation:\n${transcript}`, + temperature: 0.4, + abortSignal, + }) + + const cleaned = (text || '').trim().replace(/^```(?:json)?|```$/g, '') + const jsonStart = cleaned.indexOf('{') + const jsonEnd = cleaned.lastIndexOf('}') + if (jsonStart === -1 || jsonEnd === -1 || jsonEnd <= jsonStart) { + return [] + } + + let parsed: { followUps?: unknown } + try { + parsed = JSON.parse(cleaned.slice(jsonStart, jsonEnd + 1)) + } catch { + return [] + } + + if (!Array.isArray(parsed?.followUps)) return [] + + const seen = new Set() + const followUps: string[] = [] + for (const item of parsed.followUps) { + if (typeof item !== 'string') continue + const trimmed = item.trim().replace(/\s+/g, ' ') + if (!trimmed || trimmed.length > 120) continue + const key = trimmed.toLowerCase() + if (seen.has(key)) continue + seen.add(key) + followUps.push(trimmed) + if (followUps.length === 3) break + } + return followUps + } catch (error) { + this.logger.warn( + { error, pid: project.id }, + 'Failed to generate follow-up suggestions', + ) + return [] + } + } + + /** + * 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. When a tool result includes an "anomalies" object on a chart series, briefly call out the most notable ones in prose (mention the date, the value, and whether it was a spike or dip) AND mirror them in the chart JSON via "annotations" so they are highlighted visually. Don't invent anomalies that aren't present in the tool output. +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): -{"type":"chart","chartType":"line","title":"Chart Title","data":{"x":["2024-01-01","2024-01-02"],"pageviews":[100,150],"visitors":[80,120]}} +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]},"annotations":[{"x":"2024-01-02","label":"Spike +50%","kind":"spike"}],"link":{"tab":"traffic","period":"7d"}} -For pie/donut charts (showing 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]}} +Pie / donut (proportions): +{"type":"chart","chartType":"pie","title":"Device Distribution","data":{"labels":["Desktop","Mobile","Tablet"],"values":[650,280,70]},"link":{"tab":"traffic","period":"7d"}} +{"type":"chart","chartType":"donut","title":"Traffic Sources","data":{"labels":["Organic","Direct","Referral"],"values":[450,300,150]},"link":{"tab":"traffic","period":"7d"}} 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). + +Chart "annotations" field (OPTIONAL, time-series only — line/bar/area): +- Use to highlight notable points on the x-axis such as spikes, dips, deploys, or campaign moments. Ignored for pie/donut charts. +- Schema: [{ "x": "YYYY-MM-DD" (must match a value in data.x), "label": short string (<= 30 chars), "kind"?: "spike" | "dip" }] +- Whenever a tool result includes "anomalies" for a series, surface them here. Pick a concise label (e.g. "Spike +312%", "Dip -64%", "Outage?"). +- Cap to at most 3 annotations per chart so the visual stays readable. + +Chart "link" field (REQUIRED whenever the chart corresponds to data the user can drill into in the dashboard): +- ALWAYS include "link" so the chart can be opened in the project dashboard. +- Mirror the EXACT period/from/to and filters used in the originating tool call so the dashboard view shows the same data. +- Schema: { "tab": "traffic" | "performance" | "errors" | "sessions" | "funnels" | "goals" | "experiments" | "featureFlags" | "captcha" | "profiles", "period"?: "1h"|"today"|"yesterday"|"1d"|"7d"|"4w"|"3M"|"12M"|"24M"|"all", "from"?: "YYYY-MM-DD", "to"?: "YYYY-MM-DD", "filters"?: [{"column": string, "filter": string, "isExclusive"?: boolean, "isContains"?: boolean}] } +- Pick the most relevant tab: pageviews/visitors/sessions/geo/devices => "traffic"; performance metrics => "performance"; errors => "errors"; user sessions => "sessions"; funnel charts => "funnels"; goal conversions => "goals"; A/B experiments => "experiments"; feature flags => "featureFlags"; CAPTCHA => "captcha"; profiles overview => "profiles". +- Use either "period" OR ("from" + "to"), never both. +- ALWAYS pass through every filter you used in the originating getData/tool call. If the chart is "Top countries in North America" and you filtered by cc=US, cc=CA, cc=MX, the link MUST include all three filter entries — never drop them. Same rule for any breakdown chart (e.g. "Top pages on /blog" must carry the pg filter). +- Filters are an ARRAY: include one entry per value, even when filtering on the same column multiple times. Example for the North America case: "filters":[{"column":"cc","filter":"US"},{"column":"cc","filter":"CA"},{"column":"cc","filter":"MX"}]. +- Allowed filter columns and what they mean: + - pg: page path (e.g. "/blog", "/pricing") + - cc: country code, 2-letter ISO 3166-1 alpha-2 (e.g. "US", "GB", "DE") + - rg: region / state / province name + - ct: city name + - br: browser name (e.g. "Chrome", "Safari", "Firefox") + - os: operating system name (e.g. "Windows", "macOS", "iOS", "Android") + - dv: device type — one of "desktop", "mobile", "tablet", "smarttv", "wearable", "console", "xr", "embedded" + - ref: full referrer URL + - so: UTM source (utm_source) + - me: UTM medium (utm_medium) + - ca: UTM campaign (utm_campaign) + - te: UTM term (utm_term) + - co: UTM content (utm_content) + - lc: locale / language code (e.g. "en-US", "de-DE") + - host: hostname (e.g. "example.com") +- Filter modifiers (combine freely): + - "isExclusive": true => EXCLUDE this value (NOT equal / NOT contains). + - "isContains": true => substring match (case-insensitive). Use this when filtering by a partial value such as "/blog" matching "/blog/foo", or referrers containing "google". + - Default (both omitted) is exact-match, include. +- Omit "link" only for hypothetical/illustrative charts that don't map to real dashboard data.` } 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 +669,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 +686,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 @@ -212,26 +706,30 @@ Available columns for filters: - os: operating system - dv: device type (desktop, mobile, tablet) - ref: referrer -- so: source -- me: medium -- ca: campaign +- so: utm_source +- me: utm_medium +- ca: utm_campaign +- te: utm_term +- co: utm_content - lc: locale/language -- host: hostname`, +- host: hostname + +Filter modifiers: +- isExclusive: true => exclude this value (NOT equal / NOT contains) +- isContains: true => case-insensitive substring match (e.g. pg contains "/blog")`, 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() @@ -245,6 +743,12 @@ Available columns for filters: .boolean() .optional() .describe('If true, exclude this value'), + isContains: z + .boolean() + .optional() + .describe( + 'If true, case-insensitive substring match instead of exact equality', + ), }), ) .optional() @@ -263,6 +767,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 +800,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 +837,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 +883,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 +1038,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 @@ -453,6 +1081,7 @@ Available columns for filters: column?: string filter?: string isExclusive?: boolean + isContains?: boolean }> measure?: 'average' | 'median' | 'p95' }, @@ -470,8 +1099,14 @@ Available columns for filters: // Filter out invalid filter entries const filters = rawFilters.filter( - (f): f is { column: string; filter: string; isExclusive?: boolean } => - typeof f.column === 'string' && typeof f.filter === 'string', + ( + f, + ): f is { + column: string + filter: string + isExclusive?: boolean + isContains?: boolean + } => typeof f.column === 'string' && typeof f.filter === 'string', ) try { @@ -525,7 +1160,35 @@ Available columns for filters: } if (dataType === 'errors') { - return this.getErrorsData(pid, groupFromUTC, groupToUTC, safeTimezone) + return this.getErrorsData( + pid, + groupFromUTC, + groupToUTC, + safeTimezone, + filters, + ) + } + + if (dataType === 'captcha') { + return this.getCaptchaData( + pid, + groupFromUTC, + groupToUTC, + timeBucket, + safeTimezone, + filters, + ) + } + + if (dataType === 'customEvents') { + return this.getCustomEventsData( + pid, + groupFromUTC, + groupToUTC, + timeBucket, + safeTimezone, + filters, + ) } return { error: 'Unsupported data type' } @@ -545,6 +1208,7 @@ Available columns for filters: column: string filter: string isExclusive?: boolean + isContains?: boolean }>, ) { const filterConditions = this.buildFilterConditions(filters) @@ -697,12 +1361,18 @@ Available columns for filters: }) .then((r) => r.json()) + const dates = _map(chartData, (d: any) => d.date) as string[] + const pageviews = _map(chartData, (d: any) => Number(d.pageviews) || 0) + const sessions = _map(chartData, (d: any) => Number(d.sessions) || 0) + const anomalies = computeChartAnomalies(dates, { pageviews, sessions }) + return { overall: overallData[0] || {}, chart: { - x: _map(chartData, (d: any) => d.date), - pageviews: _map(chartData, (d: any) => d.pageviews), - sessions: _map(chartData, (d: any) => d.sessions), + x: dates, + pageviews, + sessions, + ...(anomalies ? { anomalies } : {}), }, topPages, topCountries, @@ -723,6 +1393,7 @@ Available columns for filters: column: string filter: string isExclusive?: boolean + isContains?: boolean }>, measure: string, ) { @@ -790,12 +1461,18 @@ Available columns for filters: }) .then((r) => r.json()) + const dates = _map(chartData, (d: any) => d.date) as string[] + const pageLoad = _map(chartData, (d: any) => Number(d.pageLoad) || 0) + const ttfb = _map(chartData, (d: any) => Number(d.ttfb) || 0) + const anomalies = computeChartAnomalies(dates, { pageLoad, ttfb }) + return { overall: overallData[0] || {}, chart: { - x: _map(chartData, (d: any) => d.date), - pageLoad: _map(chartData, (d: any) => d.pageLoad), - ttfb: _map(chartData, (d: any) => d.ttfb), + x: dates, + pageLoad, + ttfb, + ...(anomalies ? { anomalies } : {}), }, measure, period: { from: groupFrom, to: groupTo }, @@ -807,8 +1484,15 @@ Available columns for filters: groupFrom: string, groupTo: string, _timezone: string, + filters: Array<{ + column: string + filter: string + isExclusive?: boolean + isContains?: boolean + }> = [], ) { - // Get error counts + const filterConditions = this.buildFilterConditions(filters) + const overallQuery = ` SELECT count(*) as totalErrors, @@ -816,16 +1500,16 @@ Available columns for filters: FROM errors WHERE pid = {pid:FixedString(12)} AND created BETWEEN {groupFrom:String} AND {groupTo:String} + ${filterConditions.where} ` const { data: overallData } = await clickhouse .query({ query: overallQuery, - query_params: { pid, groupFrom, groupTo }, + query_params: { pid, groupFrom, groupTo, ...filterConditions.params }, }) .then((r) => r.json()) - // Get top errors const topErrorsQuery = ` SELECT name, @@ -835,6 +1519,7 @@ Available columns for filters: FROM errors WHERE pid = {pid:FixedString(12)} AND created BETWEEN {groupFrom:String} AND {groupTo:String} + ${filterConditions.where} GROUP BY name, message ORDER BY count DESC LIMIT 10 @@ -843,7 +1528,7 @@ Available columns for filters: const { data: topErrors } = await clickhouse .query({ query: topErrorsQuery, - query_params: { pid, groupFrom, groupTo }, + query_params: { pid, groupFrom, groupTo, ...filterConditions.params }, }) .then((r) => r.json()) @@ -878,7 +1563,9 @@ Available columns for filters: ) if (goalId) { - const goal = await this.goalService.findOne({ where: { id: goalId } }) + const goal = await this.goalService.findOne({ + where: { id: goalId, project: { id: pid } }, + }) if (!goal) { return { error: 'Goal not found' } } @@ -1042,30 +1729,796 @@ Available columns for filters: return conversionMap[timeBucket] || conversionMap.day } - private buildFilterConditions( + private async getCaptchaData( + pid: string, + groupFrom: string, + groupTo: string, + timeBucket: TimeBucketType, + timezone: string, filters: Array<{ column: string filter: string isExclusive?: boolean - }>, - ): { where: string; params: Record } { - if (_isEmpty(filters)) { + isContains?: boolean + }> = [], + ) { + const filterConditions = this.buildFilterConditions(filters) + + 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} + ${filterConditions.where} + ` + + 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} + ${filterConditions.where} + 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} != '' + ${filterConditions.where} + GROUP BY ${column} + ORDER BY count DESC + LIMIT 10 + ` + + const params = { + pid, + groupFrom, + groupTo, + timezone, + ...filterConditions.params, + } + + 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()), + ]) + + const dates = _map(chart.data as any[], (d) => d.date) as string[] + const challenges = _map( + chart.data as any[], + (d) => Number(d.challenges) || 0, + ) + const manuallyPassed = _map( + chart.data as any[], + (d) => Number(d.manuallyPassed) || 0, + ) + const anomalies = computeChartAnomalies(dates, { + challenges, + manuallyPassed, + }) + + return { + overall: (overall.data as any)[0] || {}, + chart: { + x: dates, + challenges, + manuallyPassed, + ...(anomalies ? { anomalies } : {}), + }, + 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 + isContains?: 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()), + ]) + + const dates = _map(chart.data as any[], (d) => d.date) as string[] + const events = _map(chart.data as any[], (d) => Number(d.events) || 0) + const sessions = _map(chart.data as any[], (d) => Number(d.sessions) || 0) + const anomalies = computeChartAnomalies(dates, { events, sessions }) + + return { + overall: (overall.data as any)[0] || {}, + topEvents: topEvents.data, + chart: { + x: dates, + events, + sessions, + ...(anomalies ? { anomalies } : {}), + }, + 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 + filter: string + isExclusive?: boolean + isContains?: boolean + }>, + ): { where: string; params: Record } { + if (_isEmpty(filters)) { return { where: '', params: {} } } - const conditions: string[] = [] - const params: Record = {} + // Group equality/contains filters by column so multiple values OR together, + // matching the dashboard's behaviour (e.g. cc=US OR cc=CA OR cc=MX). + const grouped = new Map< + string, + { + eqInclude: string[] + eqExclude: string[] + containsInclude: string[] + containsExclude: string[] + } + >() - filters.forEach((f, index) => { + filters.forEach((f) => { // Validate column name against allowlist to prevent SQL injection if (!ALLOWED_FILTER_COLUMNS.has(f.column)) { return // Skip invalid columns } - const paramName = `filter_${index}` - params[paramName] = f.filter - const operator = f.isExclusive ? '!=' : '=' - conditions.push(`${f.column} ${operator} {${paramName}:String}`) + const bucket = grouped.get(f.column) ?? { + eqInclude: [], + eqExclude: [], + containsInclude: [], + containsExclude: [], + } + + if (f.isContains && f.isExclusive) bucket.containsExclude.push(f.filter) + else if (f.isContains) bucket.containsInclude.push(f.filter) + else if (f.isExclusive) bucket.eqExclude.push(f.filter) + else bucket.eqInclude.push(f.filter) + + grouped.set(f.column, bucket) + }) + + const conditions: string[] = [] + const params: Record = {} + let paramIndex = 0 + const nextParam = (value: string): string => { + const name = `filter_${paramIndex++}` + params[name] = value + return name + } + + grouped.forEach((bucket, column) => { + if (bucket.eqInclude.length > 0) { + const placeholders = bucket.eqInclude.map( + (v) => `${column} = {${nextParam(v)}:String}`, + ) + conditions.push(`(${placeholders.join(' OR ')})`) + } + if (bucket.containsInclude.length > 0) { + const placeholders = bucket.containsInclude.map( + (v) => `${column} ILIKE concat('%', {${nextParam(v)}:String}, '%')`, + ) + conditions.push(`(${placeholders.join(' OR ')})`) + } + bucket.eqExclude.forEach((v) => { + conditions.push(`${column} != {${nextParam(v)}:String}`) + }) + bucket.containsExclude.forEach((v) => { + conditions.push( + `${column} NOT ILIKE concat('%', {${nextParam(v)}:String}, '%')`, + ) + }) }) return { diff --git a/backend/apps/cloud/src/ai/dto/chat.dto.ts b/backend/apps/cloud/src/ai/dto/chat.dto.ts index 28c03be6e..5082e377c 100644 --- a/backend/apps/cloud/src/ai/dto/chat.dto.ts +++ b/backend/apps/cloud/src/ai/dto/chat.dto.ts @@ -7,16 +7,110 @@ import { IsIn, IsOptional, IsInt, + IsBoolean, + IsUUID, Min, Max, ArrayMaxSize, MaxLength, + MinLength, + registerDecorator, + ValidationOptions, + ValidationArguments, } from 'class-validator' import { Type, Transform } from 'class-transformer' const MAX_MESSAGES_PER_CHAT = 50 const MAX_MESSAGE_LENGTH = 5000 const MAX_CHAT_NAME_LENGTH = 200 +const MAX_TOOL_CALLS_PER_MESSAGE = 50 +const MAX_TOOL_NAME_LENGTH = 100 +export const MAX_TAGS_PER_CHAT = 5 +export const MAX_TAG_LENGTH = 30 + +const MAX_TOOL_ARGS_JSON_LENGTH = 4000 +const MAX_TOOL_ARGS_DEPTH = 8 + +const measureJsonDepth = (value: unknown, depth = 0): number => { + if (depth > MAX_TOOL_ARGS_DEPTH) return depth + if (value === null || typeof value !== 'object') return depth + let max = depth + if (Array.isArray(value)) { + for (const item of value) { + const d = measureJsonDepth(item, depth + 1) + if (d > max) max = d + if (max > MAX_TOOL_ARGS_DEPTH) return max + } + return max + } + for (const key of Object.keys(value as Record)) { + const d = measureJsonDepth( + (value as Record)[key], + depth + 1, + ) + if (d > max) max = d + if (max > MAX_TOOL_ARGS_DEPTH) return max + } + return max +} + +/** + * Validates that a value is JSON-serialisable, has bounded serialised length, + * and bounded nesting depth. Used to defend the persisted chat payload from + * arbitrarily large/nested user-supplied tool args. + */ +function IsBoundedJson(validationOptions?: ValidationOptions) { + return function (object: object, propertyName: string) { + registerDecorator({ + name: 'isBoundedJson', + target: object.constructor, + propertyName, + options: validationOptions, + validator: { + validate(value: unknown) { + if (value === undefined || value === null) return true + let json: string + try { + json = JSON.stringify(value) + } catch { + return false + } + if (typeof json !== 'string') return false + if (json.length > MAX_TOOL_ARGS_JSON_LENGTH) return false + if (measureJsonDepth(value) > MAX_TOOL_ARGS_DEPTH) return false + return true + }, + defaultMessage(args: ValidationArguments) { + return `${args.property} must be JSON-serialisable, at most ${MAX_TOOL_ARGS_JSON_LENGTH} chars when stringified, and nested no deeper than ${MAX_TOOL_ARGS_DEPTH} levels` + }, + }, + }) + } +} + +class ChatMessageToolCallDto { + @ApiProperty({ description: 'Tool name that was invoked' }) + @IsNotEmpty() + @IsString() + @MaxLength(MAX_TOOL_NAME_LENGTH) + toolName: string + + @ApiProperty({ + description: 'Arguments the tool was called with (arbitrary JSON)', + }) + @IsOptional() + @IsBoundedJson() + args?: unknown + + @ApiProperty({ + required: false, + description: 'ISO timestamp when the tool call was issued', + }) + @IsOptional() + @IsString() + @MaxLength(40) + timestamp?: string +} class ChatMessageDto { @ApiProperty({ @@ -34,6 +128,31 @@ class ChatMessageDto { @IsString() @MaxLength(MAX_MESSAGE_LENGTH) content: string + + @ApiProperty({ + required: false, + type: [String], + description: 'AI-suggested follow-up prompts for this assistant message', + }) + @IsOptional() + @IsArray() + @ArrayMaxSize(3) + @IsString({ each: true }) + @MaxLength(140, { each: true }) + followUps?: string[] + + @ApiProperty({ + required: false, + type: [ChatMessageToolCallDto], + description: + 'Tool calls performed while producing this assistant message (used for the "How I got this" breakdown)', + }) + @IsOptional() + @IsArray() + @ArrayMaxSize(MAX_TOOL_CALLS_PER_MESSAGE) + @ValidateNested({ each: true }) + @Type(() => ChatMessageToolCallDto) + toolCalls?: ChatMessageToolCallDto[] } export class ChatDto { @@ -83,6 +202,15 @@ export class CreateChatDto { @IsString() @MaxLength(MAX_CHAT_NAME_LENGTH) name?: string + + @ApiProperty({ + required: false, + description: + 'ID of the chat this conversation was branched from. Must belong to the same project.', + }) + @IsOptional() + @IsUUID() + parentChatId?: string } export class UpdateChatDto { @@ -108,18 +236,141 @@ export class UpdateChatDto { name?: string } +const parseOptionalBool = ({ value }: { value: unknown }) => { + if (value === undefined || value === null || value === '') return undefined + if (typeof value === 'boolean') return value + const v = String(value).toLowerCase() + if (v === 'true' || v === '1') return true + if (v === 'false' || v === '0') return false + // Preserve the original value so @IsBoolean fails validation (400) instead + // of @IsOptional silently treating an invalid input as absent. + return value +} + export class GetRecentChatsQueryDto { @ApiProperty({ required: false, - description: 'Maximum number of chats to return (1-50)', + description: 'Maximum number of chats to return (1-100)', default: 5, }) @IsOptional() @Transform(({ value }) => parseInt(value, 10)) @IsInt() @Min(1) - @Max(50) + @Max(100) limit?: number + + @ApiProperty({ + required: false, + description: 'Number of chats to skip (0 or greater)', + }) + @IsOptional() + @Transform(({ value }) => parseInt(value, 10)) + @IsInt() + @Min(0) + @Max(10000) + skip?: number + + @ApiProperty({ + required: false, + description: 'Number of chats to return (1-100)', + }) + @IsOptional() + @Transform(({ value }) => parseInt(value, 10)) + @IsInt() + @Min(1) + @Max(100) + take?: number + + @ApiProperty({ + required: false, + description: 'Search query (matches chat names; falls back to messages)', + }) + @IsOptional() + @Transform(({ value }) => (value === '' ? undefined : value)) + @IsString() + @MinLength(2) + @MaxLength(100) + search?: string + + @ApiProperty({ + required: false, + description: 'Filter by tag (single tag string)', + }) + @IsOptional() + @IsString() + @MaxLength(MAX_TAG_LENGTH) + tag?: string + + @ApiProperty({ + required: false, + description: 'When true, only pinned chats are returned', + }) + @IsOptional() + @Transform(parseOptionalBool) + @IsBoolean() + pinned?: boolean + + @ApiProperty({ + required: false, + description: + 'When false, results are sorted by recency only (ignoring pinned status). Defaults to true.', + }) + @IsOptional() + @Transform(parseOptionalBool) + @IsBoolean() + orderByPinned?: boolean +} + +export class UpdateChatMetaDto { + @ApiProperty({ required: false, description: 'Whether the chat is pinned' }) + @IsOptional() + @IsBoolean() + pinned?: boolean + + @ApiProperty({ + required: false, + type: [String], + description: `User-defined tag labels (max ${MAX_TAGS_PER_CHAT}, ${MAX_TAG_LENGTH} chars each)`, + }) + @IsOptional() + @IsArray() + @ArrayMaxSize(MAX_TAGS_PER_CHAT) + @IsString({ each: true }) + @MaxLength(MAX_TAG_LENGTH, { each: true }) + tags?: string[] + + @ApiProperty({ required: false, description: 'Custom name for the chat' }) + @IsOptional() + @IsString() + @MaxLength(MAX_CHAT_NAME_LENGTH) + name?: string +} + +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) + @Max(MAX_MESSAGES_PER_CHAT - 1) + messageIndex?: number } export class GetAllChatsQueryDto { diff --git a/backend/apps/cloud/src/ai/entity/ai-chat.entity.ts b/backend/apps/cloud/src/ai/entity/ai-chat.entity.ts index c4d3651f3..1c183010d 100644 --- a/backend/apps/cloud/src/ai/entity/ai-chat.entity.ts +++ b/backend/apps/cloud/src/ai/entity/ai-chat.entity.ts @@ -11,9 +11,17 @@ import { ApiProperty } from '@nestjs/swagger' import { Project } from '../../project/entity/project.entity' import { User } from '../../user/entities/user.entity' +export interface ChatMessageToolCall { + toolName: string + args?: unknown + timestamp?: string +} + export interface ChatMessage { role: 'user' | 'assistant' content: string + followUps?: string[] + toolCalls?: ChatMessageToolCall[] } @Entity('ai_chat') @@ -30,6 +38,14 @@ export class AiChat { @Column('json') messages: ChatMessage[] + @ApiProperty() + @Column('boolean', { default: false }) + pinned: boolean + + @ApiProperty({ type: [String], nullable: true }) + @Column('simple-array', { nullable: true, default: null }) + tags: string[] | null + @ApiProperty({ type: () => Project }) @ManyToOne(() => Project, { onDelete: 'CASCADE' }) @JoinColumn() @@ -40,6 +56,15 @@ export class AiChat { @JoinColumn() user: User | null + @ApiProperty({ required: false, nullable: true }) + @Column('varchar', { name: 'parent_chat_id', length: 36, nullable: true }) + parentChatId: string | null + + @ApiProperty({ type: () => AiChat, required: false, nullable: true }) + @ManyToOne(() => AiChat, { nullable: true }) + @JoinColumn({ name: 'parent_chat_id' }) + parentChat: AiChat | null + @ApiProperty() @CreateDateColumn() created: Date diff --git a/backend/migrations/mysql/2026_04_21_ai_chat_parent_chat.sql b/backend/migrations/mysql/2026_04_21_ai_chat_parent_chat.sql new file mode 100644 index 000000000..e76ddc3fe --- /dev/null +++ b/backend/migrations/mysql/2026_04_21_ai_chat_parent_chat.sql @@ -0,0 +1,4 @@ +ALTER TABLE `ai_chat` + ADD COLUMN `parent_chat_id` varchar(36) DEFAULT NULL; + +CREATE INDEX `idx_ai_chat_parent_chat_id` ON `ai_chat` (`parent_chat_id`); diff --git a/backend/migrations/mysql/2026_04_21_ai_chat_pin_tags.sql b/backend/migrations/mysql/2026_04_21_ai_chat_pin_tags.sql new file mode 100644 index 000000000..a9a046b35 --- /dev/null +++ b/backend/migrations/mysql/2026_04_21_ai_chat_pin_tags.sql @@ -0,0 +1,5 @@ +ALTER TABLE `ai_chat` + ADD COLUMN `pinned` tinyint(1) NOT NULL DEFAULT 0, + ADD COLUMN `tags` text DEFAULT NULL; + +CREATE INDEX `idx_ai_chat_pinned_updated` ON `ai_chat` (`pinned`, `updated`); diff --git a/docs/content/docs/analytics-dashboard/ask-ai.mdx b/docs/content/docs/analytics-dashboard/ask-ai.mdx index c6379e485..fcb58ea22 100644 --- a/docs/content/docs/analytics-dashboard/ask-ai.mdx +++ b/docs/content/docs/analytics-dashboard/ask-ai.mdx @@ -3,27 +3,159 @@ title: Ask AI slug: /analytics-dashboard/ask-ai --- -Ask AI is a powerful feature that allows you to query your analytics data using natural language. Instead of manually filtering and analyzing complex datasets, you can simply ask a question and get immediate insights. +Ask AI is an analytics assistant that lets you query your project using natural language. Instead of building filters, switching tabs, or learning a query syntax, you can ask a question and get a written answer along with charts, tables, and direct links into the dashboard. + +Ask AI ## How it works -Ask AI uses advanced language models to understand your questions and translate them into queries against your analytics data. It can help you find trends, anomalies, and specific metrics without needing to know the underlying query language or navigate through multiple dashboard views. +Ask AI uses a large language model that has access to a set of read-only tools for your project. When you ask a question, the assistant decides which tools to call, fetches the relevant data, and answers in plain language. -Ask AI + + All data stays scoped to the current project. The assistant cannot browse the web, see data from + other projects, or modify any analytics, goals, flags, experiments or project settings. + + +You can open Ask AI from the **Ask AI** tab in your project dashboard. + +## What Ask AI can answer + +The assistant has tools for most of the data surfaces you can see elsewhere in Swetrix: + +- **Traffic analytics** — pageviews, unique visitors, sessions, top pages, countries, browsers, devices, referrers and UTM sources. +- **Performance** — page load time, TTFB, DNS, TLS and other timing metrics, with average / median / p95 measures. +- **Errors** — error counts and the most frequent errors over time. +- **Custom events** — breakdowns by event name and frequency. +- **CAPTCHA** — challenge stats, when CAPTCHA is enabled on the project. +- **Goals** — conversion counts and rates for any configured goal. +- **Funnels** — step-by-step conversion data for an existing funnel. +- **Feature flags** — total evaluations, unique profiles exposed, and true/false rates per flag. +- **Experiments** — exposures and conversions per variant for A/B tests. +- **Sessions** — a list of recent sessions with country, OS, browser, duration and pageview count. +- **Profiles** — overview of returning visitors, sessions per profile, and top pages. + +It also understands time ranges. You can ask for predefined periods ("last 7 days", "this month", "yesterday") or custom ranges ("between Jan 5 and Feb 12"). + +### What Ask AI can't do + +- Browse the web or pull in data from outside Swetrix. +- See data from other projects you have access to. +- Modify analytics, project settings, goals, flags, experiments or alerts. +- Guarantee correctness — always sanity-check important numbers in the underlying dashboards. + +## Asking a question + + + + +### Open the Ask AI tab + +Switch to the **Ask AI** tab in your project dashboard. If this is your first conversation, you'll see a few starter suggestions. + + + + +### Type or speak your question + +Type your question in the input at the bottom of the screen, click one of the suggestions, or use **voice input** by clicking the microphone icon. Speak your question and it will be transcribed into the input — click the icon again to stop. + + + Voice input uses your browser's speech recognition API and requires microphone permission. The + icon is disabled in browsers that don't support it. + + + + + +### Review the streamed answer + +The assistant streams its reasoning, calls the tools it needs, and renders the answer with charts, tables, and follow-up suggestions. You can hit **Stop** at any time while it's still generating. + + + + +### Examples + +- "How many unique visitors did we have last week compared to the week before?" +- "What are my top 10 pages in the US over the last 30 days?" +- "Compare conversion rate of mobile vs desktop users for the Signup goal." +- "Show me a pie chart of the most common device types this month." +- "Which feature flag has the highest exposure right now?" +- "How is the checkout button experiment performing?" +- "Show me recent sessions from Germany on Safari." + +## Reading the response + +### Charts + +When a chart helps answer your question, the assistant renders one inline. Each chart has a small toolbar in the top-right corner that lets you: + +- Switch between **line, area, spline, bar, pie and donut** views (when applicable). +- **Download the chart as PNG**. +- **Download the underlying data as CSV**. +- **Copy the data** to your clipboard. +- **Open in dashboard** — jumps to the main analytics view with the same time range and filters applied, so you can drill in further. + +Ask AI chart toolbar + +### Anomaly detection + +Ask AI automatically scans time-series results for unusual spikes and dips. When something stands out (using a robust median + MAD test), the assistant calls it out in prose and highlights the affected points directly on the chart with a short label such as "Spike +312%" or "Dip −64%". This makes it easy to spot incidents, marketing pushes, or tracking issues at a glance. + +### "How I got this" + +Below each answer you can expand a **How I got this** drawer to see every tool the assistant called, the parameters it used (period, filters, metrics, etc.), and when each step ran. This makes it transparent how a number was produced and helps you verify the assistant pulled the right slice of data. + +### Follow-up suggestions + +After most answers, Ask AI proposes a few related follow-up questions under **You might also ask**. Click any suggestion to send it as the next message — useful for digging deeper without thinking up the wording yourself. + +## Working with messages + +Each message has a hover toolbar with the following actions: + +- **Copy message** — copies the assistant's text answer to your clipboard. +- **Regenerate response** — re-runs the last turn to get a fresh answer. +- **Edit message** (your messages) — tweak your question and resend it. +- **Branch off** — creates a new conversation starting from that point. The original chat is left untouched, so you can explore an alternative direction without losing context. Branched chats show a "Branched from …" label at the top. +- **Good response / Bad response** — quick thumbs-up / thumbs-down feedback that helps us improve the assistant. + +You can also **stop generation** at any time by clicking the stop button while the assistant is still streaming. + +## Chat history + +All your conversations are saved per-project in the **Recent chats** sidebar. From there you can: + +- **Search** chats by name or message content. +- **Pin** important conversations so they stay at the top under **Pinned chats**. +- **Tag** chats (up to 5 tags per chat) and filter the list by tag — handy for grouping research, billing investigations, weekly reviews, etc. +- **Rename** a chat or let the assistant name it automatically from the first message. +- **Copy a link** to a chat to share it with teammates who have access to the project. +- **Delete** chats you no longer need. + +A list of recent / pinned AI chats + +## Exporting a conversation -## Examples +Each chat can be exported from the conversation header: -You can ask questions like: +- **Copy conversation** — copies the entire conversation as Markdown to your clipboard. +- **Download as Markdown** — saves the conversation (including the assistant's reasoning and the data it returned) as a `.md` file you can paste into a doc, ticket, or PR. -- "How many unique visitors did we have last week?" -- "What is the most popular page in the US?" -- "Compare the conversion rate of mobile vs. desktop users." -- "Show me a breakdown of traffic sources for the 'Signup' event." +This is the recommended way to attach AI findings to an internal report or to share insights with someone who doesn't have a Swetrix account. -## Usage +## Tips -1. Navigate to the **Ask AI** tab in your project dashboard. -2. Type your question in the input box. -3. Review the generated answer and data visualization. +- **Be specific about the period.** If you don't say otherwise, the assistant defaults to the last 7 days. +- **Mention the slice you care about** (country, page, browser, source) up front — the assistant will translate it into a filter. +- **Use follow-ups** instead of restating context. The assistant remembers earlier turns in the same chat. +- **Branch when exploring.** If you want to test a "what if" question, branch off rather than overwriting the current investigation. -Ask AI is designed to make data accessibility easier for everyone on your team, regardless of their technical expertise. + + AI answers are a fast first pass and may occasionally be inaccurate. For anything you'll act on, + click **Open in dashboard** on the relevant chart and double-check the numbers in the underlying + view. + diff --git a/docs/public/img/analytics-dashboard/ask-ai-chart-toolbar.png b/docs/public/img/analytics-dashboard/ask-ai-chart-toolbar.png new file mode 100644 index 000000000..2625e32b0 Binary files /dev/null and b/docs/public/img/analytics-dashboard/ask-ai-chart-toolbar.png differ diff --git a/docs/public/img/analytics-dashboard/ask-ai-history.png b/docs/public/img/analytics-dashboard/ask-ai-history.png new file mode 100644 index 000000000..1c747f140 Binary files /dev/null and b/docs/public/img/analytics-dashboard/ask-ai-history.png differ diff --git a/docs/public/img/analytics-dashboard/ask-ai.png b/docs/public/img/analytics-dashboard/ask-ai.png index aba11e62c..450d21c97 100644 Binary files a/docs/public/img/analytics-dashboard/ask-ai.png and b/docs/public/img/analytics-dashboard/ask-ai.png differ diff --git a/web/app/api/index.ts b/web/app/api/index.ts index 0216dd770..f49e41265 100644 --- a/web/app/api/index.ts +++ b/web/app/api/index.ts @@ -8,6 +8,7 @@ interface AIStreamCallbacks { onToolCall?: (toolName: string, args: unknown) => void onToolResult?: (toolName: string, result: unknown) => void onReasoning?: (chunk: string) => void + onFollowUps?: (suggestions: string[]) => void onComplete: () => void onError: (error: Error) => void } @@ -73,6 +74,14 @@ export const askAI = async ( callbacks.onToolResult?.(parsed.toolName, parsed.result) } else if (parsed.type === 'reasoning') { callbacks.onReasoning?.(parsed.content) + } else if (parsed.type === 'followUps') { + if (Array.isArray(parsed.data)) { + callbacks.onFollowUps?.( + parsed.data.filter( + (item: unknown): item is string => typeof item === 'string', + ), + ) + } } else if (parsed.type === 'error') { callbacks.onError(new Error(parsed.content)) } else if (parsed.type === 'done') { diff --git a/web/app/hooks/useSpeechRecognition.ts b/web/app/hooks/useSpeechRecognition.ts new file mode 100644 index 000000000..66a910608 --- /dev/null +++ b/web/app/hooks/useSpeechRecognition.ts @@ -0,0 +1,162 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +type SpeechRecognitionResultLike = { + isFinal: boolean + 0: { transcript: string } +} + +type SpeechRecognitionEventLike = { + resultIndex: number + results: ArrayLike +} + +type SpeechRecognitionErrorEventLike = { + error: string + message?: string +} + +type SpeechRecognitionLike = { + lang: string + continuous: boolean + interimResults: boolean + start: () => void + stop: () => void + abort: () => void + onresult: ((event: SpeechRecognitionEventLike) => void) | null + onerror: ((event: SpeechRecognitionErrorEventLike) => void) | null + onend: (() => void) | null + onstart: (() => void) | null +} + +type SpeechRecognitionConstructor = new () => SpeechRecognitionLike + +const getSpeechRecognition = (): SpeechRecognitionConstructor | null => { + if (typeof window === 'undefined') return null + const w = window as unknown as { + SpeechRecognition?: SpeechRecognitionConstructor + webkitSpeechRecognition?: SpeechRecognitionConstructor + } + return w.SpeechRecognition || w.webkitSpeechRecognition || null +} + +interface UseSpeechRecognitionResult { + isListening: boolean + transcript: string + interimTranscript: string + start: () => void + stop: () => void + isSupported: boolean + error: string | null +} + +const useSpeechRecognition = (): UseSpeechRecognitionResult => { + const [isSupported, setIsSupported] = useState(false) + const [isListening, setIsListening] = useState(false) + const [transcript, setTranscript] = useState('') + const [interimTranscript, setInterimTranscript] = useState('') + const [error, setError] = useState(null) + + const recognitionRef = useRef(null) + const finalTranscriptRef = useRef('') + + useEffect(() => { + const Ctor = getSpeechRecognition() + if (!Ctor) return + setIsSupported(true) + + const recognition = new Ctor() + recognition.lang = + (typeof navigator !== 'undefined' && navigator.language) || 'en-US' + recognition.continuous = true + recognition.interimResults = true + + recognition.onstart = () => { + setIsListening(true) + setError(null) + } + + recognition.onresult = (event) => { + let interim = '' + let finalChunk = '' + for (let i = event.resultIndex; i < event.results.length; i++) { + const result = event.results[i] + const text = result[0].transcript + if (result.isFinal) { + finalChunk += text + } else { + interim += text + } + } + + if (finalChunk) { + finalTranscriptRef.current += finalChunk + } + + setInterimTranscript(interim) + setTranscript(`${finalTranscriptRef.current}${interim}`) + } + + recognition.onerror = (event) => { + setError(event.error || 'speech-recognition-error') + setIsListening(false) + } + + recognition.onend = () => { + setIsListening(false) + setInterimTranscript('') + } + + recognitionRef.current = recognition + + return () => { + recognition.onresult = null + recognition.onerror = null + recognition.onend = null + recognition.onstart = null + try { + recognition.abort() + } catch { + // no-op + } + recognitionRef.current = null + } + }, []) + + const start = useCallback(() => { + const recognition = recognitionRef.current + if (!recognition) return + + finalTranscriptRef.current = '' + setTranscript('') + setInterimTranscript('') + setError(null) + + try { + recognition.start() + } catch (err) { + setError((err as Error)?.message || 'failed-to-start') + } + }, []) + + const stop = useCallback(() => { + const recognition = recognitionRef.current + if (!recognition) return + try { + recognition.stop() + } catch { + // no-op + } + }, []) + + return { + isListening, + transcript, + interimTranscript, + start, + stop, + isSupported, + error, + } +} + +export default useSpeechRecognition diff --git a/web/app/pages/Dashboard/Dashboard.tsx b/web/app/pages/Dashboard/Dashboard.tsx index f2eb79013..2b56785d6 100644 --- a/web/app/pages/Dashboard/Dashboard.tsx +++ b/web/app/pages/Dashboard/Dashboard.tsx @@ -350,14 +350,14 @@ const Dashboard = () => { <>
-
+
{t('titles.dashboard')} {isSearchActive ? ( diff --git a/web/app/pages/Project/tabs/AskAI/AIChart.tsx b/web/app/pages/Project/tabs/AskAI/AIChart.tsx index 53bfc090e..741df3df8 100644 --- a/web/app/pages/Project/tabs/AskAI/AIChart.tsx +++ b/web/app/pages/Project/tabs/AskAI/AIChart.tsx @@ -1,17 +1,112 @@ -import type { ChartOptions } from 'billboard.js' +import { + Menu, + MenuButton, + MenuItem, + MenuItems, + Transition, +} from '@headlessui/react' +import type { Chart, ChartOptions } from 'billboard.js' import { line, area, bar, spline, pie, donut } from 'billboard.js' +import { + ArrowSquareOutIcon, + ChartBarIcon, + ChartDonutIcon, + ChartLineIcon, + ChartPieIcon, + CopyIcon, + DownloadSimpleIcon, + FileCsvIcon, +} from '@phosphor-icons/react' import dayjs from 'dayjs' import _filter from 'lodash/filter' import _isEmpty from 'lodash/isEmpty' import _keys from 'lodash/keys' import _map from 'lodash/map' -import React, { useMemo, memo } from 'react' +import React, { + Fragment, + memo, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' import BillboardChart from '~/ui/BillboardChart' +import { cn } from '~/utils/generic' + +const VALID_LINK_TABS = new Set([ + 'traffic', + 'performance', + 'errors', + 'sessions', + 'funnels', + 'goals', + 'experiments', + 'featureFlags', + 'captcha', + 'profiles', +]) + +const VALID_LINK_PERIODS = new Set([ + '1h', + 'today', + 'yesterday', + '1d', + '7d', + '4w', + '3M', + '12M', + '24M', + 'all', +]) + +const VALID_LINK_FILTER_COLUMNS = new Set([ + 'pg', + 'cc', + 'rg', + 'ct', + 'br', + 'os', + 'dv', + 'ref', + 'so', + 'me', + 'ca', + 'te', + 'co', + 'lc', + 'host', +]) + +const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/ + +interface AIChartLink { + tab: string + period?: string + from?: string + to?: string + filters?: Array<{ + column: string + filter: string + isExclusive?: boolean + isContains?: boolean + }> +} + +interface AIChartAnnotation { + x: string + label?: string + kind?: 'spike' | 'dip' +} + +type AIChartType = 'line' | 'bar' | 'area' | 'spline' | 'pie' | 'donut' interface AIChartData { type: 'chart' - chartType: 'line' | 'bar' | 'area' | 'spline' | 'pie' | 'donut' + chartType: AIChartType title?: string data: { x?: string[] @@ -19,10 +114,115 @@ interface AIChartData { values?: number[] [key: string]: number[] | string[] | undefined } + annotations?: AIChartAnnotation[] + link?: AIChartLink +} + +const TIME_SERIES_TYPES: AIChartType[] = ['line', 'area', 'spline', 'bar'] +const CATEGORICAL_TYPES: AIChartType[] = ['pie', 'donut'] + +const ANNOTATION_LABEL_MAX = 40 + +const buildAnnotationLines = ( + annotations: AIChartAnnotation[] | undefined, + xData: string[], +): Array<{ value: Date; text: string; class: string; position: 'middle' }> => { + if (!Array.isArray(annotations) || annotations.length === 0) return [] + + const xValueSet = new Set(xData) + + return annotations + .filter( + (a): a is AIChartAnnotation => + !!a && typeof a === 'object' && typeof a.x === 'string', + ) + .map((a) => { + const matchesX = xValueSet.has(a.x) + const parsed = dayjs(a.x) + if (!matchesX && !parsed.isValid()) return null + + const date = parsed.isValid() ? parsed.toDate() : new Date(a.x) + if (Number.isNaN(date.getTime())) return null + + const kind = a.kind === 'dip' ? 'dip' : 'spike' + const rawLabel = typeof a.label === 'string' ? a.label.trim() : '' + const text = + rawLabel.length > ANNOTATION_LABEL_MAX + ? `${rawLabel.slice(0, ANNOTATION_LABEL_MAX - 1)}…` + : rawLabel + + return { + value: date, + text, + class: + kind === 'spike' + ? 'annotation-line annotation-spike' + : 'annotation-line annotation-dip', + position: 'middle' as const, + } + }) + .filter( + ( + x, + ): x is { + value: Date + text: string + class: string + position: 'middle' + } => x !== null, + ) + .slice(0, 3) } interface AIChartProps { chart: AIChartData + projectId?: string +} + +const buildDashboardUrl = ( + projectId: string, + link: AIChartLink, +): string | null => { + if (!VALID_LINK_TABS.has(link.tab)) return null + + const params = new URLSearchParams() + params.set('tab', link.tab) + + const hasCustomRange = + typeof link.from === 'string' && + ISO_DATE_PATTERN.test(link.from) && + typeof link.to === 'string' && + ISO_DATE_PATTERN.test(link.to) + + if (hasCustomRange) { + params.set('period', 'custom') + params.set('from', link.from!) + params.set('to', link.to!) + } else if (link.period && VALID_LINK_PERIODS.has(link.period)) { + params.set('period', link.period) + } + + if (Array.isArray(link.filters)) { + for (const f of link.filters) { + if ( + !f || + typeof f.column !== 'string' || + typeof f.filter !== 'string' || + !VALID_LINK_FILTER_COLUMNS.has(f.column) + ) { + continue + } + // Match the dashboard's URL convention from parseFilters(): + // `!` => exclusive, `~` => contains, `^` => exclusive + contains + let prefix = '' + if (f.isExclusive && f.isContains) prefix = '^' + else if (f.isExclusive) prefix = '!' + else if (f.isContains) prefix = '~' + params.append(`${prefix}${f.column}`, f.filter) + } + } + + return `/projects/${projectId}?${params.toString()}` } const CHART_COLORS = [ @@ -36,6 +236,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, @@ -108,9 +336,247 @@ const isPieOrDonutChart = (chartType: string): boolean => { return chartType === 'pie' || chartType === 'donut' } -const AIChart: React.FC = ({ chart }) => { +const slugify = (str: string): string => { + const slug = str + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + return slug || 'chart' +} + +const triggerBlobDownload = (blob: Blob, filename: string) => { + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + // Revoke on next tick so the download has time to kick off + setTimeout(() => URL.revokeObjectURL(url), 0) +} + +const escapeCsvCell = (value: unknown): string => { + if (value === null || value === undefined) return '' + const str = String(value) + if (/[",\n\r]/.test(str)) { + return `"${str.replace(/"/g, '""')}"` + } + return str +} + +const buildCsvFromChart = ( + chart: AIChartData, + displayType: AIChartType, +): string => { + const lines: string[] = [] + if (isPieOrDonutChart(displayType)) { + const labels = (chart.data.labels as string[]) || [] + const values = (chart.data.values as number[]) || [] + lines.push(['label', 'value'].map(escapeCsvCell).join(',')) + labels.forEach((label, idx) => { + lines.push( + [escapeCsvCell(label), escapeCsvCell(values[idx] ?? '')].join(','), + ) + }) + } else { + const xData = (chart.data.x as string[]) || [] + const seriesKeys = _filter( + _keys(chart.data), + (key) => key !== 'x' && key !== 'labels' && key !== 'values', + ) + lines.push(['x', ...seriesKeys].map(escapeCsvCell).join(',')) + xData.forEach((xVal, idx) => { + const row = [ + escapeCsvCell(xVal), + ...seriesKeys.map((key) => { + const series = chart.data[key] + return escapeCsvCell(Array.isArray(series) ? series[idx] : '') + }), + ] + lines.push(row.join(',')) + }) + } + return lines.join('\n') +} + +// Subset of CSS properties that meaningfully affect rendered SVG output. +// Inlining everything from getComputedStyle bloats the file and can break +// gradient/marker references, so we cherry-pick the visual ones. +const SVG_STYLE_PROPS = [ + 'fill', + 'fill-opacity', + 'stroke', + 'stroke-width', + 'stroke-opacity', + 'stroke-linecap', + 'stroke-linejoin', + 'stroke-dasharray', + 'stroke-miterlimit', + 'opacity', + 'visibility', + 'display', + 'color', + 'font-family', + 'font-size', + 'font-weight', + 'font-style', + 'text-anchor', + 'dominant-baseline', + 'shape-rendering', + 'paint-order', +] as const + +// Walk source + cloned trees in lockstep and copy the resolved styles onto the +// clone so the rasterised SVG matches what the browser draws. +const inlineComputedStyles = (source: SVGElement, target: SVGElement) => { + const sourceNodes: Element[] = [ + source, + ...Array.from(source.querySelectorAll('*')), + ] + const targetNodes: Element[] = [ + target, + ...Array.from(target.querySelectorAll('*')), + ] + + const len = Math.min(sourceNodes.length, targetNodes.length) + for (let i = 0; i < len; i++) { + const computed = window.getComputedStyle(sourceNodes[i]) + let styleStr = '' + for (const prop of SVG_STYLE_PROPS) { + const value = computed.getPropertyValue(prop) + if (!value) continue + styleStr += `${prop}:${value};` + } + if (styleStr) { + const existing = + (targetNodes[i] as HTMLElement).getAttribute('style') || '' + ;(targetNodes[i] as HTMLElement).setAttribute( + 'style', + `${styleStr}${existing}`, + ) + } + } +} + +// Billboard renders to SVG with styles applied via stylesheets; we inline the +// computed styles, capture the bounding box, then rasterise via Image → +// Canvas → PNG blob. +const exportChartAsPng = async ( + container: HTMLElement, + filename: string, +): Promise => { + const svg = container.querySelector('svg') + if (!svg) throw new Error('svg not found') + + const rect = svg.getBoundingClientRect() + const width = Math.max(1, Math.round(rect.width)) + const height = Math.max(1, Math.round(rect.height)) + + const clone = svg.cloneNode(true) as SVGSVGElement + clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg') + clone.setAttribute('width', String(width)) + clone.setAttribute('height', String(height)) + if (!clone.getAttribute('viewBox')) { + clone.setAttribute('viewBox', `0 0 ${width} ${height}`) + } + + inlineComputedStyles(svg, clone) + + const svgString = new XMLSerializer().serializeToString(clone) + const svgBlob = new Blob( + ['\n', svgString], + { type: 'image/svg+xml;charset=utf-8' }, + ) + const svgUrl = URL.createObjectURL(svgBlob) + + try { + await new Promise((resolve, reject) => { + const img = new Image() + img.onload = () => { + try { + const dpr = window.devicePixelRatio || 1 + const canvas = document.createElement('canvas') + canvas.width = width * dpr + canvas.height = height * dpr + const ctx = canvas.getContext('2d') + if (!ctx) throw new Error('canvas context unavailable') + ctx.fillStyle = '#ffffff' + ctx.fillRect(0, 0, canvas.width, canvas.height) + ctx.scale(dpr, dpr) + ctx.drawImage(img, 0, 0, width, height) + canvas.toBlob((pngBlob) => { + if (!pngBlob) { + reject(new Error('toBlob failed')) + return + } + triggerBlobDownload(pngBlob, filename) + resolve() + }, 'image/png') + } catch (err) { + reject(err) + } + } + img.onerror = () => reject(new Error('image load failed')) + img.src = svgUrl + }) + } finally { + URL.revokeObjectURL(svgUrl) + } +} + +const TYPE_ICONS: Record< + AIChartType, + React.ComponentType<{ className?: string }> +> = { + line: ChartLineIcon, + area: ChartLineIcon, + spline: ChartLineIcon, + bar: ChartBarIcon, + pie: ChartPieIcon, + donut: ChartDonutIcon, +} + +const AIChart: React.FC = ({ chart, projectId }) => { + const { t } = useTranslation('common') + const containerRef = useRef(null) + const [displayType, setDisplayType] = useState(chart.chartType) + const [chartReady, setChartReady] = useState(false) + + // Reset the user-selected display type whenever a new chart is provided so we + // don't carry stale compatibility decisions across distinct charts. + useEffect(() => { + setDisplayType(chart.chartType) + }, [chart.chartType]) + + const handleChartReady = useCallback((instance: Chart | null) => { + setChartReady(instance !== null) + }, []) + + const dashboardHref = useMemo(() => { + if (!projectId || !chart.link) return null + return buildDashboardUrl(projectId, chart.link) + }, [projectId, chart.link]) + + const isPieDonutData = + !_isEmpty(chart.data.labels) && !_isEmpty(chart.data.values) + const isTimeSeriesData = !_isEmpty(chart.data.x) + + const compatibleTypes = useMemo(() => { + if (isPieDonutData) { + const labels = (chart.data.labels as string[]) || [] + if (labels.length <= 1) return [displayType] + return CATEGORICAL_TYPES + } + if (isTimeSeriesData) { + return TIME_SERIES_TYPES + } + return [displayType] + }, [chart.data.labels, displayType, isPieDonutData, isTimeSeriesData]) + const chartOptions = useMemo(() => { - const isPieDonut = isPieOrDonutChart(chart.chartType) + const isPieDonut = isPieOrDonutChart(displayType) if (isPieDonut) { if ( @@ -137,11 +603,11 @@ const AIChart: React.FC = ({ chart }) => { return { data: { columns, - type: getChartType(chart.chartType), + type: getChartType(displayType), colors, }, donut: - chart.chartType === 'donut' + displayType === 'donut' ? { title: '', label: { @@ -151,7 +617,7 @@ const AIChart: React.FC = ({ chart }) => { } : undefined, pie: - chart.chartType === 'pie' + displayType === 'pie' ? { label: { format: (_value: number, ratio: number) => @@ -168,19 +634,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 +682,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 + }), ], ] @@ -212,7 +705,7 @@ const AIChart: React.FC = ({ chart }) => { seriesKeys.forEach((key, idx) => { columns.push([key, ...chart.data[key]!]) - types[key] = getChartType(chart.chartType) as any + types[key] = getChartType(displayType) as any colors[key] = CHART_COLORS[idx % CHART_COLORS.length] }) @@ -231,7 +724,11 @@ const AIChart: React.FC = ({ chart }) => { const optimalTicks = allYValues.length > 0 ? calculateOptimalTicks(allYValues) : undefined - const isDateAxis = xData.length > 0 && dayjs(xData[0]).isValid() + const isBar = displayType === 'bar' + + const annotationLines = isDateAxis + ? buildAnnotationLines(chart.annotations, xData) + : [] return { data: { @@ -244,6 +741,9 @@ const AIChart: React.FC = ({ chart }) => { y: { show: true, }, + ...(annotationLines.length > 0 + ? { x: { lines: annotationLines } } + : {}), }, transition: { duration: 200, @@ -254,6 +754,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 +763,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,55 +773,127 @@ 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, }, } - }, [chart]) + }, [chart, displayType]) + + const filenameBase = useMemo( + () => slugify(chart.title || 'chart'), + [chart.title], + ) + + const handleDownloadPng = useCallback(async () => { + if (!containerRef.current || !chartReady) return + try { + await exportChartAsPng(containerRef.current, `${filenameBase}.png`) + } catch { + // swallow – download failed, nothing actionable to show + } + }, [chartReady, filenameBase]) - const isPieDonut = isPieOrDonutChart(chart.chartType) + const handleDownloadCsv = useCallback(() => { + const csv = buildCsvFromChart(chart, displayType) + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }) + triggerBlobDownload(blob, `${filenameBase}.csv`) + }, [chart, displayType, filenameBase]) + + const handleCopyData = useCallback(async () => { + try { + await navigator.clipboard.writeText(JSON.stringify(chart.data, null, 2)) + toast.success(t('project.askAi.chart.dataCopied')) + } catch { + // ignore clipboard errors + } + }, [chart.data, t]) + + const isPieDonut = isPieOrDonutChart(displayType) if (isPieDonut) { if ( @@ -339,15 +909,135 @@ const AIChart: React.FC = ({ chart }) => { } } - return ( -
    - {chart.title ? ( -

    - {chart.title} -

    + const canChangeType = compatibleTypes.length > 1 + const openInDashboardLabel = t('project.askAi.openInDashboard') + + const toolbarButtonClass = + 'flex h-7 w-7 items-center justify-center rounded-md text-gray-500 bg-white/80 dark:bg-slate-900/80 backdrop-blur-sm ring-1 ring-gray-200/80 dark:ring-slate-800/80 transition-colors hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-slate-800 dark:hover:text-gray-200 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-white/80 dark:disabled:hover:bg-slate-900/80' + + const toolbar = ( +
    + {canChangeType ? ( + + {({ open }) => ( + <> + + + + + + {compatibleTypes.map((typeOption) => { + const Icon = TYPE_ICONS[typeOption] + const active = typeOption === displayType + return ( + + + + ) + })} + + + + )} + ) : null} -
    - + + + + {dashboardHref ? ( + + + + ) : null} +
    + ) + + const cardClassName = cn( + 'ai-chart group/chart relative block rounded-lg border border-gray-200 bg-white p-4 transition-colors dark:border-slate-800 dark:bg-slate-900', + ) + + const titleNode = chart.title ? ( +

    + {chart.title} +

    + ) : null + + return ( +
    + {toolbar} + {titleNode} +
    +
    ) @@ -355,5 +1045,8 @@ const AIChart: React.FC = ({ chart }) => { // Memoize to prevent re-renders during streaming when chart data hasn't changed export default memo(AIChart, (prevProps, nextProps) => { - return JSON.stringify(prevProps.chart) === JSON.stringify(nextProps.chart) + return ( + prevProps.projectId === nextProps.projectId && + JSON.stringify(prevProps.chart) === JSON.stringify(nextProps.chart) + ) }) diff --git a/web/app/pages/Project/tabs/AskAI/AskAIView.tsx b/web/app/pages/Project/tabs/AskAI/AskAIView.tsx index cfcbacbec..7a7cede33 100644 --- a/web/app/pages/Project/tabs/AskAI/AskAIView.tsx +++ b/web/app/pages/Project/tabs/AskAI/AskAIView.tsx @@ -1,8 +1,6 @@ -import _filter from 'lodash/filter' -import _isEmpty from 'lodash/isEmpty' -import _map from 'lodash/map' +import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react' import { - PaperPlaneIcon, + ArrowUpIcon, CaretDownIcon, CaretRightIcon, SpinnerGapIcon, @@ -10,16 +8,36 @@ import { WarningCircleIcon, ArrowDownIcon, ArrowLeftIcon, + ArrowUpRightIcon, ChartBarIcon, TargetIcon, GitBranchIcon, InfoIcon, CheckIcon, - ChatIcon, TrashIcon, XIcon, LinkIcon, + CopyIcon, + ArrowCounterClockwiseIcon, + ThumbsUpIcon, + ThumbsDownIcon, + PencilSimpleIcon, + ShieldIcon, + FlagIcon, + FlaskIcon, + UsersIcon, + ListBulletsIcon, + MicrophoneIcon, + MicrophoneSlashIcon, + TagIcon, + PushPinIcon, + MagnifyingGlassIcon, + DownloadSimpleIcon, + ExportIcon, } from '@phosphor-icons/react' +import _filter from 'lodash/filter' +import _isEmpty from 'lodash/isEmpty' +import _map from 'lodash/map' import { marked } from 'marked' import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' @@ -29,8 +47,11 @@ import { toast } from 'sonner' import { useStickToBottom } from 'use-stick-to-bottom' import { askAI } from '~/api' +import useSpeechRecognition from '~/hooks/useSpeechRecognition' import { ProjectViewActionData } from '~/routes/projects.$id' +import Button from '~/ui/Button' import SwetrixLogo from '~/ui/icons/SwetrixLogo' +import Input from '~/ui/Input' import Modal from '~/ui/Modal' import { Text } from '~/ui/Text' import Textarea from '~/ui/Textarea' @@ -38,6 +59,14 @@ import Tooltip from '~/ui/Tooltip' import { cn } from '~/utils/generic' import AIChart from './AIChart' +import { parseSegments } from './contentSegments' +import { + chatToMarkdown, + downloadMarkdown, + ExportMessage, + getChatExportFilename, +} from './exportHelpers' +import { formatToolCallSummary, ToolCallSummary } from './toolFormatters' interface MessagePart { type: 'text' | 'toolCall' @@ -49,17 +78,46 @@ interface MessagePart { interface AIChatSummary { id: string name: string | null + pinned?: boolean + tags?: string[] created: string updated: string } +const MAX_TAGS_PER_CHAT = 5 +const MAX_TAG_LENGTH = 30 + +const sanitiseTagsClient = (tags: string[]): string[] => { + const seen = new Set() + const out: string[] = [] + for (const raw of tags) { + if (typeof raw !== 'string') continue + const cleaned = raw.replace(/,/g, '').trim().slice(0, MAX_TAG_LENGTH) + if (!cleaned) continue + const key = cleaned.toLowerCase() + if (seen.has(key)) continue + seen.add(key) + out.push(cleaned) + if (out.length >= MAX_TAGS_PER_CHAT) break + } + return out +} + +interface MessageToolCall { + toolName: string + args: unknown + completed?: boolean + timestamp?: string +} + interface Message { id: string role: 'user' | 'assistant' content: string reasoning?: string - toolCalls?: Array<{ toolName: string; args: unknown; completed?: boolean }> + toolCalls?: MessageToolCall[] parts?: MessagePart[] + followUps?: string[] } interface AskAIViewProps { @@ -71,56 +129,6 @@ marked.setOptions({ gfm: true, }) -const parseCharts = (content: string): { text: string; charts: any[] } => { - const charts: any[] = [] - let text = content - - const chartStartPattern = '{"type":"chart"' - let searchIndex = 0 - - while (searchIndex < text.length) { - const startIndex = text.indexOf(chartStartPattern, searchIndex) - if (startIndex === -1) break - - let braceCount = 0 - let endIndex = -1 - - for (let i = startIndex; i < text.length; i++) { - if (text[i] === '{') { - braceCount++ - } else if (text[i] === '}') { - braceCount-- - if (braceCount === 0) { - endIndex = i - break - } - } - } - - if (endIndex === -1) { - searchIndex = startIndex + chartStartPattern.length - continue - } - - const jsonString = text.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 - } - } catch { - // Invalid JSON, skip - } - - searchIndex = startIndex + chartStartPattern.length - } - - return { text: text.trim(), charts } -} - const renderMarkdown = (content: string): string => { const html = marked.parse(content) as string return sanitizeHtml(html, { @@ -160,58 +168,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 +238,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')} +
  • @@ -395,43 +415,220 @@ const ToolCallBadge = ({ const MessageContent = ({ content, isStreaming, + projectId, }: { content: string isStreaming?: boolean + projectId?: string }) => { - 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) => ( - - ))} +
    + ) +} + +const formatRelativeTimestamp = (timestamp: string, t: any): string | null => { + const date = new Date(timestamp) + if (Number.isNaN(date.getTime())) return null + const diffMs = Date.now() - date.getTime() + const diffMins = Math.floor(diffMs / 60000) + const diffHours = Math.floor(diffMs / 3600000) + const diffDays = Math.floor(diffMs / 86400000) + + if (diffMins < 1) return t('project.askAi.timeFormat.justNow') + if (diffMins < 60) + return t('project.askAi.timeFormat.minutes', { count: diffMins }) + if (diffHours < 24) + return t('project.askAi.timeFormat.hours', { count: diffHours }) + if (diffDays < 7) + return t('project.askAi.timeFormat.days', { count: diffDays }) + return date.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + }) +} + +const ToolCallSummaryDrawer = ({ + toolCalls, +}: { + toolCalls: MessageToolCall[] +}) => { + const { t } = useTranslation('common') + const [isExpanded, setIsExpanded] = useState(false) + + const summaries = useMemo( + () => toolCalls.map((tc) => formatToolCallSummary(tc.toolName, tc.args, t)), + [toolCalls, t], + ) + + if (toolCalls.length === 0) return null + + const buttonLabel = t('project.askAi.howIGotThis', { + count: toolCalls.length, + defaultValue: + toolCalls.length === 1 + ? 'How I got this · 1 step' + : `How I got this · ${toolCalls.length} steps`, + }) + + return ( +
    + + + {isExpanded ? ( +
    +
      + {_map(summaries, (summary, idx) => { + const { label: toolLabel, icon: Icon } = getToolInfo( + summary.toolName, + t, + ) + const timestamp = toolCalls[idx]?.timestamp + const relative = timestamp + ? formatRelativeTimestamp(timestamp, t) + : null + return ( +
    1. +
      + + {idx + 1} + +
      +
      + + + {toolLabel} + + {relative ? ( + + · {relative} + + ) : null} +
      + {summary.params.length === 0 ? ( +

      + {t('project.askAi.noParameters')} +

      + ) : ( +
      + {_map(summary.params, (param, pIdx) => ( + +
      + {t(param.labelKey, { + defaultValue: param.fallbackLabel, + })} +
      +
      + {param.entries ? ( +
        + {_map(param.entries, (entry, eIdx) => ( +
      • + {entry} +
      • + ))} +
      + ) : param.json ? ( +
      +                                    {param.json}
      +                                  
      + ) : ( + + {param.value} + + )} +
      +
      + ))} +
      + )} +
      +
      +
    2. + ) + })} +
    ) : null} - +
    ) } const AssistantMessage = ({ message, isStreaming, + onRegenerate, + onFeedback, + feedback, + canRegenerate, + followUps, + onFollowUpClick, + projectId, }: { message: Message isStreaming?: boolean + onRegenerate?: () => void + onFeedback?: (rating: 'good' | 'bad') => void + feedback?: 'good' | 'bad' | null + canRegenerate?: boolean + followUps?: string[] + onFollowUpClick?: (prompt: string) => void + projectId?: string }) => { + 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 +641,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 +675,6 @@ const AssistantMessage = ({ onToggle={handleToggle} /> {hasParts ? ( - // Render parts in sequence <> {_map(message.parts, (part, idx) => { if (part.type === 'text' && part.text) { @@ -484,6 +688,7 @@ const AssistantMessage = ({
    ) @@ -502,7 +707,6 @@ const AssistantMessage = ({ })} ) : ( - // Fall back to old behavior for messages without parts (e.g., loaded from saved chats) <> {message.toolCalls && message.toolCalls.length > 0 ? (
    @@ -515,18 +719,252 @@ const AssistantMessage = ({ ))}
    ) : null} - + )} + + {!isStreaming && message.toolCalls && message.toolCalls.length > 0 ? ( + + ) : null} + + {!isStreaming && + hasContent && + followUps && + followUps.length > 0 && + onFollowUpClick ? ( +
    + {_map(followUps, (suggestion, idx) => ( + + ))} +
    + ) : null} + + {!isStreaming && hasContent ? ( +
    + + {onRegenerate && canRegenerate ? ( + + ) : null} + {onFeedback ? ( + <> + + + + ) : null} +
    + ) : null}
    ) } -const UserMessage = ({ content }: { content: string }) => { +const UserMessage = ({ + content, + onEdit, + onBranch, + isLoading, +}: { + content: string + onEdit?: (newContent: string) => void + onBranch?: () => 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 ( +
    +
    +