diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts
index 23e7e31c..00d4ccd0 100644
--- a/apps/desktop/electron.vite.config.ts
+++ b/apps/desktop/electron.vite.config.ts
@@ -3,6 +3,7 @@ import { readFileSync } from 'fs'
import { defineConfig } from 'electron-vite'
import vue from '@vitejs/plugin-vue'
import ui from '@nuxt/ui/vite'
+import { chatlabIconBundle } from '../../vite.icon-bundle.config'
const rootDir = resolve(__dirname, '../..')
const rootPkg = JSON.parse(readFileSync(resolve(rootDir, 'package.json'), 'utf-8'))
@@ -107,6 +108,7 @@ export default defineConfig({
},
},
}),
+ chatlabIconBundle(rootDir),
],
root: resolve(rootDir, 'src/'),
build: {
@@ -126,12 +128,6 @@ export default defineConfig({
if (id.includes('node_modules/echarts')) {
return 'vendor-echarts'
}
- if (id.includes('node_modules/@nuxt/ui')) {
- return 'vendor-nuxt-ui'
- }
- if (id.includes('node_modules/reka-ui')) {
- return 'vendor-reka-ui'
- }
if (id.includes('node_modules/@zumer/snapdom')) {
return 'vendor-snapdom'
}
diff --git a/apps/desktop/tsconfig.node.json b/apps/desktop/tsconfig.node.json
index 8cdb5069..b12e0815 100644
--- a/apps/desktop/tsconfig.node.json
+++ b/apps/desktop/tsconfig.node.json
@@ -2,6 +2,7 @@
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
"include": [
"electron.vite.config.*",
+ "../../vite.icon-bundle.config.ts",
"main/**/*",
"preload/**/*",
"shared/**/*",
diff --git a/apps/web-wasm/src/main.ts b/apps/web-wasm/src/main.ts
index cbe65719..13f8c982 100644
--- a/apps/web-wasm/src/main.ts
+++ b/apps/web-wasm/src/main.ts
@@ -1,3 +1,6 @@
+import '@/icons/disable-iconify-api'
+import 'virtual:nuxt-icon-bundle/register'
+
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
diff --git a/package.json b/package.json
index 474c18d5..fce1e3fa 100644
--- a/package.json
+++ b/package.json
@@ -74,6 +74,11 @@
"@openchatlab/tools": "workspace:*",
"@openchatlab/web-runtime": "workspace:*",
"@electron-toolkit/tsconfig": "^1.0.1",
+ "@iconify-json/heroicons": "^1.2.3",
+ "@iconify-json/lucide": "^1.2.118",
+ "@iconify-json/simple-icons": "1.2.83",
+ "@iconify/vue": "5.0.1",
+ "@nuxt/icon": "2.3.1",
"@nuxt/ui": "^4.9.0",
"@tailwindcss/vite": "^4.0.0",
"@types/markdown-it": "^14.1.2",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b50d1abf..0fe1830b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -39,6 +39,21 @@ importers:
'@electron-toolkit/tsconfig':
specifier: ^1.0.1
version: 1.0.1(@types/node@25.2.2)
+ '@iconify-json/heroicons':
+ specifier: ^1.2.3
+ version: 1.2.3
+ '@iconify-json/lucide':
+ specifier: ^1.2.118
+ version: 1.2.118
+ '@iconify-json/simple-icons':
+ specifier: 1.2.83
+ version: 1.2.83
+ '@iconify/vue':
+ specifier: 5.0.1
+ version: 5.0.1(vue@3.5.27(typescript@6.0.3))
+ '@nuxt/icon':
+ specifier: 2.3.1
+ version: 2.3.1(vite@7.3.3(@types/node@25.2.2)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.27(typescript@6.0.3))
'@nuxt/ui':
specifier: ^4.9.0
version: 4.9.0(s6g7ddwkwqprfo6zxngcqjtvte)
@@ -1441,6 +1456,12 @@ packages:
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
engines: {node: '>=18.18'}
+ '@iconify-json/heroicons@1.2.3':
+ resolution: {integrity: sha512-n+vmCEgTesRsOpp5AB5ILB6srsgsYK+bieoQBNlafvoEhjVXLq8nIGN4B0v/s4DUfa0dOrjwE/cKJgIKdJXOEg==}
+
+ '@iconify-json/lucide@1.2.118':
+ resolution: {integrity: sha512-JBnK4YOq6K/lA0JP//27QxFxJ4120TjvfXAzGZZIGjCcXcRRRFxl1rcV7+IWdcVCe90KXdqVaAwLaLf6G3HELw==}
+
'@iconify-json/simple-icons@1.2.83':
resolution: {integrity: sha512-6Pp9V++XisT9RKH7FB4RLPqUDzcmLtSma0ovOEIoEWGrXtHwBFsH7oN1z8vvCVCb95fb87QgR46/zRLyN9Y3kg==}
@@ -8090,6 +8111,14 @@ snapshots:
'@humanwhocodes/retry@0.4.3': {}
+ '@iconify-json/heroicons@1.2.3':
+ dependencies:
+ '@iconify/types': 2.0.0
+
+ '@iconify-json/lucide@1.2.118':
+ dependencies:
+ '@iconify/types': 2.0.0
+
'@iconify-json/simple-icons@1.2.83':
dependencies:
'@iconify/types': 2.0.0
diff --git a/src/env.d.ts b/src/env.d.ts
index fce1d53f..63f50fe3 100644
--- a/src/env.d.ts
+++ b/src/env.d.ts
@@ -1,4 +1,5 @@
///
+///
declare const __IS_ELECTRON__: boolean | undefined
declare const __IS_WEB_WASM__: boolean | undefined
diff --git a/src/icons/disable-iconify-api.ts b/src/icons/disable-iconify-api.ts
new file mode 100644
index 00000000..5944c86c
--- /dev/null
+++ b/src/icons/disable-iconify-api.ts
@@ -0,0 +1,5 @@
+import { _api as iconifyAPI } from '@iconify/vue'
+
+// All icons used by the app are registered by virtual:nuxt-icon-bundle/register.
+// Disable Iconify's fallback fetcher so an unbundled dynamic name cannot contact a CDN.
+iconifyAPI.setFetch(undefined as unknown as typeof globalThis.fetch)
diff --git a/src/main.ts b/src/main.ts
index d572f65b..d159e601 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -1,3 +1,6 @@
+import '@/icons/disable-iconify-api'
+import 'virtual:nuxt-icon-bundle/register'
+
import { mountChatLabApp } from '@/bootstrap/mount-app'
void mountChatLabApp().catch((error) => {
diff --git a/vite.icon-bundle.config.ts b/vite.icon-bundle.config.ts
new file mode 100644
index 00000000..af67cd8c
--- /dev/null
+++ b/vite.icon-bundle.config.ts
@@ -0,0 +1,17 @@
+import { NuxtIconBundle } from '@nuxt/icon/vite'
+import type { PluginOption } from 'vite'
+
+/** Bundle every icon used by ChatLab and Nuxt UI so rendering never depends on an icon CDN. */
+export function chatlabIconBundle(rootDir: string): PluginOption {
+ return NuxtIconBundle({
+ cwd: rootDir,
+ scan: {
+ globInclude: [
+ 'src/**/*.{vue,ts,tsx}',
+ 'apps/web-wasm/src/**/*.{vue,ts,tsx}',
+ 'node_modules/@nuxt/ui/dist/**/*.{vue,js,mjs,ts}',
+ ],
+ globExclude: [],
+ },
+ })
+}
diff --git a/vite.web-wasm.config.mts b/vite.web-wasm.config.mts
index 34625120..711798c1 100644
--- a/vite.web-wasm.config.mts
+++ b/vite.web-wasm.config.mts
@@ -3,6 +3,7 @@ import { resolve } from 'node:path'
import vue from '@vitejs/plugin-vue'
import ui from '@nuxt/ui/vite'
import { defineConfig } from 'vite'
+import { chatlabIconBundle } from './vite.icon-bundle.config'
const rootDir = import.meta.dirname
@@ -20,6 +21,7 @@ export default defineConfig({
},
},
}),
+ chatlabIconBundle(rootDir),
],
resolve: {
alias: {
diff --git a/vite.web.config.mts b/vite.web.config.mts
index 3a212c72..37a405d3 100644
--- a/vite.web.config.mts
+++ b/vite.web.config.mts
@@ -19,6 +19,7 @@ import vue from '@vitejs/plugin-vue'
import ui from '@nuxt/ui/vite'
import { DEFAULT_API_PORT } from './packages/config/src/schema'
import { createChatlabStartCommand, terminateChatlabStartProcess } from './scripts/dev-server-command.mjs'
+import { chatlabIconBundle } from './vite.icon-bundle.config'
const BACKEND_PORT = DEFAULT_API_PORT
@@ -167,6 +168,7 @@ export default defineConfig({
},
},
}),
+ chatlabIconBundle(__dirname),
chatlabServePlugin(),
],
build: {
@@ -182,8 +184,6 @@ export default defineConfig({
if (id.includes('node_modules/echarts-wordcloud')) return 'vendor-echarts-wordcloud'
if (id.includes('node_modules/zrender')) return 'vendor-zrender'
if (id.includes('node_modules/echarts')) return 'vendor-echarts'
- if (id.includes('node_modules/@nuxt/ui')) return 'vendor-nuxt-ui'
- if (id.includes('node_modules/reka-ui')) return 'vendor-reka-ui'
if (id.includes('node_modules/@zumer/snapdom')) return 'vendor-snapdom'
return undefined
},