mirror of https://github.com/buster-so/buster.git
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:
commit
4464abfc22
|
@ -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 () => {
|
||||||
|
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -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
|
@ -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
|
||||||
|
|
|
@ -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]
|
||||||
|
);
|
||||||
|
};
|
|
@ -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>
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 },
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
@ -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';
|
||||||
|
|
Loading…
Reference in New Issue