diff --git a/apps/sim/app/api/mothership/chats/[chatId]/route.test.ts b/apps/sim/app/api/mothership/chats/[chatId]/route.test.ts index 3d54f4fad0..7e96d47622 100644 --- a/apps/sim/app/api/mothership/chats/[chatId]/route.test.ts +++ b/apps/sim/app/api/mothership/chats/[chatId]/route.test.ts @@ -47,8 +47,8 @@ vi.mock('drizzle-orm', () => ({ vi.mock('@/lib/copilot/request/http', () => copilotHttpMock) vi.mock('@/lib/copilot/chat/lifecycle', () => ({ - getAccessibleCopilotChat: mockGetAccessibleCopilotChat, getAccessibleCopilotChatAuth: mockGetAccessibleCopilotChat, + getAccessibleCopilotChatWithMessages: mockGetAccessibleCopilotChat, })) vi.mock('@/lib/copilot/chat/stream-liveness', () => ({ diff --git a/apps/sim/app/api/mothership/chats/[chatId]/route.ts b/apps/sim/app/api/mothership/chats/[chatId]/route.ts index 121a01b5fd..2d7c20c1b1 100644 --- a/apps/sim/app/api/mothership/chats/[chatId]/route.ts +++ b/apps/sim/app/api/mothership/chats/[chatId]/route.ts @@ -13,8 +13,8 @@ import { parseRequest } from '@/lib/api/server' import { getLatestRunForStream } from '@/lib/copilot/async-runs/repository' import { buildEffectiveChatTranscript } from '@/lib/copilot/chat/effective-transcript' import { - getAccessibleCopilotChat, getAccessibleCopilotChatAuth, + getAccessibleCopilotChatWithMessages, } from '@/lib/copilot/chat/lifecycle' import { normalizeMessage } from '@/lib/copilot/chat/persisted-message' import { reconcileChatStreamMarkers } from '@/lib/copilot/chat/stream-liveness' @@ -45,7 +45,7 @@ export const GET = withRouteHandler( if (!paramsResult.success) return paramsResult.response const { chatId } = paramsResult.data.params - const chat = await getAccessibleCopilotChat(chatId, userId) + const chat = await getAccessibleCopilotChatWithMessages(chatId, userId) if (!chat || chat.type !== 'mothership') { return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 }) } diff --git a/apps/sim/lib/copilot/chat/lifecycle.ts b/apps/sim/lib/copilot/chat/lifecycle.ts index 5224ffdd61..01ec3f5b17 100644 --- a/apps/sim/lib/copilot/chat/lifecycle.ts +++ b/apps/sim/lib/copilot/chat/lifecycle.ts @@ -15,7 +15,7 @@ const logger = createLogger('CopilotChatLifecycle') export interface ChatLoadResult { chatId: string - chat: typeof copilotChats.$inferSelect | null + chat: CopilotChatDetailRow | null conversationHistory: unknown[] isNew: boolean } @@ -34,11 +34,43 @@ const copilotChatAuthColumns = { type: copilotChats.type, } as const +/** + * Column set for chat-detail callers that need the conversation transcript but + * not the copilot-only TOAST-able fields (`previewYaml`, `planArtifact`, + * `config`) or unused metadata (`model`, `pinned`, `lastSeenAt`). Selecting + * only these columns avoids the Postgres detoast cost on the dropped fields, + * which dominates latency for chats with large message histories. + */ +const copilotChatDetailColumns = { + ...copilotChatAuthColumns, + title: copilotChats.title, + messages: copilotChats.messages, + conversationId: copilotChats.conversationId, + resources: copilotChats.resources, + createdAt: copilotChats.createdAt, + updatedAt: copilotChats.updatedAt, +} as const + type CopilotChatAuthRow = Pick< typeof copilotChats.$inferSelect, 'id' | 'userId' | 'workflowId' | 'workspaceId' | 'type' > +export type CopilotChatDetailRow = Pick< + typeof copilotChats.$inferSelect, + | 'id' + | 'userId' + | 'workflowId' + | 'workspaceId' + | 'type' + | 'title' + | 'messages' + | 'conversationId' + | 'resources' + | 'createdAt' + | 'updatedAt' +> + async function authorizeCopilotChatRow( chat: T | undefined, chatId: string, @@ -99,8 +131,10 @@ export async function getAccessibleCopilotChatAuth( /** * Load the full copilot chat row after authorization. Use this only when the - * caller actually consumes the heavy columns (`messages`, `planArtifact`, - * `config`, etc.) — for example, chat resume or the GET-by-id endpoint. + * caller actually consumes copilot-only TOAST-able columns (`previewYaml`, + * `planArtifact`, `config`) or other extended metadata — for example the + * legacy copilot chat detail endpoint. Mothership chats and other consumers + * that only need the transcript should prefer `getAccessibleCopilotChatWithMessages`. */ export async function getAccessibleCopilotChat(chatId: string, userId: string) { const [chat] = await db @@ -112,6 +146,27 @@ export async function getAccessibleCopilotChat(chatId: string, userId: string) { return authorizeCopilotChatRow(chat, chatId, userId) } +/** + * Load a copilot chat with the conversation transcript and resources after + * authorization, omitting copilot-only TOAST-able fields (`previewYaml`, + * `planArtifact`, `config`) and unused metadata (`model`, `pinned`, + * `lastSeenAt`). Use this for the mothership chat detail endpoint and the + * shared `resolveOrCreateChat` path — every column read here is consumed + * downstream, and dropping the others avoids per-request detoast overhead. + */ +export async function getAccessibleCopilotChatWithMessages( + chatId: string, + userId: string +): Promise { + const [chat] = await db + .select(copilotChatDetailColumns) + .from(copilotChats) + .where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId))) + .limit(1) + + return authorizeCopilotChatRow(chat, chatId, userId) +} + /** * Resolve or create a copilot chat session. * If chatId is provided, loads the existing chat. Otherwise creates a new one. @@ -132,7 +187,7 @@ export async function resolveOrCreateChat(params: { } if (chatId) { - const chat = await getAccessibleCopilotChat(chatId, userId) + const chat = await getAccessibleCopilotChatWithMessages(chatId, userId) if (chat) { if (workflowId && chat.workflowId !== workflowId) { @@ -189,7 +244,7 @@ export async function resolveOrCreateChat(params: { messages: [], lastSeenAt: now, }) - .returning() + .returning(copilotChatDetailColumns) if (!newChat) { logger.warn('Failed to create new copilot chat row', { userId, workflowId, workspaceId }) diff --git a/apps/sim/lib/copilot/chat/process-contents.ts b/apps/sim/lib/copilot/chat/process-contents.ts index dd3cdccc3d..21b6842cd6 100644 --- a/apps/sim/lib/copilot/chat/process-contents.ts +++ b/apps/sim/lib/copilot/chat/process-contents.ts @@ -228,8 +228,8 @@ async function processPastChatFromDb( currentWorkspaceId?: string ): Promise { try { - const { getAccessibleCopilotChat } = await import('./lifecycle') - const chat = await getAccessibleCopilotChat(chatId, userId) + const { getAccessibleCopilotChatWithMessages } = await import('./lifecycle') + const chat = await getAccessibleCopilotChatWithMessages(chatId, userId) if (!chat) { return null }