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', () => ({
|
||||
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 () => {
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
@ -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
|
||||
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
|
||||
|
|
|
@ -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 { 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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 },
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -4,6 +4,10 @@ export {
|
|||
createMessageFileAssociation,
|
||||
GenerateAssetMessagesInputSchema,
|
||||
type GenerateAssetMessagesInput,
|
||||
getAssetDetailsById,
|
||||
GetAssetDetailsInputSchema,
|
||||
type GetAssetDetailsInput,
|
||||
type AssetDetailsResult,
|
||||
} from './assets';
|
||||
|
||||
export type { DatabaseAssetType } from './assets';
|
||||
|
|
Loading…
Reference in New Issue