Merge pull request #628 from buster-so/big-nate/bus-1483-quick-win-for-filter-dashboard-drill-downexplore-metric

Filter and drill down feature
This commit is contained in:
dal 2025-07-25 13:42:20 -06:00 committed by GitHub
commit 4464abfc22
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1428 additions and 597 deletions

View File

@ -15,6 +15,7 @@ vi.mock('./services/chat-service', () => ({
vi.mock('./services/chat-helpers', () => ({ vi.mock('./services/chat-helpers', () => ({
handleAssetChat: vi.fn(), handleAssetChat: vi.fn(),
handleAssetChatWithPrompt: vi.fn(),
})); }));
vi.mock('@buster/database', () => ({ vi.mock('@buster/database', () => ({
@ -41,7 +42,7 @@ vi.mock('@buster/database', () => ({
import { getUserOrganizationId, updateMessage } from '@buster/database'; import { getUserOrganizationId, updateMessage } from '@buster/database';
import { tasks } from '@trigger.dev/sdk/v3'; import { tasks } from '@trigger.dev/sdk/v3';
import { createChatHandler } from './handler'; import { createChatHandler } from './handler';
import { handleAssetChat } from './services/chat-helpers'; import { handleAssetChat, handleAssetChatWithPrompt } from './services/chat-helpers';
import { initializeChat } from './services/chat-service'; import { initializeChat } from './services/chat-service';
describe('createChatHandler', () => { describe('createChatHandler', () => {
@ -120,8 +121,51 @@ describe('createChatHandler', () => {
expect(result).toEqual(mockChat); expect(result).toEqual(mockChat);
}); });
it('should handle asset-based chat creation', async () => { it('should handle asset-based chat creation and NOT trigger analyst task', async () => {
const assetChat = { ...mockChat, title: 'Asset Chat' }; // Asset chat should match Rust implementation exactly
const assetChat = {
...mockChat,
title: 'Test Metric', // Should be the asset name
message_ids: ['asset-msg-123'],
messages: {
'asset-msg-123': {
id: 'asset-msg-123',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
request_message: null, // No request message per Rust implementation
response_messages: {
'text-msg-id': {
type: 'text' as const,
id: 'text-msg-id',
message:
'Test Metric has been pulled into a new chat.\n\nContinue chatting to modify or make changes to it.',
is_final_message: true,
},
'asset-123': {
type: 'file' as const,
id: 'asset-123',
file_type: 'metric' as const,
file_name: 'Test Metric',
version_number: 1,
filter_version_id: null,
metadata: [
{
status: 'completed' as const,
message: 'Pulled into new chat',
timestamp: expect.any(Number),
},
],
},
},
response_message_ids: ['text-msg-id', 'asset-123'],
reasoning_message_ids: [],
reasoning_messages: {},
final_reasoning_message: '',
feedback: null,
is_completed: true,
},
},
};
vi.mocked(handleAssetChat).mockResolvedValue(assetChat); vi.mocked(handleAssetChat).mockResolvedValue(assetChat);
const result = await createChatHandler( const result = await createChatHandler(
@ -137,15 +181,8 @@ describe('createChatHandler', () => {
mockUser, mockUser,
mockChat mockChat
); );
expect(tasks.trigger).toHaveBeenCalledWith( // IMPORTANT: Should NOT trigger analyst task for asset-only requests
'analyst-agent-task', expect(tasks.trigger).not.toHaveBeenCalled();
{
message_id: 'msg-123',
},
{
concurrencyKey: 'chat-123',
}
);
expect(result).toEqual(assetChat); expect(result).toEqual(assetChat);
}); });
@ -158,23 +195,177 @@ describe('createChatHandler', () => {
expect(tasks.trigger).not.toHaveBeenCalled(); expect(tasks.trigger).not.toHaveBeenCalled();
}); });
it('should not call handleAssetChat when prompt is provided with asset', async () => { it('should call handleAssetChatWithPrompt when prompt is provided with asset', async () => {
// Chat should start empty when we have asset+prompt
const emptyChat = {
...mockChat,
message_ids: [],
messages: {},
};
// After handleAssetChatWithPrompt, we should have import message then user message
const chatWithPrompt = {
...mockChat,
message_ids: ['import-msg-123', 'user-msg-123'],
messages: {
'import-msg-123': {
id: 'import-msg-123',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
request_message: null,
response_messages: {},
response_message_ids: [],
reasoning_message_ids: [],
reasoning_messages: {},
final_reasoning_message: null,
feedback: null,
is_completed: true,
},
'user-msg-123': {
id: 'user-msg-123',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
request_message: {
request: 'Hello',
sender_id: 'user-123',
sender_name: 'Test User',
},
response_messages: {},
response_message_ids: [],
reasoning_message_ids: [],
reasoning_messages: {},
final_reasoning_message: null,
feedback: null,
is_completed: false,
},
},
};
// Mock initializeChat to return empty chat (no initial message created)
vi.mocked(initializeChat).mockResolvedValue({
chatId: 'chat-123',
messageId: 'msg-123',
chat: emptyChat,
});
vi.mocked(handleAssetChatWithPrompt).mockResolvedValueOnce(chatWithPrompt);
const result = await createChatHandler( const result = await createChatHandler(
{ prompt: 'Hello', asset_id: 'asset-123', asset_type: 'metric' }, { prompt: 'Hello', asset_id: 'asset-123', asset_type: 'metric' },
mockUser mockUser
); );
// Verify initializeChat was called without prompt (to avoid duplicate message)
expect(initializeChat).toHaveBeenCalledWith(
{ prompt: undefined, asset_id: 'asset-123', asset_type: 'metric' },
mockUser,
'550e8400-e29b-41d4-a716-446655440000'
);
expect(handleAssetChat).not.toHaveBeenCalled(); expect(handleAssetChat).not.toHaveBeenCalled();
expect(handleAssetChatWithPrompt).toHaveBeenCalledWith(
'chat-123',
'msg-123',
'asset-123',
'metric',
'Hello',
mockUser,
emptyChat
);
expect(tasks.trigger).toHaveBeenCalledWith( expect(tasks.trigger).toHaveBeenCalledWith(
'analyst-agent-task', 'analyst-agent-task',
{ {
message_id: 'msg-123', message_id: 'user-msg-123', // Should use the last message ID (user's prompt)
}, },
{ {
concurrencyKey: 'chat-123', concurrencyKey: 'chat-123',
} }
); );
expect(result).toEqual(mockChat); expect(result).toEqual(chatWithPrompt);
});
it('should ensure correct message order: import first, then user prompt', async () => {
const chatWithMessages = {
...mockChat,
message_ids: ['import-msg-123', 'user-msg-123'],
messages: {
'import-msg-123': {
id: 'import-msg-123',
created_at: '2025-07-25T12:00:00.000Z',
updated_at: '2025-07-25T12:00:00.000Z',
request_message: null, // Import messages have no request
response_messages: {
'asset-123': {
type: 'file' as const,
id: 'asset-123',
file_type: 'metric' as const,
file_name: 'Test Metric',
version_number: 1,
},
},
response_message_ids: ['asset-123'],
reasoning_message_ids: [],
reasoning_messages: {},
final_reasoning_message: null,
feedback: null,
is_completed: true,
},
'user-msg-123': {
id: 'user-msg-123',
created_at: '2025-07-25T12:00:01.000Z', // After import
updated_at: '2025-07-25T12:00:01.000Z',
request_message: {
request: 'What is this metric?',
sender_id: 'user-123',
sender_name: 'Test User',
},
response_messages: {},
response_message_ids: [],
reasoning_message_ids: [],
reasoning_messages: {},
final_reasoning_message: null,
feedback: null,
is_completed: false,
},
},
};
vi.mocked(initializeChat).mockResolvedValue({
chatId: 'chat-123',
messageId: 'msg-123',
chat: { ...mockChat, message_ids: [], messages: {} }, // Empty chat
});
vi.mocked(handleAssetChatWithPrompt).mockResolvedValueOnce(chatWithMessages);
const result = await createChatHandler(
{ prompt: 'What is this metric?', asset_id: 'asset-123', asset_type: 'metric' },
mockUser
);
// Verify message order is correct
expect(result.message_ids).toHaveLength(2);
expect(result.message_ids[0]).toBe('import-msg-123');
expect(result.message_ids[1]).toBe('user-msg-123');
// Verify import message has no request
const importMsg = result.messages['import-msg-123'];
expect(importMsg).toBeDefined();
expect(importMsg?.request_message).toBeNull();
expect(importMsg?.is_completed).toBe(true);
// Verify user message has request
const userMsg = result.messages['user-msg-123'];
expect(userMsg).toBeDefined();
expect(userMsg?.request_message?.request).toBe('What is this metric?');
expect(userMsg?.is_completed).toBe(false);
// Verify analyst task is triggered with user message ID
expect(tasks.trigger).toHaveBeenCalledWith(
'analyst-agent-task',
{ message_id: 'user-msg-123' },
{ concurrencyKey: 'chat-123' }
);
}); });
it('should handle trigger errors gracefully', async () => { it('should handle trigger errors gracefully', async () => {

View File

@ -7,7 +7,7 @@ import {
type ChatWithMessages, type ChatWithMessages,
} from '@buster/server-shared/chats'; } from '@buster/server-shared/chats';
import { tasks } from '@trigger.dev/sdk/v3'; import { tasks } from '@trigger.dev/sdk/v3';
import { handleAssetChat } from './services/chat-helpers'; import { handleAssetChat, handleAssetChatWithPrompt } from './services/chat-helpers';
import { initializeChat } from './services/chat-service'; import { initializeChat } from './services/chat-service';
/** /**
@ -57,30 +57,60 @@ export async function createChatHandler(
} }
// Initialize chat (new or existing) // Initialize chat (new or existing)
const { chatId, messageId, chat } = await initializeChat(request, user, organizationId); // When we have both asset and prompt, we'll skip creating the initial message
// since handleAssetChatWithPrompt will create both the import and prompt messages
const shouldCreateInitialMessage = !(request.asset_id && request.asset_type && request.prompt);
const modifiedRequest = shouldCreateInitialMessage
? request
: { ...request, prompt: undefined };
const { chatId, messageId, chat } = await initializeChat(modifiedRequest, user, organizationId);
// Handle asset-based chat if needed // Handle asset-based chat if needed
let finalChat: ChatWithMessages = chat; let finalChat: ChatWithMessages = chat;
if (request.asset_id && request.asset_type && !request.prompt) { let actualMessageId = messageId; // Track the actual message ID to use for triggering
finalChat = await handleAssetChat( let shouldTriggerAnalyst = true; // Flag to control whether to trigger analyst task
chatId,
messageId, if (request.asset_id && request.asset_type) {
request.asset_id, if (!request.prompt) {
request.asset_type, // Original flow: just import the asset without a prompt
user, finalChat = await handleAssetChat(
chat chatId,
); messageId,
request.asset_id,
request.asset_type,
user,
chat
);
// For asset-only chats, don't trigger analyst task - just return the chat with asset
shouldTriggerAnalyst = false;
} else {
// New flow: import asset then process the prompt
finalChat = await handleAssetChatWithPrompt(
chatId,
messageId,
request.asset_id,
request.asset_type,
request.prompt,
user,
chat
);
// For asset+prompt chats, use the last message ID (the user's prompt message)
const lastMessageId = finalChat.message_ids[finalChat.message_ids.length - 1];
if (lastMessageId) {
actualMessageId = lastMessageId;
}
}
} }
// Trigger background analysis if we have content // Trigger background analysis only if we have a prompt or it's not an asset-only request
// This should be very fast (just queuing the job, not waiting for completion) if (shouldTriggerAnalyst && (request.prompt || !request.asset_id)) {
if (request.prompt || request.asset_id) {
try { try {
// Just queue the background job - should be <100ms // Just queue the background job - should be <100ms
const taskHandle = await tasks.trigger( const taskHandle = await tasks.trigger(
'analyst-agent-task', 'analyst-agent-task',
{ {
message_id: messageId, message_id: actualMessageId,
}, },
{ {
concurrencyKey: chatId, // Ensure sequential processing per chat concurrencyKey: chatId, // Ensure sequential processing per chat
@ -96,7 +126,7 @@ export async function createChatHandler(
// Update the message with the trigger run ID // Update the message with the trigger run ID
const { updateMessage } = await import('@buster/database'); const { updateMessage } = await import('@buster/database');
await updateMessage(messageId, { await updateMessage(actualMessageId, {
triggerRunId: taskHandle.id, triggerRunId: taskHandle.id,
}); });

View File

@ -0,0 +1,133 @@
import type { User } from '@buster/database';
import {
type AssetDetailsResult,
type Message,
chats,
createMessage,
createMessageFileAssociation,
db,
getAssetDetailsById,
} from '@buster/database';
import type {
ChatAssetType,
ChatMessage,
ChatMessageResponseMessage,
} from '@buster/server-shared/chats';
import { eq } from 'drizzle-orm';
import { convertChatAssetTypeToDatabaseAssetType } from './server-asset-conversion';
/**
* Creates an import message for an asset
* This message represents the initial import of the asset into the chat
*/
export async function createAssetImportMessage(
chatId: string,
messageId: string,
assetId: string,
assetType: ChatAssetType,
assetDetails: AssetDetailsResult,
user: User
): Promise<Message> {
// Create the import message content
const importContent = `Imported ${assetType === 'metric' ? 'metric' : 'dashboard'} "${
assetDetails.name
}"`;
// Create the message in the database
const message = await createMessage({
messageId,
chatId,
content: importContent,
userId: user.id,
});
// Update the message to include response and mark as completed
const { updateMessage } = await import('@buster/database');
await updateMessage(messageId, {
isCompleted: true,
responseMessages: [
{
id: assetId,
type: 'file',
file_type: assetType === 'metric' ? 'metric' : 'dashboard',
file_name: assetDetails.name,
version_number: assetDetails.versionNumber,
},
],
});
// Create the file association
const dbAssetType = convertChatAssetTypeToDatabaseAssetType(assetType);
await createMessageFileAssociation({
messageId,
fileId: assetId,
fileType: dbAssetType,
version: assetDetails.versionNumber,
});
// Update the chat with most recent file information and title (matching Rust behavior)
const fileType = assetType === 'metric' ? 'metric' : 'dashboard';
await db
.update(chats)
.set({
title: assetDetails.name, // Set chat title to asset name
mostRecentFileId: assetId,
mostRecentFileType: fileType,
mostRecentVersionNumber: assetDetails.versionNumber,
updatedAt: new Date().toISOString(),
})
.where(eq(chats.id, chatId));
// Return the message with the updated fields
return {
...message,
isCompleted: true,
responseMessages: [
{
id: assetId,
type: 'file',
file_type: assetType === 'metric' ? 'metric' : 'dashboard',
file_name: assetDetails.name,
version_number: assetDetails.versionNumber,
},
],
};
}
/**
* Builds a ChatMessage from a database Message for an asset import
*/
export function buildAssetImportChatMessage(message: Message, user: User): ChatMessage {
const responseMessages: Record<string, ChatMessageResponseMessage> = {};
// Parse response messages if they exist
if (Array.isArray(message.responseMessages)) {
for (const resp of message.responseMessages as ChatMessageResponseMessage[]) {
if ('id' in resp && resp.id) {
responseMessages[resp.id] = resp;
}
}
}
return {
id: message.id,
created_at: message.createdAt,
updated_at: message.updatedAt,
request_message: message.requestMessage
? {
request: message.requestMessage,
sender_id: user.id,
sender_name: user.name || user.email || 'Unknown User',
sender_avatar: user.avatarUrl || undefined,
}
: null,
response_messages: responseMessages,
response_message_ids: Object.keys(responseMessages),
reasoning_messages: {},
reasoning_message_ids: [],
final_reasoning_message: null,
feedback: null,
is_completed: true,
post_processing_message: undefined,
};
}

File diff suppressed because it is too large Load Diff

View File

@ -63,12 +63,7 @@ function buildMessages<T extends { id: string }>(
} }
} }
// Early return for already-correct format // Handle array format (new format from generateAssetMessages)
if (parsedMessages && typeof parsedMessages === 'object' && !Array.isArray(parsedMessages)) {
return parsedMessages as Record<string, T>;
}
// Optimized array processing with pre-allocation and validation
if (Array.isArray(parsedMessages)) { if (Array.isArray(parsedMessages)) {
const result: Record<string, T> = {}; const result: Record<string, T> = {};
for (let i = 0; i < parsedMessages.length; i++) { for (let i = 0; i < parsedMessages.length; i++) {
@ -80,6 +75,11 @@ function buildMessages<T extends { id: string }>(
return result; return result;
} }
// Handle object format (legacy format with IDs as keys)
if (parsedMessages && typeof parsedMessages === 'object' && !Array.isArray(parsedMessages)) {
return parsedMessages as Record<string, T>;
}
return {}; return {};
} }
@ -388,6 +388,10 @@ export async function handleAssetChat(
// Convert and add to chat // Convert and add to chat
for (const msg of assetMessages) { for (const msg of assetMessages) {
// Build response messages from the database message
const responseMessages = buildResponseMessages(msg.responseMessages);
const responseMessageIds = Object.keys(responseMessages);
const chatMessage: ChatMessage = { const chatMessage: ChatMessage = {
id: msg.id, id: msg.id,
created_at: msg.createdAt, created_at: msg.createdAt,
@ -400,13 +404,13 @@ export async function handleAssetChat(
sender_avatar: chat.created_by_avatar || undefined, sender_avatar: chat.created_by_avatar || undefined,
} }
: null, : null,
response_messages: {}, response_messages: responseMessages,
response_message_ids: [], response_message_ids: responseMessageIds,
reasoning_message_ids: [], reasoning_message_ids: [],
reasoning_messages: {}, reasoning_messages: {},
final_reasoning_message: null, final_reasoning_message: msg.finalReasoningMessage || null,
feedback: null, feedback: null,
is_completed: false, is_completed: msg.isCompleted || false,
post_processing_message: validateNullableJsonb( post_processing_message: validateNullableJsonb(
msg.postProcessingMessage, msg.postProcessingMessage,
PostProcessingMessageSchema PostProcessingMessageSchema
@ -420,6 +424,26 @@ export async function handleAssetChat(
chat.messages[msg.id] = chatMessage; chat.messages[msg.id] = chatMessage;
} }
// Update the chat with most recent file information and title (matching Rust behavior)
const fileType = chatAssetType === 'metric' ? 'metric' : 'dashboard';
// Get the asset name from the first message
const assetName = assetMessages[0]?.title || '';
await db
.update(chats)
.set({
title: assetName, // Set chat title to asset name
mostRecentFileId: assetId,
mostRecentFileType: fileType,
mostRecentVersionNumber: 1, // Asset imports always start at version 1
updatedAt: new Date().toISOString(),
})
.where(eq(chats.id, chatId));
// Update the chat object with the new title
chat.title = assetName;
return chat; return chat;
} catch (error) { } catch (error) {
console.error('Failed to handle asset chat:', { console.error('Failed to handle asset chat:', {
@ -442,6 +466,222 @@ export async function handleAssetChat(
} }
} }
/**
* Handle asset-based chat initialization with a prompt
* This creates an import message for the asset, then adds the user's prompt as a follow-up
*/
export async function handleAssetChatWithPrompt(
chatId: string,
_messageId: string, // Initial message ID (not used since we create two messages)
assetId: string,
chatAssetType: ChatAssetType,
prompt: string,
user: User,
chat: ChatWithMessages
): Promise<ChatWithMessages> {
const userId = user.id;
try {
// First, use the exact same logic as handleAssetChat to import the asset
// This ensures we get dashboard metrics and proper formatting
const assetType = convertChatAssetTypeToDatabaseAssetType(chatAssetType);
const assetMessages = await generateAssetMessages({
assetId,
assetType,
userId,
chatId,
});
if (!assetMessages || assetMessages.length === 0) {
console.warn('No asset messages generated', {
assetId,
assetType,
userId,
chatId,
});
// Still create the user message with prompt
const userMessageId = crypto.randomUUID();
const userMessage = await createMessage({
messageId: userMessageId,
chatId,
content: prompt,
userId: user.id,
});
// Add to chat
const chatMessage: ChatMessage = {
id: userMessage.id,
created_at: userMessage.createdAt,
updated_at: userMessage.updatedAt,
request_message: {
request: prompt,
sender_id: user.id,
sender_name: chat.created_by_name,
sender_avatar: chat.created_by_avatar || undefined,
},
response_messages: {},
response_message_ids: [],
reasoning_message_ids: [],
reasoning_messages: {},
final_reasoning_message: null,
feedback: null,
is_completed: false,
post_processing_message: undefined,
};
if (!chat.message_ids.includes(userMessage.id)) {
chat.message_ids.push(userMessage.id);
}
chat.messages[userMessage.id] = chatMessage;
return chat;
}
// Add the import message to chat (exact same logic as handleAssetChat)
for (const msg of assetMessages) {
// Build response messages from the database message
const responseMessages = buildResponseMessages(msg.responseMessages);
const responseMessageIds = Object.keys(responseMessages);
const chatMessage: ChatMessage = {
id: msg.id,
created_at: msg.createdAt,
updated_at: msg.updatedAt,
request_message: msg.requestMessage
? {
request: msg.requestMessage,
sender_id: msg.createdBy,
sender_name: chat.created_by_name,
sender_avatar: chat.created_by_avatar || undefined,
}
: null,
response_messages: responseMessages,
response_message_ids: responseMessageIds,
reasoning_message_ids: [],
reasoning_messages: {},
final_reasoning_message: msg.finalReasoningMessage || null,
feedback: null,
is_completed: msg.isCompleted || false,
post_processing_message: validateNullableJsonb(
msg.postProcessingMessage,
PostProcessingMessageSchema
),
};
// Only add message ID if it doesn't already exist
if (!chat.message_ids.includes(msg.id)) {
chat.message_ids.push(msg.id);
}
chat.messages[msg.id] = chatMessage;
}
// Update the chat with most recent file information and title (matching handleAssetChat)
const fileType = chatAssetType === 'metric' ? 'metric' : 'dashboard';
const assetName = assetMessages[0]?.title || '';
await db
.update(chats)
.set({
title: assetName, // Set chat title to asset name
mostRecentFileId: assetId,
mostRecentFileType: fileType,
mostRecentVersionNumber: 1, // Asset imports always start at version 1
updatedAt: new Date().toISOString(),
})
.where(eq(chats.id, chatId));
// Update the chat object with the new title
chat.title = assetName;
// Then, create the user's prompt message as a follow-up
const userMessageId = crypto.randomUUID();
const userMessage = await createMessage({
messageId: userMessageId,
chatId,
content: prompt,
userId: user.id,
});
// Add user message to chat
const userChatMessage: ChatMessage = {
id: userMessage.id,
created_at: userMessage.createdAt,
updated_at: userMessage.updatedAt,
request_message: {
request: prompt,
sender_id: user.id,
sender_name: chat.created_by_name,
sender_avatar: chat.created_by_avatar || undefined,
},
response_messages: {},
response_message_ids: [],
reasoning_message_ids: [],
reasoning_messages: {},
final_reasoning_message: null,
feedback: null,
is_completed: false,
post_processing_message: undefined,
};
if (!chat.message_ids.includes(userMessage.id)) {
chat.message_ids.push(userMessage.id);
}
chat.messages[userMessage.id] = userChatMessage;
return chat;
} catch (error) {
console.error('Failed to handle asset chat with prompt:', {
chatId,
assetId,
chatAssetType,
userId,
error:
error instanceof Error
? {
message: error.message,
stack: error.stack,
name: error.name,
}
: String(error),
});
// Don't fail the entire request, create the user message anyway
const userMessageId = crypto.randomUUID();
const userMessage = await createMessage({
messageId: userMessageId,
chatId,
content: prompt,
userId: user.id,
});
const chatMessage: ChatMessage = {
id: userMessage.id,
created_at: userMessage.createdAt,
updated_at: userMessage.updatedAt,
request_message: {
request: prompt,
sender_id: user.id,
sender_name: chat.created_by_name,
sender_avatar: chat.created_by_avatar || undefined,
},
response_messages: {},
response_message_ids: [],
reasoning_message_ids: [],
reasoning_messages: {},
final_reasoning_message: null,
feedback: null,
is_completed: false,
post_processing_message: undefined,
};
if (!chat.message_ids.includes(userMessage.id)) {
chat.message_ids.push(userMessage.id);
}
chat.messages[userMessage.id] = chatMessage;
return chat;
}
}
/** /**
* Soft delete a message and all subsequent messages in the same chat * Soft delete a message and all subsequent messages in the same chat
* Used for "redo from this point" functionality * Used for "redo from this point" functionality

View File

@ -0,0 +1,25 @@
import React, { useMemo } from 'react';
import type { DropdownItem } from '@/components/ui/dropdown';
import { WandSparkle } from '@/components/ui/icons';
import { FollowUpWithAssetContent } from '@/components/features/popups/FollowUpWithAsset';
export const useMetricDrilldownItem = ({ metricId }: { metricId: string }): DropdownItem => {
return useMemo(
() => ({
value: 'drilldown',
label: 'Drill down & filter',
items: [
<FollowUpWithAssetContent
key="drilldown-and-filter"
assetType="metric"
assetId={metricId}
placeholder="Describe how you want to drill down or filter..."
buttonText="Submit request"
mode="drilldown"
/>
],
icon: <WandSparkle />
}),
[metricId]
);
};

View File

@ -8,6 +8,8 @@ import { AppTooltip } from '../../ui/tooltip';
import { useAppLayoutContextSelector } from '@/context/BusterAppLayout'; import { useAppLayoutContextSelector } from '@/context/BusterAppLayout';
import { assetParamsToRoute } from '../../../lib/assets'; import { assetParamsToRoute } from '../../../lib/assets';
type FollowUpMode = 'filter' | 'drilldown';
type FollowUpWithAssetProps = { type FollowUpWithAssetProps = {
assetType: Exclude<ShareAssetType, 'chat' | 'collection'>; assetType: Exclude<ShareAssetType, 'chat' | 'collection'>;
assetId: string; assetId: string;
@ -16,6 +18,7 @@ type FollowUpWithAssetProps = {
align?: PopoverProps['align']; align?: PopoverProps['align'];
placeholder?: string; placeholder?: string;
buttonText?: string; buttonText?: string;
mode?: FollowUpMode;
}; };
export const FollowUpWithAssetContent: React.FC<{ export const FollowUpWithAssetContent: React.FC<{
@ -23,22 +26,41 @@ export const FollowUpWithAssetContent: React.FC<{
assetId: string; assetId: string;
placeholder?: string; placeholder?: string;
buttonText?: string; buttonText?: string;
mode?: FollowUpMode;
}> = React.memo( }> = React.memo(
({ ({
assetType, assetType,
assetId, assetId,
placeholder = 'Describe the filter you want to apply', placeholder = 'Describe the filter you want to apply',
buttonText = 'Apply custom filter' buttonText = 'Apply custom filter',
mode
}) => { }) => {
const { mutateAsync: startChatFromAsset, isPending } = useStartChatFromAsset(); const { mutateAsync: startChatFromAsset, isPending } = useStartChatFromAsset();
const onChangePage = useAppLayoutContextSelector((x) => x.onChangePage); const onChangePage = useAppLayoutContextSelector((x) => x.onChangePage);
const transformPrompt = useMemoizedFn((userPrompt: string): string => {
if (!mode) return userPrompt;
if (mode === 'filter') {
return `Hey Buster. Please recreate this dashboard applying this filter to the metrics on the dashboard: ${userPrompt}`;
}
if (mode === 'drilldown') {
return `Hey Buster. Can you filter or drill down into this metric based on the following request: ${userPrompt}`;
}
return userPrompt;
});
const onSubmit = useMemoizedFn(async (prompt: string) => { const onSubmit = useMemoizedFn(async (prompt: string) => {
if (!prompt || !assetId || !assetType || isPending) return; if (!prompt || !assetId || !assetType || isPending) return;
const transformedPrompt = transformPrompt(prompt);
const res = await startChatFromAsset({ const res = await startChatFromAsset({
asset_id: assetId, asset_id: assetId,
asset_type: assetType, asset_type: assetType,
prompt prompt: transformedPrompt
}); });
const link = assetParamsToRoute({ const link = assetParamsToRoute({
assetId, assetId,
@ -63,7 +85,16 @@ export const FollowUpWithAssetContent: React.FC<{
FollowUpWithAssetContent.displayName = 'FollowUpWithAssetContent'; FollowUpWithAssetContent.displayName = 'FollowUpWithAssetContent';
export const FollowUpWithAssetPopup: React.FC<FollowUpWithAssetProps> = React.memo( export const FollowUpWithAssetPopup: React.FC<FollowUpWithAssetProps> = React.memo(
({ assetType, assetId, side = 'bottom', align = 'end', children, placeholder, buttonText }) => { ({
assetType,
assetId,
side = 'bottom',
align = 'end',
children,
placeholder,
buttonText,
mode
}) => {
return ( return (
<Popover <Popover
side={side} side={side}
@ -75,6 +106,7 @@ export const FollowUpWithAssetPopup: React.FC<FollowUpWithAssetProps> = React.me
assetId={assetId} assetId={assetId}
placeholder={placeholder} placeholder={placeholder}
buttonText={buttonText} buttonText={buttonText}
mode={mode}
/> />
}> }>
<AppTooltip title="Apply custom filter">{children}</AppTooltip> <AppTooltip title="Apply custom filter">{children}</AppTooltip>

View File

@ -32,6 +32,13 @@ export const InputCard: React.FC<InputCardProps> = ({
const disableSubmit = !inputHasText(inputValue) || loading; const disableSubmit = !inputHasText(inputValue) || loading;
const handlePressEnter = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (!disableSubmit) {
e.preventDefault();
onSubmit?.(inputValue);
}
};
const spacingClass = 'py-2.5 px-3'; const spacingClass = 'py-2.5 px-3';
return ( return (
@ -42,6 +49,7 @@ export const InputCard: React.FC<InputCardProps> = ({
value={inputValue} value={inputValue}
readOnly={loading} readOnly={loading}
onChange={handleChange} onChange={handleChange}
onPressEnter={handlePressEnter}
autoResize={{ autoResize={{
minRows: 5, minRows: 5,
maxRows: 10 maxRows: 10

View File

@ -7,7 +7,6 @@ import { Dropdown, type DropdownItems, type DropdownItem } from '@/components/ui
import { import {
DotsVertical, DotsVertical,
Trash, Trash,
WandSparkle,
ShareRight, ShareRight,
PenSparkle, PenSparkle,
SquareChartPen, SquareChartPen,
@ -17,7 +16,6 @@ import { cn } from '@/lib/utils';
import { ASSET_ICONS } from '@/components/features/config/assetIcons'; import { ASSET_ICONS } from '@/components/features/config/assetIcons';
import { assetParamsToRoute } from '@/lib/assets/assetParamsToRoute'; import { assetParamsToRoute } from '@/lib/assets/assetParamsToRoute';
import { useChatLayoutContextSelector } from '@/layouts/ChatLayout'; import { useChatLayoutContextSelector } from '@/layouts/ChatLayout';
import { FollowUpWithAssetContent } from '@/components/features/popups/FollowUpWithAsset';
import { useGetMetric } from '@/api/buster_rest/metrics'; import { useGetMetric } from '@/api/buster_rest/metrics';
import { getShareAssetConfig } from '@/components/features/ShareMenu/helpers'; import { getShareAssetConfig } from '@/components/features/ShareMenu/helpers';
import { getIsEffectiveOwner } from '@/lib/share'; import { getIsEffectiveOwner } from '@/lib/share';
@ -28,6 +26,7 @@ import {
useFavoriteMetricSelectMenu, useFavoriteMetricSelectMenu,
useVersionHistorySelectMenu useVersionHistorySelectMenu
} from '@/components/features/metrics/ThreeDotMenu'; } from '@/components/features/metrics/ThreeDotMenu';
import { useMetricDrilldownItem } from '@/components/features/metrics/hooks/useMetricDrilldownItem';
export const MetricItemCardThreeDotMenu: React.FC<{ export const MetricItemCardThreeDotMenu: React.FC<{
dashboardId: string; dashboardId: string;
@ -50,7 +49,7 @@ const MetricItemCardThreeDotMenuPopover: React.FC<{
const chatId = useChatLayoutContextSelector((x) => x.chatId); const chatId = useChatLayoutContextSelector((x) => x.chatId);
const removeFromDashboardItem = useRemoveFromDashboardItem({ dashboardId, metricId }); const removeFromDashboardItem = useRemoveFromDashboardItem({ dashboardId, metricId });
const openChartItem = useOpenChartItem({ dashboardId, metricId, chatId }); const openChartItem = useOpenChartItem({ dashboardId, metricId, chatId });
const drilldownItem = useDrilldownItem({ metricId }); const drilldownItem = useMetricDrilldownItem({ metricId });
const shareMenu = useShareMenuSelectMenu({ metricId }); const shareMenu = useShareMenuSelectMenu({ metricId });
const editWithAI = useEditWithAI({ metricId, dashboardId, chatId }); const editWithAI = useEditWithAI({ metricId, dashboardId, chatId });
const editChartButton = useEditChartButton({ metricId, dashboardId, chatId }); const editChartButton = useEditChartButton({ metricId, dashboardId, chatId });
@ -166,26 +165,6 @@ const useOpenChartItem = ({
}; };
}; };
const useDrilldownItem = ({ metricId }: { metricId: string }): DropdownItem => {
return useMemo(
() => ({
value: 'drilldown',
label: 'Drill down & filter',
items: [
<FollowUpWithAssetContent
key="drilldown-and-filter"
assetType="metric"
assetId={metricId}
placeholder="Describe how you want to drill down or filter..."
buttonText="Submit request"
/>
],
icon: <WandSparkle />
}),
[metricId]
);
};
const useShareMenuSelectMenu = ({ metricId }: { metricId: string }): DropdownItem | undefined => { const useShareMenuSelectMenu = ({ metricId }: { metricId: string }): DropdownItem | undefined => {
const { data: shareAssetConfig } = useGetMetric( const { data: shareAssetConfig } = useGetMetric(
{ id: metricId }, { id: metricId },

View File

@ -84,7 +84,7 @@ AddContentToDashboardButton.displayName = 'AddContentToDashboardButton';
const FollowUpWithAssetButton = React.memo(({ dashboardId }: { dashboardId: string }) => { const FollowUpWithAssetButton = React.memo(({ dashboardId }: { dashboardId: string }) => {
return ( return (
<FollowUpWithAssetPopup assetId={dashboardId} assetType="dashboard"> <FollowUpWithAssetPopup assetId={dashboardId} assetType="dashboard" mode="filter">
<Button variant="ghost" prefix={<BarsFilter />} /> <Button variant="ghost" prefix={<BarsFilter />} />
</FollowUpWithAssetPopup> </FollowUpWithAssetPopup>
); );

View File

@ -63,6 +63,7 @@ import {
useFavoriteMetricSelectMenu, useFavoriteMetricSelectMenu,
useVersionHistorySelectMenu useVersionHistorySelectMenu
} from '@/components/features/metrics/ThreeDotMenu'; } from '@/components/features/metrics/ThreeDotMenu';
import { useMetricDrilldownItem } from '@/components/features/metrics/hooks/useMetricDrilldownItem';
export const ThreeDotMenuButton = React.memo( export const ThreeDotMenuButton = React.memo(
({ ({
@ -90,6 +91,7 @@ export const ThreeDotMenuButton = React.memo(
const deleteMetricMenu = useDeleteMetricSelectMenu({ metricId }); const deleteMetricMenu = useDeleteMetricSelectMenu({ metricId });
const renameMetricMenu = useRenameMetricSelectMenu({ metricId }); const renameMetricMenu = useRenameMetricSelectMenu({ metricId });
const shareMenu = useShareMenuSelectMenu({ metricId }); const shareMenu = useShareMenuSelectMenu({ metricId });
const drilldownItem = useMetricDrilldownItem({ metricId });
const isEditor = canEdit(permission); const isEditor = canEdit(permission);
const isOwnerEffective = getIsEffectiveOwner(permission); const isOwnerEffective = getIsEffectiveOwner(permission);
@ -99,6 +101,7 @@ export const ThreeDotMenuButton = React.memo(
() => () =>
[ [
chatId && openFullScreenMetric, chatId && openFullScreenMetric,
drilldownItem,
isOwnerEffective && !isViewingOldVersion && shareMenu, isOwnerEffective && !isViewingOldVersion && shareMenu,
isEditor && !isViewingOldVersion && statusSelectMenu, isEditor && !isViewingOldVersion && statusSelectMenu,
{ type: 'divider' }, { type: 'divider' },
@ -120,6 +123,7 @@ export const ThreeDotMenuButton = React.memo(
[ [
chatId, chatId,
openFullScreenMetric, openFullScreenMetric,
drilldownItem,
isEditor, isEditor,
isOwner, isOwner,
isOwnerEffective, isOwnerEffective,

View File

@ -1,4 +1,4 @@
import { and, eq, isNull } from 'drizzle-orm'; import { and, eq, inArray, isNull } from 'drizzle-orm';
import type { InferSelectModel } from 'drizzle-orm'; import type { InferSelectModel } from 'drizzle-orm';
import { z } from 'zod'; import { z } from 'zod';
import { db } from '../../connection'; import { db } from '../../connection';
@ -33,6 +33,38 @@ interface AssetDetails {
createdBy: string; createdBy: string;
} }
/**
* Dashboard content schema for parsing
*/
const DashboardContentSchema = z.object({
name: z.string(),
description: z.string().optional(),
rows: z.array(
z.object({
id: z.number(),
items: z.array(
z.object({
id: z.string().uuid(),
})
),
columnSizes: z.array(z.number()),
})
),
});
/**
* Extract metric IDs from dashboard content
*/
function extractMetricIds(content: unknown): string[] {
try {
const parsedContent = DashboardContentSchema.parse(content);
const metricIds = parsedContent.rows.flatMap((row) => row.items.map((item) => item.id));
return [...new Set(metricIds)];
} catch {
return [];
}
}
/** /**
* Get asset details based on type * Get asset details based on type
*/ */
@ -76,6 +108,7 @@ async function getAssetDetails(
/** /**
* Generate initial messages for an asset-based chat * Generate initial messages for an asset-based chat
* This matches the Rust implementation exactly
*/ */
export async function generateAssetMessages(input: GenerateAssetMessagesInput): Promise<Message[]> { export async function generateAssetMessages(input: GenerateAssetMessagesInput): Promise<Message[]> {
const validated = GenerateAssetMessagesInputSchema.parse(input); const validated = GenerateAssetMessagesInputSchema.parse(input);
@ -86,69 +119,138 @@ export async function generateAssetMessages(input: GenerateAssetMessagesInput):
throw new Error(`Asset not found: ${validated.assetId}`); throw new Error(`Asset not found: ${validated.assetId}`);
} }
const createdMessages: Message[] = []; const timestamp = Math.floor(Date.now() / 1000);
const assetTypeStr = validated.assetType === 'metric_file' ? 'metric' : 'dashboard';
// Create initial user message based on asset type // Prepare asset data and fetch additional context files for dashboards
const userMessageContent = interface AssetFileData {
validated.assetType === 'metric_file' id: string;
? `Let me help you analyze the metric "${asset.name}".` name: string;
: `Let me help you explore the dashboard "${asset.name}".`; file_type: string;
asset_type: string;
const [userMessage] = await db yml_content: string;
.insert(messages) created_at: string;
.values({ version_number: number;
chatId: validated.chatId, updated_at: string;
createdBy: validated.userId,
requestMessage: userMessageContent,
responseMessages: {},
reasoning: {},
title: userMessageContent,
rawLlmMessages: {},
isCompleted: true,
})
.returning();
if (userMessage) {
createdMessages.push(userMessage);
} }
// Create assistant message with asset context let additionalFiles: AssetFileData[] = [];
const assistantMessageId = crypto.randomUUID(); let messageText = `Successfully imported 1 ${assetTypeStr} file.`;
const assistantContent =
validated.assetType === 'metric_file'
? `I'm ready to help you analyze the metric "${asset.name}". What would you like to know about it?`
: `I'm ready to help you explore the dashboard "${asset.name}". What would you like to understand about it?`;
const [assistantMessage] = await db const assetData = {
id: validated.assetId,
name: asset.name,
file_type: assetTypeStr,
asset_type: assetTypeStr,
yml_content: JSON.stringify(asset.content), // Using JSON since we don't have YAML serializer
created_at: new Date().toISOString(),
version_number: 1,
updated_at: new Date().toISOString(),
};
// If it's a dashboard, fetch associated metrics
if (validated.assetType === 'dashboard_file') {
const metricIds = extractMetricIds(asset.content);
if (metricIds.length > 0) {
// Fetch all metrics associated with the dashboard
const metrics = await db
.select({
id: metricFiles.id,
name: metricFiles.name,
content: metricFiles.content,
createdBy: metricFiles.createdBy,
createdAt: metricFiles.createdAt,
updatedAt: metricFiles.updatedAt,
})
.from(metricFiles)
.where(and(inArray(metricFiles.id, metricIds), isNull(metricFiles.deletedAt)));
// Format metric data for inclusion
additionalFiles = metrics.map((metric) => ({
id: metric.id,
name: metric.name,
file_type: 'metric',
asset_type: 'metric',
yml_content: JSON.stringify(metric.content),
created_at: metric.createdAt,
version_number: 1,
updated_at: metric.updatedAt,
}));
messageText = `Successfully imported 1 dashboard file with ${additionalFiles.length} additional context files.`;
}
}
// Create combined file list with the main asset first, followed by context files
const allFiles = [assetData, ...additionalFiles];
// Create the user message with imported asset information (matching Rust)
const userMessageForAgent = {
role: 'user',
content: `I've imported the following ${assetTypeStr}:\n\n${messageText}\n\nFile details:\n${JSON.stringify(allFiles, null, 2)}`,
};
const rawLlmMessages = [userMessageForAgent];
// Generate IDs for the response messages
const textMessageId = crypto.randomUUID();
const fileMessageId = validated.assetId; // Use the asset ID as the file message ID
// Create response messages as an array (matching Rust format)
const responseMessages = [
{
type: 'text',
id: textMessageId,
message: `${asset.name} has been pulled into a new chat.\n\nContinue chatting to modify or make changes to it.`,
is_final_message: true,
},
{
type: 'file',
id: fileMessageId,
file_type: assetTypeStr,
file_name: asset.name,
version_number: 1,
filter_version_id: null,
metadata: [
{
status: 'completed',
message: 'Pulled into new chat',
timestamp: timestamp,
},
],
},
];
// Create the message with no request_message (matching Rust)
const [message] = await db
.insert(messages) .insert(messages)
.values({ .values({
chatId: validated.chatId, chatId: validated.chatId,
createdBy: validated.userId, createdBy: validated.userId,
requestMessage: null, requestMessage: null, // No request message, matching Rust
responseMessages: { responseMessages: responseMessages, // Use array format
content: assistantContent, reasoning: [],
role: 'assistant', finalReasoningMessage: '',
}, title: asset.name,
reasoning: {}, rawLlmMessages: rawLlmMessages,
title: assistantContent,
rawLlmMessages: {},
isCompleted: true, isCompleted: true,
}) })
.returning(); .returning();
if (assistantMessage) { if (message) {
createdMessages.push(assistantMessage); // Create file association for the message
// Create file association for the assistant message
await createMessageFileAssociation({ await createMessageFileAssociation({
messageId: assistantMessageId, messageId: message.id,
fileId: validated.assetId, fileId: validated.assetId,
fileType: validated.assetType, fileType: validated.assetType,
version: 1, version: 1,
}); });
return [message];
} }
return createdMessages; return [];
} }
/** /**
@ -172,3 +274,95 @@ export async function createMessageFileAssociation(
isDuplicate: false, isDuplicate: false,
}); });
} }
/**
* Get asset details by ID and type (for TypeScript server)
*/
export const GetAssetDetailsInputSchema = z.object({
assetId: z.string().uuid(),
assetType: DatabaseAssetTypeSchema,
});
export type GetAssetDetailsInput = z.infer<typeof GetAssetDetailsInputSchema>;
export interface AssetDetailsResult {
id: string;
name: string;
content: unknown;
versionNumber: number;
createdBy: string;
}
export async function getAssetDetailsById(
input: GetAssetDetailsInput
): Promise<AssetDetailsResult | null> {
const validated = GetAssetDetailsInputSchema.parse(input);
if (validated.assetType === 'metric_file') {
const [metric] = await db
.select({
id: metricFiles.id,
name: metricFiles.name,
content: metricFiles.content,
versionHistory: metricFiles.versionHistory,
createdBy: metricFiles.createdBy,
})
.from(metricFiles)
.where(and(eq(metricFiles.id, validated.assetId), isNull(metricFiles.deletedAt)))
.limit(1);
if (!metric) return null;
// Extract version number from version history
const versionNumber =
typeof metric.versionHistory === 'object' &&
metric.versionHistory &&
'version_number' in metric.versionHistory
? (metric.versionHistory as { version_number: number }).version_number
: 1;
return {
id: metric.id,
name: metric.name,
content: metric.content,
versionNumber,
createdBy: metric.createdBy,
};
}
if (validated.assetType === 'dashboard_file') {
const [dashboard] = await db
.select({
id: dashboardFiles.id,
name: dashboardFiles.name,
content: dashboardFiles.content,
versionHistory: dashboardFiles.versionHistory,
createdBy: dashboardFiles.createdBy,
})
.from(dashboardFiles)
.where(and(eq(dashboardFiles.id, validated.assetId), isNull(dashboardFiles.deletedAt)))
.limit(1);
if (!dashboard) return null;
// Extract version number from version history
const versionNumber =
typeof dashboard.versionHistory === 'object' &&
dashboard.versionHistory &&
'version_number' in dashboard.versionHistory
? (dashboard.versionHistory as { version_number: number }).version_number
: 1;
return {
id: dashboard.id,
name: dashboard.name,
content: dashboard.content,
versionNumber,
createdBy: dashboard.createdBy,
};
}
// Exhaustive check
const _exhaustiveCheck: never = validated.assetType;
return null;
}

View File

@ -4,6 +4,10 @@ export {
createMessageFileAssociation, createMessageFileAssociation,
GenerateAssetMessagesInputSchema, GenerateAssetMessagesInputSchema,
type GenerateAssetMessagesInput, type GenerateAssetMessagesInput,
getAssetDetailsById,
GetAssetDetailsInputSchema,
type GetAssetDetailsInput,
type AssetDetailsResult,
} from './assets'; } from './assets';
export type { DatabaseAssetType } from './assets'; export type { DatabaseAssetType } from './assets';