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', () => ({
handleAssetChat: vi.fn(),
handleAssetChatWithPrompt: vi.fn(),
}));
vi.mock('@buster/database', () => ({
@ -41,7 +42,7 @@ vi.mock('@buster/database', () => ({
import { getUserOrganizationId, updateMessage } from '@buster/database';
import { tasks } from '@trigger.dev/sdk/v3';
import { createChatHandler } from './handler';
import { handleAssetChat } from './services/chat-helpers';
import { handleAssetChat, handleAssetChatWithPrompt } from './services/chat-helpers';
import { initializeChat } from './services/chat-service';
describe('createChatHandler', () => {
@ -120,8 +121,51 @@ describe('createChatHandler', () => {
expect(result).toEqual(mockChat);
});
it('should handle asset-based chat creation', async () => {
const assetChat = { ...mockChat, title: 'Asset Chat' };
it('should handle asset-based chat creation and NOT trigger analyst task', async () => {
// 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);
const result = await createChatHandler(
@ -137,15 +181,8 @@ describe('createChatHandler', () => {
mockUser,
mockChat
);
expect(tasks.trigger).toHaveBeenCalledWith(
'analyst-agent-task',
{
message_id: 'msg-123',
},
{
concurrencyKey: 'chat-123',
}
);
// IMPORTANT: Should NOT trigger analyst task for asset-only requests
expect(tasks.trigger).not.toHaveBeenCalled();
expect(result).toEqual(assetChat);
});
@ -158,23 +195,177 @@ describe('createChatHandler', () => {
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(
{ prompt: 'Hello', asset_id: 'asset-123', asset_type: 'metric' },
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(handleAssetChatWithPrompt).toHaveBeenCalledWith(
'chat-123',
'msg-123',
'asset-123',
'metric',
'Hello',
mockUser,
emptyChat
);
expect(tasks.trigger).toHaveBeenCalledWith(
'analyst-agent-task',
{
message_id: 'msg-123',
message_id: 'user-msg-123', // Should use the last message ID (user's prompt)
},
{
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 () => {

View File

@ -7,7 +7,7 @@ import {
type ChatWithMessages,
} from '@buster/server-shared/chats';
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';
/**
@ -57,30 +57,60 @@ export async function createChatHandler(
}
// 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
let finalChat: ChatWithMessages = chat;
if (request.asset_id && request.asset_type && !request.prompt) {
finalChat = await handleAssetChat(
chatId,
messageId,
request.asset_id,
request.asset_type,
user,
chat
);
let actualMessageId = messageId; // Track the actual message ID to use for triggering
let shouldTriggerAnalyst = true; // Flag to control whether to trigger analyst task
if (request.asset_id && request.asset_type) {
if (!request.prompt) {
// Original flow: just import the asset without a prompt
finalChat = await handleAssetChat(
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
// This should be very fast (just queuing the job, not waiting for completion)
if (request.prompt || request.asset_id) {
// Trigger background analysis only if we have a prompt or it's not an asset-only request
if (shouldTriggerAnalyst && (request.prompt || !request.asset_id)) {
try {
// Just queue the background job - should be <100ms
const taskHandle = await tasks.trigger(
'analyst-agent-task',
{
message_id: messageId,
message_id: actualMessageId,
},
{
concurrencyKey: chatId, // Ensure sequential processing per chat
@ -96,7 +126,7 @@ export async function createChatHandler(
// Update the message with the trigger run ID
const { updateMessage } = await import('@buster/database');
await updateMessage(messageId, {
await updateMessage(actualMessageId, {
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
if (parsedMessages && typeof parsedMessages === 'object' && !Array.isArray(parsedMessages)) {
return parsedMessages as Record<string, T>;
}
// Optimized array processing with pre-allocation and validation
// Handle array format (new format from generateAssetMessages)
if (Array.isArray(parsedMessages)) {
const result: Record<string, T> = {};
for (let i = 0; i < parsedMessages.length; i++) {
@ -80,6 +75,11 @@ function buildMessages<T extends { id: string }>(
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 {};
}
@ -388,6 +388,10 @@ export async function handleAssetChat(
// Convert and add to chat
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,
@ -400,13 +404,13 @@ export async function handleAssetChat(
sender_avatar: chat.created_by_avatar || undefined,
}
: null,
response_messages: {},
response_message_ids: [],
response_messages: responseMessages,
response_message_ids: responseMessageIds,
reasoning_message_ids: [],
reasoning_messages: {},
final_reasoning_message: null,
final_reasoning_message: msg.finalReasoningMessage || null,
feedback: null,
is_completed: false,
is_completed: msg.isCompleted || false,
post_processing_message: validateNullableJsonb(
msg.postProcessingMessage,
PostProcessingMessageSchema
@ -420,6 +424,26 @@ export async function handleAssetChat(
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;
} catch (error) {
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
* 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 { assetParamsToRoute } from '../../../lib/assets';
type FollowUpMode = 'filter' | 'drilldown';
type FollowUpWithAssetProps = {
assetType: Exclude<ShareAssetType, 'chat' | 'collection'>;
assetId: string;
@ -16,6 +18,7 @@ type FollowUpWithAssetProps = {
align?: PopoverProps['align'];
placeholder?: string;
buttonText?: string;
mode?: FollowUpMode;
};
export const FollowUpWithAssetContent: React.FC<{
@ -23,22 +26,41 @@ export const FollowUpWithAssetContent: React.FC<{
assetId: string;
placeholder?: string;
buttonText?: string;
mode?: FollowUpMode;
}> = React.memo(
({
assetType,
assetId,
placeholder = 'Describe the filter you want to apply',
buttonText = 'Apply custom filter'
buttonText = 'Apply custom filter',
mode
}) => {
const { mutateAsync: startChatFromAsset, isPending } = useStartChatFromAsset();
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) => {
if (!prompt || !assetId || !assetType || isPending) return;
const transformedPrompt = transformPrompt(prompt);
const res = await startChatFromAsset({
asset_id: assetId,
asset_type: assetType,
prompt
prompt: transformedPrompt
});
const link = assetParamsToRoute({
assetId,
@ -63,7 +85,16 @@ export const FollowUpWithAssetContent: React.FC<{
FollowUpWithAssetContent.displayName = 'FollowUpWithAssetContent';
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 (
<Popover
side={side}
@ -75,6 +106,7 @@ export const FollowUpWithAssetPopup: React.FC<FollowUpWithAssetProps> = React.me
assetId={assetId}
placeholder={placeholder}
buttonText={buttonText}
mode={mode}
/>
}>
<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 handlePressEnter = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (!disableSubmit) {
e.preventDefault();
onSubmit?.(inputValue);
}
};
const spacingClass = 'py-2.5 px-3';
return (
@ -42,6 +49,7 @@ export const InputCard: React.FC<InputCardProps> = ({
value={inputValue}
readOnly={loading}
onChange={handleChange}
onPressEnter={handlePressEnter}
autoResize={{
minRows: 5,
maxRows: 10

View File

@ -7,7 +7,6 @@ import { Dropdown, type DropdownItems, type DropdownItem } from '@/components/ui
import {
DotsVertical,
Trash,
WandSparkle,
ShareRight,
PenSparkle,
SquareChartPen,
@ -17,7 +16,6 @@ import { cn } from '@/lib/utils';
import { ASSET_ICONS } from '@/components/features/config/assetIcons';
import { assetParamsToRoute } from '@/lib/assets/assetParamsToRoute';
import { useChatLayoutContextSelector } from '@/layouts/ChatLayout';
import { FollowUpWithAssetContent } from '@/components/features/popups/FollowUpWithAsset';
import { useGetMetric } from '@/api/buster_rest/metrics';
import { getShareAssetConfig } from '@/components/features/ShareMenu/helpers';
import { getIsEffectiveOwner } from '@/lib/share';
@ -28,6 +26,7 @@ import {
useFavoriteMetricSelectMenu,
useVersionHistorySelectMenu
} from '@/components/features/metrics/ThreeDotMenu';
import { useMetricDrilldownItem } from '@/components/features/metrics/hooks/useMetricDrilldownItem';
export const MetricItemCardThreeDotMenu: React.FC<{
dashboardId: string;
@ -50,7 +49,7 @@ const MetricItemCardThreeDotMenuPopover: React.FC<{
const chatId = useChatLayoutContextSelector((x) => x.chatId);
const removeFromDashboardItem = useRemoveFromDashboardItem({ dashboardId, metricId });
const openChartItem = useOpenChartItem({ dashboardId, metricId, chatId });
const drilldownItem = useDrilldownItem({ metricId });
const drilldownItem = useMetricDrilldownItem({ metricId });
const shareMenu = useShareMenuSelectMenu({ metricId });
const editWithAI = useEditWithAI({ 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 { data: shareAssetConfig } = useGetMetric(
{ id: metricId },

View File

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

View File

@ -63,6 +63,7 @@ import {
useFavoriteMetricSelectMenu,
useVersionHistorySelectMenu
} from '@/components/features/metrics/ThreeDotMenu';
import { useMetricDrilldownItem } from '@/components/features/metrics/hooks/useMetricDrilldownItem';
export const ThreeDotMenuButton = React.memo(
({
@ -90,6 +91,7 @@ export const ThreeDotMenuButton = React.memo(
const deleteMetricMenu = useDeleteMetricSelectMenu({ metricId });
const renameMetricMenu = useRenameMetricSelectMenu({ metricId });
const shareMenu = useShareMenuSelectMenu({ metricId });
const drilldownItem = useMetricDrilldownItem({ metricId });
const isEditor = canEdit(permission);
const isOwnerEffective = getIsEffectiveOwner(permission);
@ -99,6 +101,7 @@ export const ThreeDotMenuButton = React.memo(
() =>
[
chatId && openFullScreenMetric,
drilldownItem,
isOwnerEffective && !isViewingOldVersion && shareMenu,
isEditor && !isViewingOldVersion && statusSelectMenu,
{ type: 'divider' },
@ -120,6 +123,7 @@ export const ThreeDotMenuButton = React.memo(
[
chatId,
openFullScreenMetric,
drilldownItem,
isEditor,
isOwner,
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 { z } from 'zod';
import { db } from '../../connection';
@ -33,6 +33,38 @@ interface AssetDetails {
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
*/
@ -76,6 +108,7 @@ async function getAssetDetails(
/**
* Generate initial messages for an asset-based chat
* This matches the Rust implementation exactly
*/
export async function generateAssetMessages(input: GenerateAssetMessagesInput): Promise<Message[]> {
const validated = GenerateAssetMessagesInputSchema.parse(input);
@ -86,69 +119,138 @@ export async function generateAssetMessages(input: GenerateAssetMessagesInput):
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
const userMessageContent =
validated.assetType === 'metric_file'
? `Let me help you analyze the metric "${asset.name}".`
: `Let me help you explore the dashboard "${asset.name}".`;
const [userMessage] = await db
.insert(messages)
.values({
chatId: validated.chatId,
createdBy: validated.userId,
requestMessage: userMessageContent,
responseMessages: {},
reasoning: {},
title: userMessageContent,
rawLlmMessages: {},
isCompleted: true,
})
.returning();
if (userMessage) {
createdMessages.push(userMessage);
// Prepare asset data and fetch additional context files for dashboards
interface AssetFileData {
id: string;
name: string;
file_type: string;
asset_type: string;
yml_content: string;
created_at: string;
version_number: number;
updated_at: string;
}
// Create assistant message with asset context
const assistantMessageId = crypto.randomUUID();
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?`;
let additionalFiles: AssetFileData[] = [];
let messageText = `Successfully imported 1 ${assetTypeStr} file.`;
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)
.values({
chatId: validated.chatId,
createdBy: validated.userId,
requestMessage: null,
responseMessages: {
content: assistantContent,
role: 'assistant',
},
reasoning: {},
title: assistantContent,
rawLlmMessages: {},
requestMessage: null, // No request message, matching Rust
responseMessages: responseMessages, // Use array format
reasoning: [],
finalReasoningMessage: '',
title: asset.name,
rawLlmMessages: rawLlmMessages,
isCompleted: true,
})
.returning();
if (assistantMessage) {
createdMessages.push(assistantMessage);
// Create file association for the assistant message
if (message) {
// Create file association for the message
await createMessageFileAssociation({
messageId: assistantMessageId,
messageId: message.id,
fileId: validated.assetId,
fileType: validated.assetType,
version: 1,
});
return [message];
}
return createdMessages;
return [];
}
/**
@ -172,3 +274,95 @@ export async function createMessageFileAssociation(
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,
GenerateAssetMessagesInputSchema,
type GenerateAssetMessagesInput,
getAssetDetailsById,
GetAssetDetailsInputSchema,
type GetAssetDetailsInput,
type AssetDetailsResult,
} from './assets';
export type { DatabaseAssetType } from './assets';