From 54e1862909c635eb50e6daf8e0c397d9743efbf0 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 24 Apr 2026 12:54:05 +0000 Subject: [PATCH] perf(frontend): split vendor chunks and lazy-load AI SDKs Two bundle-size wins for the frontend, focused on first-paint for logged-in users (the production build is 83 MB across 624 JS files today): - Configure Vite manualChunks so Monaco, ag-grid/charts, AI SDKs, markdown, @xyflow+d3-dag, yjs, pdfjs, chart.js, quicktype and quill each land in a single cacheable vendor chunk instead of being fragmented across route chunks. No byte reduction, but users stop redownloading Monaco on every navigation and the number of JS files drops from 624 to 583. - Convert the OpenAI and Anthropic SDK imports in copilot/lib.ts (and the chat/* helpers) to dynamic imports. The SDKs were previously imported eagerly through copilot/lib -> aiStore -> sidebar/WorkspaceMenu, so every logged-in page paid ~41 KB gzipped for code only used by Copilot. The SDKs now live in a separate chunk loaded on first construction of a client. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../copilot/chat/AIChatManager.svelte.ts | 8 +++- .../lib/components/copilot/chat/anthropic.ts | 6 +-- .../lib/components/copilot/chat/chatLoop.ts | 33 ++++--------- .../copilot/chat/openai-responses.ts | 16 +++---- frontend/src/lib/components/copilot/lib.ts | 36 +++++++------- frontend/vite.config.js | 47 +++++++++++++++++++ 6 files changed, 91 insertions(+), 55 deletions(-) diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index faf5da8c2b..966f5ce1c2 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -416,6 +416,10 @@ class AIChatManager { // on each iteration. This is critical for changeModeTool (Navigator → Script/Flow) // which reassigns this.tools, this.helpers, this.systemMessage mid-loop. const self = this + const [openai, anthropic] = await Promise.all([ + workspaceAIClients.getOpenaiClient(), + workspaceAIClients.getAnthropicClient() + ]) const result = await runChatLoop({ messages, get systemMessage() { @@ -433,8 +437,8 @@ class AIChatManager { return getCurrentModel() }, clients: { - openai: workspaceAIClients.getOpenaiClient(), - anthropic: workspaceAIClients.getAnthropicClient() + openai, + anthropic }, workspace: get(workspaceStore) ?? '', skipResponsesApi: this.skipResponsesApi, diff --git a/frontend/src/lib/components/copilot/chat/anthropic.ts b/frontend/src/lib/components/copilot/chat/anthropic.ts index 4bc581db88..6fd7726b88 100644 --- a/frontend/src/lib/components/copilot/chat/anthropic.ts +++ b/frontend/src/lib/components/copilot/chat/anthropic.ts @@ -1,5 +1,5 @@ -import { OpenAI } from 'openai' -import Anthropic from '@anthropic-ai/sdk' +import type { OpenAI } from 'openai' +import type Anthropic from '@anthropic-ai/sdk' import type { ChatCompletionMessageParam, ChatCompletionMessageFunctionToolCall @@ -41,7 +41,7 @@ export async function getAnthropicCompletion( const { system, messages: anthropicMessages } = convertOpenAIToAnthropicMessages(messages) const anthropicTools = convertOpenAIToolsToAnthropic(tools) - const client = options?.anthropicClient ?? workspaceAIClients.getAnthropicClient() + const client = await (options?.anthropicClient ?? workspaceAIClients.getAnthropicClient()) const anthropicParams = { model: config.model, diff --git a/frontend/src/lib/components/copilot/chat/chatLoop.ts b/frontend/src/lib/components/copilot/chat/chatLoop.ts index a624698cc9..08c75a3f48 100644 --- a/frontend/src/lib/components/copilot/chat/chatLoop.ts +++ b/frontend/src/lib/components/copilot/chat/chatLoop.ts @@ -1,5 +1,5 @@ -import OpenAI from 'openai' -import Anthropic from '@anthropic-ai/sdk' +import type OpenAI from 'openai' +import type Anthropic from '@anthropic-ai/sdk' import type { ChatCompletionMessageParam, ChatCompletionSystemMessageParam, @@ -8,16 +8,9 @@ import type { import type { AIProviderModel } from '$lib/gen' import { getCompletion, parseOpenAICompletion } from '../lib' import { getAnthropicCompletion, parseAnthropicCompletion } from './anthropic' -import { - getOpenAIResponsesCompletion, - parseOpenAIResponsesCompletion -} from './openai-responses' +import { getOpenAIResponsesCompletion, parseOpenAIResponsesCompletion } from './openai-responses' import type { Tool, ToolCallbacks } from './shared' -import { - addChatTokenUsage, - emptyChatTokenUsage, - type ChatTokenUsage -} from './tokenUsage' +import { addChatTokenUsage, emptyChatTokenUsage, type ChatTokenUsage } from './tokenUsage' export interface ChatClients { openai: OpenAI @@ -137,10 +130,7 @@ export async function runChatLoop(config: ChatLoopConfig): Promise { + const { default: OpenAIClass } = await import('openai') + return new OpenAIClass({ baseURL, apiKey: 'fake-key', defaultHeaders: { @@ -400,8 +401,9 @@ export function createOpenAIProxyClient(baseURL: string): OpenAI { }) } -export function createAnthropicProxyClient(baseURL: string): Anthropic { - return new Anthropic({ +export async function createAnthropicProxyClient(baseURL: string): Promise { + const { default: AnthropicClass } = await import('@anthropic-ai/sdk') + return new AnthropicClass({ baseURL, apiKey: 'fake-key', dangerouslyAllowBrowser: true, @@ -410,30 +412,30 @@ export function createAnthropicProxyClient(baseURL: string): Anthropic { } class WorkspacedAIClients { - private openaiClient: OpenAI | undefined - private anthropicClient: Anthropic | undefined + private openaiClient: Promise | undefined + private anthropicClient: Promise | undefined init(workspace: string) { this.openaiClient = this.createOpenaiClient(workspace) this.anthropicClient = this.createAnthropicClient(workspace) } - createOpenaiClient(workspace: string): OpenAI { + createOpenaiClient(workspace: string): Promise { return createOpenAIProxyClient(getAiProxyBaseURL(workspace)) } - createAnthropicClient(workspace: string): Anthropic { + createAnthropicClient(workspace: string): Promise { return createAnthropicProxyClient(getAiProxyBaseURL(workspace)) } - getOpenaiClient() { + getOpenaiClient(): Promise { if (!this.openaiClient) { throw new Error('OpenAI not initialized') } return this.openaiClient } - getAnthropicClient() { + getAnthropicClient(): Promise { if (!this.anthropicClient) { throw new Error('Anthropic not initialized') } @@ -522,11 +524,11 @@ async function testAnthropicKey({ headers['X-API-Key'] = apiKey } - const anthropicClient = apiKey + const anthropicClient = await (apiKey ? createAnthropicProxyClient(getAiProxyBaseURL()) : workspace ? workspaceAIClients.createAnthropicClient(workspace) - : workspaceAIClients.getAnthropicClient() + : workspaceAIClients.getAnthropicClient()) await anthropicClient.messages.create( { @@ -800,11 +802,11 @@ export async function getNonStreamingCompletion( 'X-API-Key': testOptions.apiKey } } - const openaiClient = testOptions?.apiKey + const openaiClient = await (testOptions?.apiKey ? createOpenAIProxyClient(getAiProxyBaseURL()) : testOptions?.workspace ? workspaceAIClients.createOpenaiClient(testOptions.workspace) - : workspaceAIClients.getOpenaiClient() + : workspaceAIClients.getOpenaiClient()) const completion = await openaiClient.chat.completions.create(config, fetchOptions) response = completion.choices?.[0]?.message.content || '' @@ -911,7 +913,7 @@ export async function getCompletion( } // Use Completions API for other providers - const client = options?.openaiClient ?? workspaceAIClients.getOpenaiClient() + const client = await (options?.openaiClient ?? workspaceAIClients.getOpenaiClient()) const completionConfig = (provider === 'openai' || provider === 'azure_openai' || provider === 'googleai') && config.stream diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 8f49dc1ba4..8d0759fe0b 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -111,6 +111,53 @@ const config = { dedupe: ['vscode', 'monaco-editor'] }, assetsInclude: ['**/*.wasm'], + build: { + rollupOptions: { + output: { + manualChunks(id) { + if (id.includes('node_modules')) { + if (id.includes('monaco-editor') || id.includes('@codingame/monaco-vscode')) { + return 'vendor-monaco' + } + if (id.includes('/openai/') || id.includes('/@anthropic-ai/')) { + return 'vendor-ai' + } + if (id.includes('@xyflow/svelte') || id.includes('d3-dag')) { + return 'vendor-flow' + } + if (id.includes('/yjs/') || id.includes('y-websocket') || id.includes('y-monaco')) { + return 'vendor-collab' + } + if (id.includes('ag-grid') || id.includes('ag-charts')) { + return 'vendor-ag' + } + if (id.includes('pdfjs-dist')) { + return 'vendor-pdf' + } + if ( + id.includes('svelte-exmarkdown') || + id.includes('/parse5/') || + id.includes('/rehype-') || + id.includes('/remark-') || + id.includes('/mdast-') || + id.includes('/micromark') + ) { + return 'vendor-markdown' + } + if (id.includes('quicktype-core')) { + return 'vendor-quicktype' + } + if (id.includes('/quill/')) { + return 'vendor-quill' + } + if (id.includes('/chart.js/') || id.includes('chartjs-')) { + return 'vendor-chart' + } + } + } + } + } + }, test: { expect: { requireAssertions: true }, projects: [