Skip to content

Instantly share code, notes, and snippets.

@Innei
Created February 25, 2026 11:58
Show Gist options
  • Select an option

  • Save Innei/a5fa1ec983faefc5905f8268e7ee532b to your computer and use it in GitHub Desktop.

Select an option

Save Innei/a5fa1ec983faefc5905f8268e7ee532b to your computer and use it in GitHub Desktop.
LobeChat SPA Migration Plans

Migration Plan: Next.js App Router → Vite + React Router SPA

Context

LobeChat 前端已基本完成 React Router 迁移(231 文件),src/libs/next/ 已预置抽象层。但 next dev 编译 20s+、内存 8-12G+,严重影响开发效率。本计划将前端构建从 Next App Router 迁至 Vite SPA,后端保留 Next.js。

核心架构决策

  • RouteVariants 机制:删除
  • Locale/Mobile:Vite 分两次 build 分别产出 desktop bundle 和 mobile bundle(通过 define: { __MOBILE__: true/false } 注入)。Locale 不由服务端注入,而是在 index.html 中插入前置 script 从 cookie(LOBE_LOCALE)读取并设置到 document.documentElement.lang。Next.js catch-all route 仅注入 serverConfig
  • 静态资源:Vite 产物放 public/spa/,HTML 由 Next.js route handler 读取模板并字符串替换后返回
  • 环境变量:大部分 NEXT_PUBLIC_* 移入 window.__SERVER_CONFIG__ 运行时注入,仅 2-3 个保留为构建时 VITE_*
  • Auth 页面(auth) route group 保留 Next.js App Router 不动,页面组件内的 router hook 改用 next/navigation(而非 react-router-dom);oauth/consent/[uid] 保留 Next.js page(catch-all 排除此路径)
  • Desktop Electron:本次仅迁移 web 端,desktop 构建流程暂不动,后续单独 PR 适配

Phase 1: 环境变量整治(前置,不改架构)

目标:统一散落的 NEXT_PUBLIC_* 引用,修复已知 bug,为后续迁移扫清障碍。

1.1 修复 Pyodide 变量名不一致 bug

  • src/envs/python.ts:8 schema 定义 NEXT_PUBLIC_PYODIDE_PIP_INDEX_URL
  • src/services/python.ts:13 实际读取 NEXT_PUBLIC_PYPI_INDEX_URL
  • 操作:统一为 NEXT_PUBLIC_PYODIDE_PIP_INDEX_URL(与 schema 一致),src/services/python.ts 改为读取 pythonEnv.NEXT_PUBLIC_PYODIDE_PIP_INDEX_URL

1.2 收敛散落的直接 process.env.NEXT_PUBLIC_* 引用

将散落引用改为经 src/envs/ 读取,便于后续统一替换:

文件 当前 改为
src/services/python.ts:12-13 process.env.NEXT_PUBLIC_PYODIDE_INDEX_URL pythonEnv.NEXT_PUBLIC_PYODIDE_INDEX_URL
src/layout/AuthProvider/MarketAuth/MarketAuthProvider.tsx:169 process.env.NEXT_PUBLIC_MARKET_BASE_URL appEnv.MARKET_BASE_URL(新增到 appEnv)
src/components/Analytics/Desktop.tsx:9-14 process.env.NEXT_PUBLIC_DESKTOP_* 新增 desktopAnalyticsEnv,或合入 analyticsEnv
packages/const/src/version.ts:7 process.env.NEXT_PUBLIC_IS_DESKTOP_APP 保持(构建时常量,后续改 VITE_*
packages/builtin-tool-group-management/src/const.ts:1 同上 同上
packages/builtin-tool-gtd/src/const.ts:1 同上 同上

1.3 服务端 NEXT_PUBLIC_MARKET_BASE_URL 去前缀

5 个服务端文件直接读 process.env.NEXT_PUBLIC_MARKET_BASE_URL,改为 MARKET_BASE_URL

  • src/server/services/market/index.ts:11
  • src/server/routers/lambda/market/agent.ts:11
  • src/server/routers/lambda/market/agentGroup.ts:11
  • src/app/(backend)/market/oidc/[[...segments]]/route.ts:7
  • src/server/services/discover/index.ts:97

验证bun run type-check 通过;现有功能不受影响。


Phase 2: Vite 工程搭建

目标:建立 Vite SPA 工程入口,能在本地启动并看到基础页面壳。

2.1 工程结构(直接在 src 中修改,不创建 web-spa app)

在项目根目录新增 Vite 相关文件,复用现有 src/

lobe-chat/
├── vite.config.ts          # Vite 配置(两次 build:desktop + mobile)
├── index.html              # SPA 入口 HTML 模板
├── src/
│   ├── entry.desktop.tsx   # Desktop SPA 入口
│   └── entry.mobile.tsx    # Mobile SPA 入口
└── ...(现有 src/ 结构不变)

2.2 Vite 配置要点(两次独立 build)

Desktop 和 Mobile 分别执行一次 vite build,通过 define 注入 __MOBILE__ 常量,产出两个独立 bundle:

// vite.config.ts 关键配置
import { defineConfig } from 'vite';

const isMobile = process.env.MOBILE === 'true';

export default defineConfig({
  build: {
    outDir: isMobile ? 'dist/mobile' : 'dist/desktop',
  },
  define: {
    '__MOBILE__': JSON.stringify(isMobile),
    'process.env.NEXT_PUBLIC_IS_DESKTOP_APP': JSON.stringify('0'),
  },
  plugins: [
    tsconfigPaths(),
    react({ jsxImportSource: '@emotion/react' }), // emotion 支持
    // WASM 支持(Vite 原生)
  ],
});

构建命令:

# Desktop bundle
vite build

# Mobile bundle
MOBILE=true vite build

2.3 HTML 模板格式

单一 index.html,Vite build 时根据 __MOBILE__ 选择不同入口 tsx:

<!-- index.html -->
<!DOCTYPE html>
<html lang="<!--LOCALE-->" dir="<!--DIR-->">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <!--SEO_META-->
  </head>
  <body>
    <div id="root"></div>
    <script>
      window.__SERVER_CONFIG__ = undefined; /* SERVER_CONFIG */
    </script>
    <!--ANALYTICS_SCRIPTS-->
    <script type="module" src="/src/entry.desktop.tsx"></script>
  </body>
</html>

注:mobile build 时通过 Vite rollupOptions.input 或条件脚本指向 entry.mobile.tsxwindow.__SERVER_CONFIG__ 的占位在 prod 由 Next.js catch-all route 替换注入。

2.4 SPA 入口文件

// entry.desktop.tsx
import { BrowserRouter, Routes } from 'react-router-dom';
import { renderRoutes } from '@/utils/router';
import { desktopRoutes } from '@/app/[variants]/router/desktopRouter.config';
import { SPAGlobalProvider } from '@/layout/SPAGlobalProvider'; // 新建,不修改现有 GlobalProvider

const App = () => (
  <SPAGlobalProvider>
    <BrowserRouter>
      <Routes>{renderRoutes(desktopRoutes)}</Routes>
    </BrowserRouter>
  </SPAGlobalProvider>
);

ReactDOM.createRoot(document.getElementById('root')!).render(<App />);

2.5 开发代理配置

// vite.config.ts server.proxy
server: {
  proxy: {
    '/api': 'http://localhost:3010',
    '/trpc': 'http://localhost:3010',
    '/webapi': 'http://localhost:3010',
    '/oidc': 'http://localhost:3010',
  },
},

验证bun run dev:spa 启动 Vite dev server,能看到基础布局壳。


Phase 3: 第一方包 Next.js 解耦

目标:使 packages 在无 Next runtime 环境下可编译。

3.1 packages/builtin-tool-web-browsing

4 文件引用 next/linkResult.tsxSearchResultItem.tsxLoading.tsxPageContent/index.tsx)。

操作:改为从 react-router-dom 导入 Link,或创建 adapter:

// packages/builtin-tool-web-browsing/src/client/Link.tsx
export { Link } from 'react-router-dom';

3.2 packages/builtin-tool-agent-builder

1 文件引用 next/imageInstallPlugin.tsx,使用 <Image unoptimized />)。

操作unoptimizednext/image 等价于 <img>,直接替换。

3.3 packages/constpackages/builtin-tool-*

3 文件引用 process.env.NEXT_PUBLIC_IS_DESKTOP_APP

操作:Phase 2 中 Vite define 已处理。后续统一改为从共享 const 导入:

// packages/const/src/version.ts
export const isDesktop =
  typeof import.meta !== 'undefined'
    ? import.meta.env?.VITE_IS_DESKTOP_APP === '1'
    : process.env.NEXT_PUBLIC_IS_DESKTOP_APP === '1'; // 兼容 Next 后端

3.4 packages/utils/src/server/

responsive.tsauth.ts 引用 next/headers。仅服务端使用,无需改动

验证:Vite SPA 工程能 import 这些 packages 并编译通过。


Phase 4: Next.js 抽象层替换

目标:将 src/libs/next/ 的 wrapper 指向非 Next 实现。

4.1 navigation.ts

现有导出 → 替换为:

导出 替换
useRouter react-router-domuseNavigate 封装(兼容 .push()/.replace()/.back() API)
usePathname react-router-domuseLocation().pathname
useSearchParams react-router-domuseSearchParams
useParams react-router-domuseParams
redirect react-router-domNavigate 组件或 useNavigate
notFound 自定义 throw(SPA 内由 ErrorBoundary 捕获)

关键文件src/libs/next/navigation.ts

注意(auth) 页面仍运行在 Next.js 环境中,不能使用 src/libs/next/ wrapper(替换后指向 react-router-dom)。需将 (auth) 下引用 @/libs/next/navigation 的地方改为直接 import { useRouter, usePathname, ... } from 'next/navigation',确保 auth 页面在 Next.js 中正常工作。

4.2 Link.tsx

替换为 react-router-domLink。注意 next/linkhref prop 在 react-router-dom 中为 to

关键文件src/libs/next/Link.tsx 影响范围:需检查所有 import Link from '@/libs/next' 的用法中 prop 差异

4.3 Image.tsx

替换为 <img> 标签。现有 next/imagefill/sizes/priority 等 prop 需要适配或移除。

关键文件src/libs/next/Image.tsx

4.4 dynamic.tsx

替换为 React.lazy + Suspense。项目已有 src/utils/router.tsxdynamicElement 工具可参考。

关键文件src/libs/next/dynamic.tsx

验证:Vite SPA 中所有页面路由可正常加载。


Phase 5: 新建 SPAGlobalProvider

目标:新建 SPAGlobalProvider,不修改现有 GlobalProvider(因为 Next.js 的 (auth) segment 仍在使用)。从 window.__SERVER_CONFIG__ 读取初始配置,并包含 BetterAuthProvider 以确保 user session 可用。

5.1 新建 SPAGlobalProvider

现有 GlobalProvider 是 async Server Component,SPA 无法使用。新建纯客户端版本:

// src/layout/SPAGlobalProvider/index.tsx
import AuthProvider from '@/layout/AuthProvider';

const SPAGlobalProvider: FC<PropsWithChildren> = ({ children }) => {
  const serverConfig = window.__SERVER_CONFIG__;
  return (
    <StyleRegistry>
      <Locale antdLocale={...} defaultLang={serverConfig.locale}>
        <NextThemeProvider>
          <AppTheme
            customFontFamily={serverConfig.theme.customFontFamily}
            customFontURL={serverConfig.theme.customFontURL}
            globalCDN={serverConfig.theme.cdnUseGlobal}
          >
            <ServerConfigStoreProvider
              featureFlags={serverConfig.featureFlags}
              isMobile={serverConfig.isMobile}
              serverConfig={serverConfig.config}
            >
              <QueryProvider>
                <AuthProvider>  {/* 包含 BetterAuthProvider,确保 user session 可用 */}
                  <StoreInitialization />
                  {/* ... 其余 Provider 树同 GlobalProvider ... */}
                  {children}
                </AuthProvider>
              </QueryProvider>
            </ServerConfigStoreProvider>
          </AppTheme>
        </NextThemeProvider>
      </Locale>
    </StyleRegistry>
  );
};

重要:必须包裹 AuthProvider(内部根据环境选择 BetterAuthProvider),否则 SPA 中无法获取 user session。

5.2 window.__SERVER_CONFIG__ 类型定义

写到 src/types/global.d.ts,同时声明 Vite define 注入的变量:

// src/types/global.d.ts
import 'vite/client'; // add this line
import type { SPAServerConfig } from '@/types/spaServerConfig';

declare global {
  interface Window {
    __SERVER_CONFIG__: SPAServerConfig;
  }

  /** Vite define 注入,标识当前 bundle 是否为 mobile 版 */
  const __MOBILE__: boolean;
}
export {}; // add this line

5.3 Analytics 改造

现状:Analytics/index.tsx 是 Server Component,读取 analyticsEnv 后传 props 给 client 组件。

改为:从 window.__SERVER_CONFIG__.analyticsConfig 读取,各 analytics 组件改为纯客户端:

  • 移除 next/script 依赖 → 用 useEffect + document.createElement('script') 动态插入
  • 或使用 react-helmet-async

关键文件

  • 新增 src/layout/SPAGlobalProvider/index.tsx
  • 新增 src/types/global.d.tsWindow.__SERVER_CONFIG__ + __MOBILE__ 类型声明)
  • src/store/serverConfig/Provider.tsx
  • src/components/Analytics/*.tsx
  • 现有 src/layout/GlobalProvider/index.tsx 不修改

验证:SPA 启动后 Zustand store 正确初始化 serverConfig;user session 正常拉取。


Phase 6: Next.js Catch-All Route 实现

目标:Next.js 后端提供 catch-all route,读取 Vite 产出的 HTML 模板并注入运行时数据。

6.1 Route Handler

参考实现:catch-all.eg.ts

src/app/(spa)/[...path]/route.ts   # catch-all,优先级低于 (backend)/*

核心逻辑(dev /prod 分离)

  • prod:读取 Vite 构建产物的 HTML string template(dist/desktop/index.html / dist/mobile/index.html),进行字符串替换后返回
  • dev:代理 Vite dev server(fetch(VITE_DEV_ORIGIN)),获取 HTML 后 rewrite 资源 URL 指向 Vite dev server origin(处理 script src、link href、inline module scripts)。特别注意 Worker 跨域问题:dev 模式下 Next.js 与 Vite dev server 不同源,new Worker() 无法直接加载跨域脚本,需注入 workerPatch(将跨域 URL 包装为 blob URL),参考 catch-all.eg.ts 中的实现
// 伪代码
async function getTemplate(isMobile: boolean): Promise<string> {
  if (isDev) {
    // 代理 Vite dev server HTML,rewrite 资源 URL
    const res = await fetch(VITE_DEV_ORIGIN);
    const html = await res.text();
    return rewriteViteAssetUrls(html);
  }
  // prod:读取预构建的 string template
  return isMobile ? mobileHtmlTemplate : desktopHtmlTemplate;
}

完整流程:

  1. 读取 UA → 选 desktop /mobile template
  2. 读取 cookie/headers → 解析 locale
  3. 调用 getServerGlobalConfig() → 构建 SPAServerConfig
  4. 安全序列化 + 正则替换 window.__SERVER_CONFIG__ 占位
  5. new Response(html, { headers })

6.2 安全序列化

已有实现 src/server/utils/serializeForHtml.ts,直接复用。

6.3 缓存策略

headers: {
  'content-type': 'text/html; charset=utf-8',
  'cache-control': 'private, no-cache, no-store, must-revalidate',
  'vary': 'Accept-Language, User-Agent, Cookie',
}

public/spa/assets/*(JS/CSS)由 Next.js 自动静态服务,Vite content hash 保证可强缓存。

6.4 Middleware 适配

src/libs/next/proxy/define-config.ts 中的 SPA 路由白名单改为放行到 catch-all route(不再 rewrite 到 [variants])。

关键文件

  • 新增 src/app/(spa)/[...path]/route.ts
  • 修改 src/libs/next/proxy/define-config.ts

验证next dev + 访问 /agent,返回 Vite 产出的 HTML(含注入的 locale/config)。


Phase 7: Auth 页面处理

7.1 (auth) route group 保留 Next.js App Router

(auth) 下的所有页面不迁入 SPA,保持为 Next.js App Router 页面。原因:

  • Auth 页面需要服务端能力(redirect、OIDC session lookup 等)
  • 现有 GlobalProvider 仍为这些页面服务

操作:页面组件内的 router hook 统一使用 next/navigation(而非 react-router-dom),确保在 Next.js 环境下正常运行。

7.2 Catch-All Route 排除 Auth 路径

catch-all route 排除所有 auth 相关路径,让 Next.js App Router 正常接管:

  • /signin/signup/auth-error/reset-password/verify-email
  • /oauth/consent/*/oauth/callback/*
  • /market-auth-callback

7.3 Middleware auth 检查适配

betterAuthMiddleware 的 session 检查对 SPA 路由需调整:

  • SPA 页面全量 public(HTML 本身无敏感数据)
  • 登录态检查由 SPA 内部 route guard 负责(SPAGlobalProvider 中的 AuthProvider/BetterAuthProvider)
  • /api/*/trpc/*/oidc/* 的鉴权保持不变
  • Auth 页面继续由 Next.js middleware 保护

关键文件

  • src/app/[variants]/(auth)/ — 不动,保持 Next.js
  • src/libs/next/proxy/define-config.ts — 排除 auth 路径

Phase 8: 第三方依赖迁移

依赖 用途 文件 迁移
nuqs/adapters/next/app Auth 页面 query state (auth)/layout.tsx Phase 7 已移除
@vercel/speed-insights/next Vercel 性能监控 [variants]/layout.tsx 改用 @vercel/speed-insights 的 vanilla 版本,或移除
next-mdx-remote/rsc MDX 渲染 src/components/mdx/index.tsx 改用 @mdx-js/rollup(Vite plugin)或运行时 MDX 解析
@next/third-parties/google GA4 Analytics/Google.tsx <script> 直接注入 gtag
react-scan/monitoring/next React 性能调试 Analytics/ReactScan.tsx 改用 react-scan 的通用版本
@serwist/next PWA/Service Worker sw.ts + define-config.ts 改用 vite-plugin-pwa(Workbox 封装)
@t3-oss/env-nextjs 环境变量校验 src/envs/*.ts 改用 @t3-oss/env-core(框架无关版)

关键文件

  • src/components/mdx/index.tsx
  • src/components/Analytics/*.tsx
  • src/envs/*.ts(6 个文件)
  • src/app/sw.ts(改用 vite-plugin-pwa 集成)

Phase 9: 构建集成与产物组织

9.1 Vite 构建产物

两次 build 分别产出:

dist/
├── desktop/
│   ├── index.html          # desktop 入口模板
│   └── assets/             # JS/CSS(content hash)
└── mobile/
    ├── index.html          # mobile 入口模板
    └── assets/

9.2 构建脚本

// package.json scripts
{
  "build:spa": "vite build && MOBILE=true vite build",
  "build:spa:copy": "cp -r dist/* public/spa/",
  "build:docker": "bun run build:spa && bun run build:spa:copy && DOCKER=true next build --webpack",
  "dev:spa": "vite",
  "dev": "bun run dev:spa", // 默认开发命令改为 Vite
}

9.3 Dockerfile 适配

# builder stage
RUN bun run build:spa # 先构建两个 SPA bundle
RUN cp -r dist/* public/spa/
RUN bun run build:docker # 再构建 Next.js(后端 + 托管)

9.4 robots.tsx / sitemap.tsx / manifest.ts

保留在 Next.js 中不变(属于后端 / SEO 能力)。

验证bun run build:spa && bun run build:docker 完成;Docker 镜像可运行;访问 /agent 返回 SPA 页面。


Phase 10: 清理与收敛

10.1 删除旧前端壳(最小化变动)

仅删除 src/app/[variants]/ 下的 Next.js route segment 文件,排除 (auth) 目录

  • 删除 src/app/[variants]/page.tsxlayout.tsxloading.tsxmetadata.ts 等 route segment 文件
  • 删除 src/app/[variants]/ 下其他非 (auth) 的 Next.js page/layout
  • 保留 src/app/[variants]/(auth)/ 整个目录不动
  • 删除 src/app/loading.tsx
  • 删除 src/libs/next/proxy/ 中 SPA 相关的 rewrite 逻辑(保留 API 代理)

10.2 精简 Next.js 依赖

  • next.config.ts:移除 withPWA、前端相关 webpack 配置(emotion、optimizePackageImports 等)
  • 移除 @serwist/next@next/bundle-analyzer(前端侧)

10.3 开发命令收敛

  • 纯前端开发:pnpm dev:spa(仅 Vite)
  • 前后端联调:pnpm dev:spa + pnpm dev:next(Vite 代理到 Next)
  • 生产构建:pnpm build:spa → copy → pnpm build:docker

10.4 Desktop Electron 适配(本次不做)

Desktop 构建流程暂保持不变,后续单独 PR 适配:

  • scripts/electronWorkflow/modifiers/ 暂不改动
  • 确保本次迁移不破坏 desktop 构建的前提:src/ 目录结构变化需要与 modifier 脚本的文件路径假设兼容
  • 若不兼容,在 Phase 10 前与 desktop 维护者沟通确认

验证策略

每个 Phase 的验证

Phase 验证方式
1 bun run type-check 通过;功能回归无影响
2 bun run dev:spa 启动 Vite dev server,浏览器可见页面壳
3 SPA 工程能 import 所有 packages 并编译通过
4 SPA 中页面路由跳转正常,Link/Image/dynamic 替代品工作正常
5 SPA 启动后 Zustand store 正确初始化 serverConfig;user session 正常拉取
6 next dev 访问 /agent 返回注入后的 HTML,SPA 正常渲染
7 Auth 页面在 Next.js App Router 中正常工作;catch-all 正确排除 auth 路径
8 Analytics 脚本加载、MDX 渲染、PWA 安装正常
9 Docker 镜像构建成功;线上访问 SPA + API 均正常
10 旧代码已删;pnpm dev:spa 为默认开发命令;desktop 构建正常

端到端验证

  • 本地:pnpm dev:spa 纯前端开发(不启动 Next),验证页面渲染、路由、热更新
  • 联调:pnpm dev:spa + next dev,验证 API 调用、tRPC、Auth 流程
  • Docker:构建镜像 → 运行 → 验证全量功能(含 locale 切换、mobile UA 切换、OAuth 流程)
  • Desktop:pnpm desktop:build:renderer → 验证 Electron 构建不受影响

风险与注意事项

  1. process.env 在 Vite 中不可用:Vite 不注入 process.env,所有客户端代码中的 process.env.* 需改为 import.meta.env.*window.__SERVER_CONFIG__。可通过 Vite define 提供兼容层,但建议逐步替换。

  2. Emotion SSR:当前 Next.js 有 compiler.emotion 支持。Vite 侧需配置 @emotion/babel-plugin(通过 @vitejs/plugin-reactbabel 选项)。

  3. i18n /locale 在 Vite 中需要独立实现:Vite 不支持 Next.js 式的 import() 动态路径,需改用 import.meta.glob 静态分析。已有 Vite 版实现:

    • src/utils/locale.vite.ts — antd locale 加载(import.meta.glob 读取 antd/es/locale/*.js
    • src/utils/i18n/loadI18nNamespaceModule.vite.ts — i18n namespace 加载(import.meta.glob 读取 locales/ 目录)

    Vite build 时需通过 alias 或条件导入将这些 .vite.ts 版本替换掉原版(如在 vite.config.tsresolve.alias 中映射)。

  4. Circular dependency:现有 pnpm circular 检查需在 SPA 工程中同步验证。

  5. Desktop Electron 构建:本次不动 desktop,但需确保 src/ 结构变化不破坏 modifier 脚本。删除 src/app/[variants]/ 会导致 desktop modifier 失效 —— 因此 Phase 10 清理需在 desktop 适配 PR 之后,或保留 [variants] 目录结构作为 desktop 构建入口直到 desktop 迁移完成。

SPA ServerConfig 精简 + Turborepo Dev 集成

IMPORTANT: Not lint/format any code or do typecheck. and do git commit when you finish each task.

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: 精简 SPAServerConfig(移除 localetheme);catch-all 加 [locale] 段实现 force-static + 按语系生成 SEO meta;locale 由 index.html 前置 script 检测;turborepo dev 流程可用。

Architecture: middleware locale 检测逻辑保持不变(?hl= → cookie → Accept-Language)。SPA catch-all 路由从 (spa)/[[...path]]/route.ts 迁移至 (spa)/[locale]/[[...path]]/route.ts,标记 force-static,通过 generateStaticParams 为 18 种语系预渲染。每个语系 HTML 内嵌 locale 对应的 SEO meta(title/description/OG)。客户端 locale 由 index.html 前置 script 处理(读 cookie / ?hl= / navigator.language),SPAGlobalProvider 从 DOM 读取。theme 字段直接删除(app 层已处理)。

Tech Stack: Next.js route handler, Vite, TypeScript, Turborepo


Task 1: 验证 Turborepo Dev 可用

Files:

  • 已有: turbo.json
  • 已有: package.json scripts (dev, dev:next, dev:spa)

Step 1: 运行 turbo dev 验证并行启动

Run: bun run dev Expected: Turborepo 并行启动 dev:next(port 3010)和 dev:spa(port 3011),两个进程均正常运行。

Step 2: 验证代理连通

访问 http://localhost:3011,确认 Vite SPA 页面正常渲染,API 请求代理至 Next.js(3010)。


Task 2: index.html 注入 locale 检测前置 script

Files:

  • Modify: index.html

Step 1: 在 index.html 中添加 locale 检测 script

<div id="root"></div> 之前插入前置 script,复用 proxy define-config.ts 中的 locale 检测优先级:

  1. ?hl= search param(最高优先级,同时持久化至 cookie)
  2. LOBE_LOCALE cookie
  3. navigator.language(等同服务端 Accept-Language
  4. fallback en-US

若值为 auto 则降级至 navigator.language

完整 index.html

<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <!--SEO_META-->
  </head>
  <body>
    <script>
      (function () {
        var hl = new URLSearchParams(location.search).get('hl');
        var m = document.cookie.match(/(?:^|;\s*)LOBE_LOCALE=([^;]*)/);
        var cookie = m ? decodeURIComponent(m[1]) : '';
        var locale = hl || cookie || navigator.language || 'en-US';
        if (locale === 'auto') locale = navigator.language || 'en-US';
        if (hl && !cookie) {
          document.cookie =
            'LOBE_LOCALE=' + encodeURIComponent(hl) + ';path=/;max-age=7776000;SameSite=Lax';
        }
        document.documentElement.lang = locale;
        var rtl = ['ar', 'arc', 'dv', 'fa', 'ha', 'he', 'khw', 'ks', 'ku', 'ps', 'ur', 'yi'];
        document.documentElement.dir =
          rtl.indexOf(locale.split('-')[0].toLowerCase()) >= 0 ? 'rtl' : 'ltr';
      })();
    </script>
    <div id="root"></div>
    <script>
      window.__SERVER_CONFIG__ = undefined; /* SERVER_CONFIG */
    </script>
    <!--ANALYTICS_SCRIPTS-->
    <script type="module" src="/src/entry.desktop.tsx"></script>
  </body>
</html>

注:此 script 完全复用 proxy define-config.ts 的 locale 检测逻辑(?hl= → cookie → browser language),包括 ?hl= 持久化至 cookie(90 天 = 7776000 秒)。middleware 中的 locale 逻辑保持不变。<!--LOCALE--> / <!--DIR--> 占位符移除;<!--SEO_META--> 保留,由 Task 5 的 force-static route 注入各语系 SEO meta。

Step 2: 验证 dev 模式

Run: bun run dev:spa 打开浏览器,检查 document.documentElement.lang 是否正确从 cookie 或 navigator.language 取值。

Step 3: Commit

git add index.html
git commit -m "feat: add locale detection script to index.html for SPA dev mode"

Task 3: SPAServerConfig 类型重构 — 移除 theme 和 locale

Files:

  • Modify: src/types/spaServerConfig.ts

Step 1: 直接删除 locale 和 theme,不做合并

// src/types/spaServerConfig.ts
import type { IFeatureFlags } from '@/config/featureFlags';
import type { GlobalServerConfig } from '@/types/serverConfig';

export interface AnalyticsConfig {
  clarity?: { projectId: string };
  desktop?: { baseUrl: string; projectId: string };
  google?: { measurementId: string };
  plausible?: { domain: string; scriptBaseUrl: string };
  posthog?: { debug: boolean; host: string; key: string };
  reactScan?: { apiKey: string };
  umami?: { scriptUrl: string; websiteId: string };
  vercel?: { debug: boolean; enabled: boolean };
}

export interface SPAClientEnv {
  marketBaseUrl?: string;
  pyodideIndexUrl?: string;
  pyodidePipIndexUrl?: string;
  s3FilePath?: string;
}

export interface SPAServerConfig {
  analyticsConfig: AnalyticsConfig;
  clientEnv: SPAClientEnv;
  config: GlobalServerConfig;
  featureFlags: Partial<IFeatureFlags>;
  isMobile: boolean;
}

变更:

  • 删除 SPAThemeConfig interface
  • 删除 locale: stringtheme: SPAThemeConfig
  • SPAClientEnv 不变(不合并 theme 字段)

Step 2: 验证类型

Run: bunx tsc --noEmit --pretty src/types/spaServerConfig.ts (预期此文件本身无错,后续文件会报错待 Task 4/5 修复)

Step 3: Commit

git add src/types/spaServerConfig.ts
git commit -m "refactor: remove locale and theme from SPAServerConfig"

Task 4: Middleware 不改 — 仅确认 SPA 路由透传兼容

Files:

  • 确认: src/libs/next/proxy/define-config.ts(不修改)

背景: middleware locale 检测逻辑(?hl= → cookie → Accept-LanguageRouteVariants.serializeVariants、cookie 持久化)保持不变。SPA 路由当前走 NextResponse.next() 透传至 catch-all,无需改动 — [locale] 段将在 Task 5 中通过 generateStaticParams 静态生成,不需要 middleware rewrite。

Step 1: 确认 SPA pass-through 逻辑

define-config.ts:102 处:

if (!isNextjsRoute) {
  logDefault('SPA route, passing through to catch-all: %s', url.pathname);
  // ...
  return response;
}

SPA 路由不做 rewrite,直接透传。Next.js 的 [locale]/[[...path]] catch-all 会匹配 /en-US/chat 这类路径(如果用户直接访问的话),但实际上 SPA 路由不携带 locale prefix(主要走 generateStaticParams 生成的默认 locale 页面)。

此 Task 无代码变更,仅确认 middleware 兼容新架构。


Task 5: Catch-all 迁移至 [locale] 段 + force-static + SEO meta

Files:

  • Move: src/app/(spa)/[[...path]]/route.tssrc/app/(spa)/[locale]/[[...path]]/route.ts
  • Move: src/app/(spa)/[[...path]]/spaHtmlTemplates.tssrc/app/(spa)/[locale]/[[...path]]/spaHtmlTemplates.ts

背景: 将 catch-all GET 改为 force-static,通过 generateStaticParams 为全部 18 种语系预渲染。每个语系页面的 <!--SEO_META--> 占位符替换为对应 locale 的 title、description、OG meta。运行时不再动态检测 locale 和 theme。

Step 1: 移动文件至 [locale] 目录

mkdir -p 'src/app/(spa)/[locale]/[[...path]]'
mv 'src/app/(spa)/[[...path]]/route.ts' 'src/app/(spa)/[locale]/[[...path]]/route.ts'
mv 'src/app/(spa)/[[...path]]/spaHtmlTemplates.ts' 'src/app/(spa)/[locale]/[[...path]]/spaHtmlTemplates.ts'
rmdir 'src/app/(spa)/[[...path]]'

Step 2: 重写 route.ts

完整新 route.ts

import { BRANDING_NAME, ORG_NAME } from '@lobechat/business-const';
import { OG_URL } from '@lobechat/const';

import { getServerFeatureFlagsValue } from '@/config/featureFlags';
import { OFFICIAL_URL } from '@/const/url';
import { isCustomBranding, isCustomORG } from '@/const/version';
import { analyticsEnv } from '@/envs/analytics';
import { appEnv } from '@/envs/app';
import { fileEnv } from '@/envs/file';
import { pythonEnv } from '@/envs/python';
import { locales } from '@/locales/resources';
import { getServerGlobalConfig } from '@/server/globalConfig';
import { translation } from '@/server/translation';
import { serializeForHtml } from '@/server/utils/serializeForHtml';
import {
  type AnalyticsConfig,
  type SPAClientEnv,
  type SPAServerConfig,
} from '@/types/spaServerConfig';

import { desktopHtmlTemplate, mobileHtmlTemplate } from './spaHtmlTemplates';

export const dynamic = 'force-static';

export function generateStaticParams() {
  return locales.map((locale) => ({ locale }));
}

const isDev = process.env.NODE_ENV === 'development';
const VITE_DEV_ORIGIN = process.env.VITE_DEV_ORIGIN || 'http://localhost:3011';

// --- rewriteViteAssetUrls 保持不变 ---

async function getTemplate(isMobile: boolean): Promise<string> {
  if (isDev) {
    const res = await fetch(VITE_DEV_ORIGIN);
    const html = await res.text();
    return await rewriteViteAssetUrls(html);
  }
  return isMobile ? mobileHtmlTemplate : desktopHtmlTemplate;
}

function buildAnalyticsConfig(): AnalyticsConfig {
  // ... 保持不变 ...
}

function buildClientEnv(): SPAClientEnv {
  // ... 保持不变 ...
}

async function buildSeoMeta(locale: string): Promise<string> {
  const { t } = await translation('metadata', locale);
  const title = t('chat.title', { appName: BRANDING_NAME });
  const description = t('chat.description', { appName: BRANDING_NAME });

  return [
    `<title>${title}</title>`,
    `<meta name="description" content="${description}" />`,
    `<meta property="og:title" content="${title}" />`,
    `<meta property="og:description" content="${description}" />`,
    `<meta property="og:type" content="website" />`,
    `<meta property="og:url" content="${OFFICIAL_URL}" />`,
    `<meta property="og:image" content="${OG_URL}" />`,
    `<meta property="og:site_name" content="${BRANDING_NAME}" />`,
    `<meta property="og:locale" content="${locale}" />`,
    `<meta name="twitter:card" content="summary_large_image" />`,
    `<meta name="twitter:title" content="${title}" />`,
    `<meta name="twitter:description" content="${description}" />`,
    `<meta name="twitter:image" content="${OG_URL}" />`,
    `<meta name="twitter:site" content="${isCustomORG ? `@${ORG_NAME}` : '@lobehub'}" />`,
  ].join('\n    ');
}

export async function GET(
  _request: Request,
  { params }: { params: Promise<{ locale: string; path?: string[] }> },
) {
  const { locale } = await params;

  // force-static: no request headers available, default to desktop
  const isMobile = false;

  const serverConfig = await getServerGlobalConfig();
  const featureFlags = getServerFeatureFlagsValue();
  const analyticsConfig = buildAnalyticsConfig();
  const clientEnv = buildClientEnv();

  const spaConfig: SPAServerConfig = {
    analyticsConfig,
    clientEnv,
    config: serverConfig,
    featureFlags,
    isMobile,
  };

  let html = await getTemplate(isMobile);

  html = html.replace(
    /window\.__SERVER_CONFIG__\s*=\s*undefined;\s*\/\*\s*SERVER_CONFIG\s*\*\//,
    `window.__SERVER_CONFIG__ = ${serializeForHtml(spaConfig)};`,
  );

  const seoMeta = await buildSeoMeta(locale);
  html = html.replace('<!--SEO_META-->', seoMeta);
  html = html.replace('<!--ANALYTICS_SCRIPTS-->', '');

  return new Response(html, {
    headers: {
      'content-type': 'text/html; charset=utf-8',
    },
  });
}

变更要点:

  • 删除: buildThemeConfigSPAThemeConfig import、isRtlLangparseBrowserLanguageDEFAULT_LANGLOBE_LOCALE_COOKIENextRequest
  • 删除: cookieLocalebrowserLanguagelocale 检测、dirtheme
  • 删除: <!--LOCALE--><!--DIR--> 替换
  • 删除: Vary / cache-control header(force-static 由 Next.js 管理缓存)
  • 新增: export const dynamic = 'force-static'
  • 新增: export function generateStaticParams() — 返回 18 种 locale
  • 新增: buildSeoMeta(locale) — 复用 translation('metadata', locale) 生成 SEO meta HTML
  • 新增: locale 从 route params 获取({ params: Promise<{ locale: string; path?: string[] }> }
  • isMobile 硬编码 false(force-static 无 request headers,客户端由前置 script + React 处理)
  • spaConfig 不再包含 localetheme
  • GET 函数签名从 NextRequest 改为 Request(force-static 限制)

Step 3: Commit

git add 'src/app/(spa)/[locale]/[[...path]]/' 'src/app/(spa)/[[...path]]/'
git commit -m "feat: add [locale] segment with force-static and SEO meta generation"

Task 6: 更新 SPAGlobalProvider — 移除 theme/locale 读取

Files:

  • Modify: src/layout/SPAGlobalProvider/index.tsx

Step 1: 修改 SPAGlobalProvider

  1. localeserverConfig 已无 locale 字段,将 serverConfig?.locale ?? document.documentElement.lang ?? 'en-US' 简化为 document.documentElement.lang || 'en-US'
  2. themeserverConfig 已无 theme 字段,删除 customFontFamily/customFontURL/globalCDN 三个 prop 传递,<AppTheme> 不传 prop(均 optional)
const SPAGlobalProvider = memo<PropsWithChildren>(({ children }) => {
  const serverConfig: SPAServerConfig | undefined = window.__SERVER_CONFIG__;

  const locale = document.documentElement.lang || 'en-US';
  const isMobile = serverConfig?.isMobile ?? typeof __MOBILE__ !== 'undefined' ? __MOBILE__ : false;

  return (
    <StyleRegistry>
      <Locale defaultLang={locale}>
        <NextThemeProvider>
          <AppTheme>
            {/* ... 其余不变 ... */}
          </AppTheme>
        </NextThemeProvider>
      </Locale>
    </StyleRegistry>
  );
});

Step 2: Commit

git add src/layout/SPAGlobalProvider/index.tsx
git commit -m "refactor: remove theme/locale reads from SPAGlobalProvider"

Task 7: 更新 global.d.ts 类型声明

Files:

  • Modify: src/types/global.d.ts

Step 1: 确认 Window.SERVER_CONFIG 类型仍正确

global.d.ts 已声明 Window.__SERVER_CONFIG__import('./spaServerConfig').SPAServerConfig | undefined,由于 Task 3 已修改 SPAServerConfig 类型,此处无需改动。仅需确认类型推导正确。

Step 2: 验证

Run: bunx tsc --noEmit --pretty src/types/global.d.ts Expected: PASS


Task 8: vite.config.ts — base 区分 dev/prod

Files:

  • Modify: vite.config.ts

背景: Vite 构建产物放入 public/spa/,由 Next.js 静态托管。prod 模式下 JS/CSS 资源路径需以 /spa/ 为前缀。dev 模式下 Vite dev server 直接服务,base 为 /

Step 1: 添加 base 配置

const isDev = process.env.NODE_ENV !== 'production';

export default defineConfig({
  base: isDev ? '/' : '/spa/',
  // ... 其余不变
});

注:mode 参数也可用,但 process.env.NODE_ENV 更直接。vite build 默认 NODE_ENV=productionvite(dev server)默认 NODE_ENV=development

Step 2: Commit

git add vite.config.ts
git commit -m "feat: set vite base to /spa/ for production builds"

Task 9: spaHtmlTemplates 改为构建时生成

Files:

  • Create: scripts/generateSpaTemplates.mts
  • Modify: src/app/(spa)/[locale]/[[...path]]/spaHtmlTemplates.ts(将由脚本自动覆写)
  • Modify: package.json(更新 build:spa script)

背景: 当前 spaHtmlTemplates.ts 运行时用 readFileSync 读取 public/spa/ 下 HTML 文件。改为 vite build 后由脚本读取产物 HTML,生成内联 string 常量的 .ts 文件,消除运行时文件读取依赖。

Step 1: 创建生成脚本

// scripts/generateSpaTemplates.mts
import { readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';

const root = resolve(import.meta.dirname, '..');

const desktopHtml = readFileSync(resolve(root, 'dist/desktop/index.html'), 'utf-8');
const mobileHtml = readFileSync(resolve(root, 'dist/mobile/index.html'), 'utf-8');

const output = `// Auto-generated by scripts/generateSpaTemplates.mts after vite build
// Do not edit manually

export const desktopHtmlTemplate = ${JSON.stringify(desktopHtml)};

export const mobileHtmlTemplate = ${JSON.stringify(mobileHtml)};
`;

writeFileSync(
  resolve(root, 'src/app/(spa)/[locale]/[[...path]]/spaHtmlTemplates.ts'),
  output,
  'utf-8',
);

console.log('Generated spaHtmlTemplates.ts');

Step 2: 更新 package.json scripts

核心变更:build = build:spa + build:next

{
  "build": "bun run build:spa && bun run build:next",
  "build:next": "cross-env NODE_OPTIONS=--max-old-space-size=8192 next build --webpack",
  "build:spa": "vite build && cross-env MOBILE=true vite build && tsx scripts/generateSpaTemplates.mts",
  "build:spa:copy": "mkdir -p public/spa && cp -r dist/desktop/assets dist/mobile/assets public/spa/",
  "build:docker": "npm run prebuild && bun run build:spa && bun run build:spa:copy && NODE_OPTIONS=--max-old-space-size=8192 DOCKER=true next build --webpack && npm run build-sitemap",
}

变更说明:

  • build:从单独 next build 改为 build:spa + build:next,先 Vite 构建 SPA + 生成模板,再 Next.js 构建
  • build:next:拆出原 build 中的 next build 部分
  • build:spa:末尾追加 tsx scripts/generateSpaTemplates.mts
  • build:spa:copy:仅复制静态资源(JS/CSS assets),HTML 已内联至代码
  • build:docker:不变(已直接调用 build:spa + build:spa:copy + next build

Step 3: 将 spaHtmlTemplates.ts 加入 .gitignore

# Auto-generated SPA templates
src/app/(spa)/[locale]/[[...path]]/spaHtmlTemplates.ts

注:此文件由 CI/build 生成,不提交。dev 模式下 route.ts 的 getTemplatefetch(VITE_DEV_ORIGIN) 分支,不依赖此文件(prod template 为空字符串不影响 dev)。

Step 4: Commit

git add scripts/generateSpaTemplates.mts package.json .gitignore
git commit -m "feat: auto-generate spaHtmlTemplates from vite build output"

Task 10: 全量类型检查 + 清理

Files:

  • Check: catch-all.eg.ts(若引用旧类型需更新或删除)

Step 1: 全量类型检查

Run: bun run type-check Expected: PASS。若有报错,修复引用旧 SPAThemeConfigserverConfig.locale / serverConfig.theme 的文件。

Step 2: 检查 catch-all.eg.ts

此文件为参考实现,若引用了 SPAThemeConfig,删除或更新。

Step 3: 最终 Commit

git add -A
git commit -m "refactor: cleanup after SPAServerConfig simplification"

Task 11: (spa) 路由组重命名为 spa 真实路由段

背景: Next.js 报错 You cannot use different slug names for the same dynamic path ('variants' !== 'locale')(spa) 路由组内 [locale] 与其他路由组内 [variants] 冲突。解决方案:将 (spa) 路由组改为 spa 真实路由段,middleware 做 rewrite。

Files:

  • Move: src/app/(spa)/src/app/spa/
  • Modify: src/libs/next/proxy/define-config.ts — SPA 路由不再 pass-through,改为 NextResponse.rewrite()/spa/[locale]/...

变更:

  1. src/app/spa/[locale]/[[...path]]/route.ts — 路径从 (spa) 改为 spa
  2. middleware — SPA 路由 rewrite: url.pathname = /spa/${locale}${pathname};直接访问 /spa/ 前缀的请求 pass-through
  3. .gitignore / scripts/generateSpaTemplates.mts — 更新路径为 src/app/spa/...

Task 12: Vite module redirect 插件

背景: resolve.alias 无法覆盖 vite-tsconfig-paths 先解析的 @/ 路径。改用自定义 Vite 插件 viteModuleRedirect()enforce: 'pre',在 resolveId hook 中拦截已解析的绝对路径并重定向至 .vite.ts 版本。

Files:

  • Modify: vite.config.ts — 新增 viteModuleRedirect() 插件
  • Create: src/libs/getUILocaleAndResources.vite.tsimport.meta.glob 版本

重定向映射:

src/utils/locale.ts              → src/utils/locale.vite.ts
src/utils/i18n/loadI18nNamespaceModule.ts → src/utils/i18n/loadI18nNamespaceModule.vite.ts
src/libs/getUILocaleAndResources.ts       → src/libs/getUILocaleAndResources.vite.ts

Task 13: SPAGlobalProvider 专用 Locale 组件

背景: SPAGlobalProvider 直接 import @/layout/GlobalProvider/Locale,其中 dayjs/locale/${locale}.js 动态 import 无法被 Vite 静态分析。需创建 SPA 专用 Locale 组件。

Files:

  • Create: src/layout/SPAGlobalProvider/Locale.tsx — 用 import.meta.glob('/node_modules/dayjs/locale/*.js') 加载 dayjs locale,移除 isOnServerSide SSR 逻辑
  • Modify: src/layout/SPAGlobalProvider/index.tsx — import 改为 ./Locale

与 GlobalProvider/Locale.tsx 的差异:

  • dayjs locale: import(dayjs/locale/${locale}.js)import.meta.glob 静态映射
  • 移除 isOnServerSide 分支(SPA 永远在客户端)
  • getAntdLocale 由 viteModuleRedirect 插件自动重定向至 .vite.ts 版本

Task 14: 移除 SPA 中 server-only 依赖

背景: SPA 入口树中存在多个 server-only 模块引用,导致 Vite 浏览器环境报错。

14a: DevPanel — node:fs

SPAGlobalProvider 导入 DevPanel,其 getCacheEntries.ts 使用 node:fs

修复: 注释 DevPanel import 及 JSX 引用(SPA 不需要 Next.js cache viewer)。

Files:

  • Modify: src/layout/SPAGlobalProvider/index.tsx — 注释 import DevPanel<DevPanel />

14b: HighlightNotification — next/link<a>

Footer → HighlightNotification 导入 next/link,Vite 加载 next 包连带触发 sharp(optionalDependency)。

修复: next/link 仅用于外链(target="_blank"),替换为 <a> 标签。

Files:

  • Modify: src/components/HighlightNotification/index.tsximport Link from 'next/link' → 删除,<Link><a>

14c: mdx/Image — plaiceholdersharp

ChangelogModal → ChangelogContent → CustomMDX → mdx/Image.tsx 导入 plaiceholder(内嵌 sharp)。

修复: 创建 Image.vite.tsx,去掉 plaiceholder/Buffer/'use server',直接渲染 <Image>

Files:

  • Create: src/components/mdx/Image.vite.tsx
  • Modify: vite.config.ts — 加入 redirect

14d: AuthProvider — @t3-oss/env-core server env

AuthProvider 访问 authEnv.AUTH_SECRET@t3-oss/env-core server 变量),浏览器端抛出 "Attempted to access a server-side environment variable on the client"。

修复: 创建 index.vite.tsx,跳过 authEnv 检查,直接用 BetterAuth(无 auth 时 useSession() 返回空 session,等效 NoAuth)。

Files:

  • Create: src/layout/AuthProvider/index.vite.tsx
  • Modify: vite.config.ts — 加入 redirect

14e: LobeAnalyticsProviderWrapper — @t3-oss/env-core server env

LobeAnalyticsProviderWrapper 访问 analyticsEnv(同为 server 变量)。

修复: 创建 .vite.tsx 版本,从 window.__SERVER_CONFIG__.analyticsConfig 读取。

Files:

  • Create: src/components/Analytics/LobeAnalyticsProviderWrapper.vite.tsx
  • Modify: vite.config.ts — 加入 redirect

14f: navigation.ts — 还原 next/navigation 再导出

src/libs/next/navigation.ts 被直接改为 react-router-dom 实现,导致 Next.js SSR(如 (auth) 路由组)中 useLocation() 无 Router context 报错。

修复: 还原 navigation.tsnext/navigation 再导出;创建 navigation.vite.ts(react-router-dom 实现),通过 redirect 切换。

Files:

  • Modify: src/libs/next/navigation.ts — 还原为 next/navigation 再导出(不含 useServerInsertedHTML
  • Create: src/libs/next/navigation.vite.ts — react-router-dom 实现
  • Modify: vite.config.ts — 加入 redirect

变更总结

文件 变更
index.html 添加 locale 检测前置 script(?hl= → cookie → browser),保留 <!--SEO_META--> 占位符
src/types/spaServerConfig.ts 删除 SPAThemeConfiglocalethemeSPAClientEnv 不变
src/libs/next/proxy/define-config.ts SPA 路由 rewrite 至 /spa/[locale]/.../spa/ 前缀直接 pass-through
src/app/spa/[locale]/[[...path]]/route.ts (spa)/[[...path]]/ 迁移至 spa/[locale]/[[...path]]/force-static + generateStaticParams(18 locales) + buildSeoMeta
src/app/spa/[locale]/[[...path]]/spaHtmlTemplates.ts 迁移至新路径;改为自动生成,加入 .gitignore
src/layout/SPAGlobalProvider/index.tsx locale 从 DOM 读取;移除 theme prop;import Locale 改为 ./Locale;注释 DevPanel
src/layout/SPAGlobalProvider/Locale.tsx 新增:SPA 专用 Locale,import.meta.glob 加载 dayjs/antd locale,无 SSR 逻辑
src/components/HighlightNotification/index.tsx next/link<a>(外链场景)
src/components/mdx/Image.vite.tsx 新增:去掉 plaiceholder/sharp,直接渲染 <Image>
src/layout/AuthProvider/index.vite.tsx 新增:跳过 authEnv server env,直接用 BetterAuth
src/components/Analytics/LobeAnalyticsProviderWrapper.vite.tsx 新增:从 window.__SERVER_CONFIG__ 读取 analytics 配置
src/libs/next/navigation.ts 还原为 next/navigation 再导出
src/libs/next/navigation.vite.ts 新增:react-router-dom 实现
vite.config.ts base: dev / → prod /spa/viteModuleRedirect() 插件含 8 条重定向规则
src/utils/locale.vite.ts 已有:import.meta.glob 加载 antd locale
src/utils/i18n/loadI18nNamespaceModule.vite.ts 已有:import.meta.glob 加载 i18n namespace
src/libs/getUILocaleAndResources.vite.ts 新增:import.meta.glob 版本
scripts/generateSpaTemplates.mts 新增:vite build 后生成内联 HTML string 的 .ts
package.json build = build:spa + build:nextbuild:spa 追加模板生成
turbo.json devdev:next 任务定义修复

Plan: 删除 Electron Modifiers,迁移至 Vite Renderer 构建

Context

Electron Desktop 的 Renderer 层构建,此前依赖 Next.js output: 'export'(静态导出),因此需要 scripts/electronWorkflow/modifiers/ 下 11 个 AST codemod 对源码进行大量转换(动态→静态、移除 server-only 代码、路由裁剪等)。

现已完成 Vite SPA 迁移(见 01-nextjs-vite-spa-migration.md),Web 端 Renderer 已通过 vite build 构建。Electron Renderer 可完全复用此 Vite 构建流程,通过在 electron-vite 中增加 renderer entry 直接构建,配合 .desktop 后缀文件实现桌面端差异化,从而彻底移除所有 modifier 脚本和 Next.js shadow workspace 构建流程

核心决策

  • Renderer 构建器:在 apps/desktop/electron.vite.config.ts 增加 renderer entry,复用根目录 Vite 插件(vitePlatformResolveviteNodeModuleStubtsconfigPathsreact),统一由 electron-vite build 一次构建 main + preload + renderer
  • HTML 入口:在 apps/desktop/ 新增 index.html<script> 指向 ../../src/entry.desktop.tsx
  • 差异化机制.desktop 后缀文件(由 vitePlatformResolve 插件自动解析,优先级 .desktop.vite → 原始),替代 AST codemod
  • 产物结构apps/desktop/dist/renderer/ 内含单个 index.html + assets/,取代原 dist/next/ 多页面结构

Phase 1: 删除所有 Modifier 脚本

目标:移除 scripts/electronWorkflow/modifiers/ 整个目录。

1.1 删除 modifier 文件

删除以下所有文件:

scripts/electronWorkflow/modifiers/
├── index.mts              # 主入口编排器
├── utils.mts              # 通用工具
├── nextConfig.mts         # Next.js 配置改造
├── nextDynamicToStatic.mts # next/dynamic → static import
├── dynamicToStatic.mts    # dynamicElement() → static import
├── i18nDynamicToStatic.mts # i18n 异步→同步映射
├── settingsContentToStatic.mts # Settings 动态→静态
├── wrapChildrenWithClientOnly.mts # ClientOnly 包装
├── removeSuspense.mts     # 移除 Suspense
├── staticExport.mts       # [variants] → (variants)
├── appCode.mts            # 移除 DevPanel/Analytics/Security
├── routes.mts             # 删除后端/认证路由
└── cleanUp.mts            # 移除 'use server'

1.2 各 Modifier 废弃理由

Modifier 原功能 废弃理由
nextConfig 注入 output: 'export',移除 redirects/headers/PWA 不再使用 Next.js 构建 Renderer
nextDynamicToStatic next/dynamic() → 静态 import SPA 中已无 next/dynamic,Vite 原生 code splitting
dynamicToStatic dynamicElement() → 静态 import Vite 原生处理 React.lazy
i18nDynamicToStatic 动态 locale import → 预构建映射表 .vite.ts 文件已通过 import.meta.glob 处理
settingsContentToStatic Settings componentMap dynamic → static Vite 原生处理 React.lazy
wrapChildrenWithClientOnly 包裹 <ClientOnly> SPA 本就纯客户端渲染
removeSuspense 移除 Suspense 包装 Suspense 在 Vite SPA 中正常工作
staticExport [variants](variants),移除 URL rewrite 不再使用 Next.js 路由
appCode 移除 DevPanel/Security/Analytics/manifest .desktop 后缀文件处理(Phase 3)
routes 删除 backend/auth/mobile 路由 SPA Router 仅包含 desktopRoutes,无需裁剪
cleanUp 移除 'use server' .vite.ts 文件已绕过 server-only 代码

Phase 2: electron-vite 增加 Renderer Entry

目标:在 apps/desktop/electron.vite.config.ts 中增加 renderer 配置,复用根目录 Vite 插件,统一由 electron-vite build 构建全部三层。

2.1 新增 apps/desktop/index.html

Electron Renderer 入口 HTML,指向根目录的 entry.desktop.tsx

<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
  </head>
  <body>
    <script>
      (function () {
        var locale = navigator.language || 'en-US';
        document.documentElement.lang = locale;
        var rtl = ['ar', 'fa', 'he', 'ur'];
        document.documentElement.dir = rtl.indexOf(locale.split('-')[0]) >= 0 ? 'rtl' : 'ltr';
      })();
    </script>
    <div id="root"></div>
    <script>
      window.__SERVER_CONFIG__ = undefined; /* injected by preload */
    </script>
    <script type="module" src="../../src/entry.desktop.tsx"></script>
  </body>
</html>

注:与根目录 index.html(Web SPA 用)类似,但简化了 locale 检测(无 cookie/?hl=,Electron 直接用 navigator.language),__SERVER_CONFIG__ 由 preload 注入。

2.2 修改 apps/desktop/electron.vite.config.ts

增加 renderer 配置,复用根目录 Vite 插件:

import react from '@vitejs/plugin-react';
import dotenv from 'dotenv';
import { defineConfig } from 'electron-vite';
import { resolve } from 'node:path';
import tsconfigPaths from 'vite-tsconfig-paths';

import { getExternalDependencies } from './native-deps.config.mjs';
// 复用根目录的 Vite 插件
import { viteNodeModuleStub } from '../../plugins/vite/nodeModuleStub';
import { vitePlatformResolve } from '../../plugins/vite/platformResolve';

dotenv.config();

const isDev = process.env.NODE_ENV === 'development';
const ROOT_DIR = resolve(__dirname, '../..');

export default defineConfig({
  main: {
    // ... 保持不变
  },
  preload: {
    // ... 保持不变
  },
  renderer: {
    root: __dirname,
    build: {
      outDir: 'dist/renderer',
      rollupOptions: {
        input: resolve(__dirname, 'index.html'),
      },
    },
    define: {
      __MOBILE__: 'false',
      __ELECTRON__: 'true',
      // NEXT_PUBLIC_IS_DESKTOP_APP 已删除,见 Phase 7
    },
    plugins: [
      viteNodeModuleStub(),
      vitePlatformResolve('desktop'), // .desktop → .vite → 原始
      tsconfigPaths({ root: ROOT_DIR }),
      react(),
    ],
    resolve: {
      alias: {
        // tsconfigPaths 处理 @/ 映射至根目录 src/
        // 此处可添加额外 alias(如需要)
      },
    },
  },
});

要点

  • root: __dirname — renderer 的根目录为 apps/desktop/
  • tsconfigPaths({ root: ROOT_DIR }) — 使用根目录 tsconfig.json 的路径映射(@/src/
  • vitePlatformResolve('desktop') — 解析优先级 .desktop.vite → 原始
  • input 指向 apps/desktop/index.html,该 HTML 的 <script src> 指向 ../../src/entry.desktop.tsx

2.3 删除旧的独立构建脚本

以下文件不再需要,可直接删除:

scripts/electronWorkflow/buildNextApp.mts       # shadow workspace + modifiers + next build
scripts/electronWorkflow/moveNextExports.ts      # 复制 out/ → dist/next/

electron-vite build 已直接输出到 apps/desktop/dist/renderer/,无需中间复制步骤。

2.4 更新 package.json scripts

{
  // Before:
  // "desktop:build:all": "npm run desktop:build:renderer:all && npm run desktop:build:main",
  // "desktop:build:renderer": "cross-env ... tsx scripts/electronWorkflow/buildNextApp.mts",
  // "desktop:build:renderer:all": "npm run desktop:build:renderer && npm run desktop:build:renderer:prepare",
  // "desktop:build:renderer:prepare": "tsx scripts/electronWorkflow/moveNextExports.ts",

  // After:
  "desktop:build:all": "npm run desktop:build:main",
  // desktop:build:renderer* 全部删除,renderer 构建由 electron-vite build 统一处理
  // apps/desktop/package.json 中 build:main = "electron-vite build" 已同时构建 main + preload + renderer
}

apps/desktop/package.json"build:main": "electron-vite build" 已包含 renderer 构建,根目录只需调用 desktop:build:main


Phase 3: .desktop 后缀差异化文件 — 全量等价审计

目标:确保每个原 modifier 在 Vite Desktop 构建中都有等价的替代方案。使用 .desktop 后缀文件覆盖 .vite 版本中行为不等价的模块。

3.0 解析优先级

vitePlatformResolve('desktop') 解析顺序:.desktop.ts.vite.ts.ts无需修改插件

3.1 全量 Modifier 等价性审计

类别 A:关注点已不存在(无需任何替代)

Modifier 原操作 废弃理由
nextConfig 注入 output: 'export',移除 redirects/headers/PWA Renderer 不再经过 Next.js 构建
staticExport [variants](variants),移除 URL rewrite 不再使用 Next.js 路由系统
wrapChildrenWithClientOnly 包裹 <ClientOnly> 防 hydration 不匹配 SPA 纯客户端渲染,无 hydration
removeSuspense 移除 Conversation Suspense 包装 Vite 中 Suspense 正常工作
routes 删除 backend/auth/mobile 路由 SPA 入口 entry.desktop.tsx 仅使用 desktopRoutes,不含后端 / 认证路由
cleanUp 移除 'use server' 指令 .vite.ts 文件已绕过 server-only 模块

类别 B:已由 .vite.ts 等价处理

Modifier 原操作 .vite.ts 等价方案
nextDynamicToStatic next/dynamic() → 静态 import SPA 中已无 next/dynamicdynamic.tsx 已由 .vite.ts 替换为 React.lazy
dynamicToStatic dynamicElement() → 静态 import Vite 原生支持 React.lazy code splitting,dynamicElement 正常工作
settingsContentToStatic Settings componentMap dynamic() → 静态 import Vite 原生 React.lazy 正常处理 Settings tab 懒加载

类别 C:.vite.ts 行为不等价,需要 .desktop.ts 覆盖

Modifier 原操作 .vite.ts 现状 不等价原因 .desktop.ts 方案
i18nDynamicToStatic (namespace) 全量静态 import 所有 locale JSON,同步查表 import.meta.glob 懒加载(async) Desktop 本地运行,async 增加启动延迟且无必要;原 modifier 是同步的 import.meta.glob({ eager: true }) 同步预加载
i18nDynamicToStatic (UI resources) 全量静态 import 所有 UI locale JSON,同步查表 import.meta.glob 懒加载(async) 同上 import.meta.glob({ eager: true }) 同步预加载
i18nDynamicToStatic (antd locale) 随 i18n 整体静态化 import.meta.glob 懒加载(async) 同上 import.meta.glob({ eager: true }) 同步预加载
i18nDynamicToStatic (dayjs locale) 随 i18n 整体静态化 import.meta.glob 懒加载(async) 同上 import.meta.glob({ eager: true }) 同步预加载

类别 D:appCode.mts 各项处置

原操作 现状 需要 .desktop
替换 page.tsx 为 desktop-only SPA 入口 entry.desktop.tsx 直接使用 desktopRoutes
移除 DevPanel SPAGlobalProvider 已注释 DevPanel(node:fs 不可用)
删除 Security 目录 SPA Router 不含 security 路由(不可达)
移除 Security tab 引用 需确认 desktopRoutes 是否包含 security 路由。若不包含则不可达,无需处理 待确认
移除 SpeedInsights/Analytics SPAGlobalProvider 不含 SpeedInsights;Analytics 由 .vite.tsx 处理
替换 mdx/Image Image.vite.tsx 已移除 plaiceholder/sharp
移除 manifest/metadataBase SPA 无 Next.js metadata

3.2 需要创建的 .desktop.ts 文件(4 个)

3.2a src/utils/i18n/loadI18nNamespaceModule.desktop.ts

.vite.ts 结构相同,但 import.meta.glob 使用 { eager: true }

import type {
  LoadI18nNamespaceModuleParams,
  LoadI18nNamespaceModuleWithFallbackParams,
} from './loadI18nNamespaceModule';

// eager: true — 构建时全量内联,运行时同步访问
const defaultModules = import.meta.glob<{ default: Record<string, string> }>(
  '/src/locales/default/*.ts',
  { eager: true },
);
const localeModules = import.meta.glob<{ default: Record<string, string> }>('/locales/*/*.json', {
  eager: true,
});

const getDefaultKey = (ns: string) => `/src/locales/default/${ns}.ts`;
const getLocaleKey = (lng: string, ns: string) => `/locales/${lng}/${ns}.json`;

export const loadI18nNamespaceModule = async (
  params: LoadI18nNamespaceModuleParams,
): Promise<{ default: Record<string, string> }> => {
  const { defaultLang, normalizeLocale, lng, ns } = params;

  if (lng === defaultLang) {
    const mod = defaultModules[getDefaultKey(ns)];
    if (!mod) throw new Error(`Missing default namespace: ${ns}`);
    return mod; // 同步返回,无需 await
  }

  const normalizedLng = normalizeLocale(lng);
  const localeMod = localeModules[getLocaleKey(normalizedLng, ns)];
  if (localeMod) return localeMod;

  const defaultMod = defaultModules[getDefaultKey(ns)];
  if (!defaultMod) throw new Error(`Missing default namespace: ${ns}`);
  return defaultMod;
};

// ... loadI18nNamespaceModuleWithFallback 同理

关键差异import.meta.glob({ eager: true }) 返回 Record<string, Module> 而非 Record<string, () => Promise<Module>>。Vite 构建时将所有 locale JSON 内联到 bundle 中,运行时同步访问。等价于原 modifier 生成的 staticLocaleNamespaceMap

3.2b src/utils/locale.desktop.ts

import { normalizeLocale } from '@/locales/resources';

// eager: true — antd locale 全量内联
const antdLocaleModules = import.meta.glob('/node_modules/antd/es/locale/*.js', { eager: true });

export const getAntdLocale = async (lang?: string) => {
  let normalLang: any = normalizeLocale(lang);
  if (normalLang === 'ar') normalLang = 'ar-EG';

  const localePath = `/node_modules/antd/es/locale/${normalLang.replace('-', '_')}.js`;
  const mod = antdLocaleModules[localePath];
  if (!mod) throw new Error(`Unsupported antd locale: ${normalLang}`);

  return (mod as any).default; // 同步访问
};

3.2c src/libs/getUILocaleAndResources.desktop.ts

import { en, zhCn } from '@lobehub/ui/es/i18n/resources/index';
import { normalizeLocale } from '@/locales/resources';

type UILocaleResources = Record<string, Record<string, string>>;

// eager: true — UI locale 全量内联
const uiLocaleModules = import.meta.glob<{ default: UILocaleResources }>('/locales/*/ui.json', {
  eager: true,
});

const getUILocale = (locale: string): string => {
  if (locale.startsWith('zh')) return 'zh-CN';
  if (locale.startsWith('en')) return 'en-US';
  return locale;
};

const loadBusinessResources = (locale: string): UILocaleResources | null => {
  const key = `/locales/${locale}/ui.json`;
  const mod = uiLocaleModules[key];
  return mod ? (mod.default as UILocaleResources) : null;
};

const loadLobeUIBuiltinResources = (locale: string): UILocaleResources | null => {
  if (locale.startsWith('zh')) return zhCn as UILocaleResources;
  return en as UILocaleResources;
};

export const getUILocaleAndResources = async (
  locale: string | 'auto',
): Promise<{ locale: string; resources: UILocaleResources }> => {
  const effectiveLocale = locale === 'auto' ? 'en-US' : locale;
  const normalizedLocale = normalizeLocale(effectiveLocale);
  const uiLocale = getUILocale(normalizedLocale);

  const resources =
    loadBusinessResources(normalizedLocale) ??
    loadLobeUIBuiltinResources(normalizedLocale) ??
    loadBusinessResources('en-US') ??
    loadLobeUIBuiltinResources('en-US');

  if (!resources) throw new Error(`Failed to load UI resources for locale=${normalizedLocale}`);

  return { locale: uiLocale, resources };
};

注意loadBusinessResourcesloadLobeUIBuiltinResources 从 async 变为同步函数(eager 模块无需 await)。@lobehub/ui 的 built-in resources 也改为顶层静态 import(等价于原 modifier 的 import { en, zhCn } from '...')。

3.2d src/layout/SPAGlobalProvider/Locale.desktop.tsx

Locale.tsx 相同,但 dayjs locale 使用 { eager: true }

// eager: true — dayjs locale 全量内联
const dayjsLocaleModules = import.meta.glob<{ default: ILocale }>(
  '/node_modules/dayjs/locale/*.js',
  { eager: true },
);

const updateDayjs = (lang: string) => {
  const locale = lang.toLowerCase() === 'en-us' ? 'en' : lang.toLowerCase();
  const key = `/node_modules/dayjs/locale/${locale}.js`;
  const mod = dayjsLocaleModules[key] ?? dayjsLocaleModules['/node_modules/dayjs/locale/en.js'];

  if (mod) dayjs.locale((mod as any).default);
};

updateDayjs 从 async 变为同步函数。

3.3 等价性总结

原 Modifier 等价方案 机制
nextConfig 不需要 不再使用 Next.js 构建
nextDynamicToStatic .vite.ts 已处理 SPA 无 next/dynamic
dynamicToStatic Vite 原生 React.lazy code splitting
i18nDynamicToStatic 4 个 .desktop.ts 文件 import.meta.glob({ eager: true }) 同步预加载
settingsContentToStatic Vite 原生 React.lazy code splitting
wrapChildrenWithClientOnly 不需要 SPA 纯客户端
removeSuspense 不需要 Suspense 正常工作
staticExport 不需要 无 Next.js 路由
appCode 已处理 / 按需 .desktop 见类别 D
routes 不需要 desktopRoutes 仅含桌面路由
cleanUp .vite.ts 已处理 绕过 server-only 模块

Phase 4: Electron 主进程适配

目标:更新 Electron 主进程,加载 Vite Renderer 产物。

4.1 更新 apps/desktop/src/main/const/dir.ts

// Before:
const nextExportOutDir = join(appPath, 'dist', 'next', 'out');
const nextExportDefaultDir = join(appPath, 'dist', 'next');
export const nextExportDir = pathExistsSync(nextExportOutDir)
  ? nextExportOutDir
  : nextExportDefaultDir;

// After:
export const rendererDir = join(appPath, 'dist', 'renderer');

4.2 更新 RendererUrlManager.ts

// Before:
import { nextExportDir } from '@/const/dir';
// ...
constructor() {
  this.rendererProtocolManager = new RendererProtocolManager({
    nextExportDir,
    // ...
  });
}

// After:
import { rendererDir } from '@/const/dir';
// ...
constructor() {
  this.rendererProtocolManager = new RendererProtocolManager({
    rendererDir,
    // ...
  });
}

简化 resolveRendererFilePath:Vite SPA 只有一个 index.html,无需复杂的多页面解析。所有非静态资源请求都 fallback 到 index.html

resolveRendererFilePath = async (url: URL): Promise<string | null> => {
  const pathname = url.pathname;

  // 静态资源直接映射
  if (pathname.startsWith('/assets/') || extname(pathname)) {
    const filePath = join(rendererDir, pathname);
    return pathExistsSync(filePath) ? filePath : null;
  }

  // 所有路由 fallback 到 index.html(SPA)
  return join(rendererDir, 'index.html');
};

4.3 更新 RendererProtocolManager.ts

// Before:
const RENDERER_DIR = 'next';

// After:
const RENDERER_DIR = 'renderer';

属性名 nextExportDirrendererDir 全局重命名。

4.4 _next/ 路径适配

原 Next.js 静态导出的资源路径前缀为 /_next/static/。Vite 产物的资源路径为 /assets/RendererUrlManager.resolveRendererFilePathRendererProtocolManager.isAssetRequest 中对 /_next/ 的检查需更新:

// Before:
if (pathname.startsWith('/_next/') || pathname.startsWith('/static/') || ...)

// After:
if (pathname.startsWith('/assets/') || ...)

4.5 window.__SERVER_CONFIG__

__SERVER_CONFIG__ 无需运行时注入。Web SPA 的 catch-all route 也是 force-static,该值在构建时即可确定。Electron Renderer 的 index.html 中直接内联即可(见 Phase 2.1 中 window.__SERVER_CONFIG__ = undefined),或在 SPAGlobalProvider 中处理 undefined 的情况。


Phase 5: electron-builder.mjs 更新

目标:更新打包配置,引用 Vite Renderer 产物。

// Before:
files: [
  'dist',
  'dist/next/**/*',
  '!dist/next/docs',
  '!dist/next/packages',
  '!dist/next/.next/server/app/sitemap',
  '!dist/next/.next/static/media',
  // ...
];

// After:
files: [
  'dist',
  'dist/renderer/**/*',
  // 移除所有 dist/next 相关排除规则(Vite 产物无此结构)
  // ...
];

Phase 6: 清理

目标:移除所有不再需要的文件和依赖。

6.1 删除文件

scripts/electronWorkflow/modifiers/          # 整个目录(13 个文件)
scripts/electronWorkflow/buildNextApp.mts    # shadow workspace 构建(已由 electron-vite 取代)
scripts/electronWorkflow/moveNextExports.ts  # 复制脚本(electron-vite 直接输出到 dist/renderer/)

6.2 删除 / 减少依赖

  • @ast-grep/napi — 若仅 modifier 使用,从 devDependencies 移除
  • fs-extra(根目录) — 检查是否仍有其他引用

6.3 更新 package.json scripts

移除:

  • desktop:build:renderer
  • desktop:build:renderer:all
  • desktop:build:renderer:prepare

简化 desktop:build:alldesktop:package:app


Phase 7: 删除 NEXT_PUBLIC_IS_DESKTOP_APP,统一 isDesktop

目标:将分散在多个包和脚本中的 isDesktop 判断统一收归 @lobechat/const,并完全删除 NEXT_PUBLIC_IS_DESKTOP_APP 环境变量。

7.0 架构分析

在新的构建体系下,NEXT_PUBLIC_IS_DESKTOP_APP 可以完全删除:

运行环境 isDesktop 理由
Vite Desktop SPA(Electron Renderer) true electron-vite 构建时 define: { '__ELECTRON__': 'true' }
Vite Web SPA false define: { '__ELECTRON__': 'false' }
Next.js 客户端(仅 (auth) 路由组) false (auth) 路由组在 desktop 本地构建中不存在
Next.js 服务端 false Desktop 本地构建不含任何 Server 代码
脚本(prebuild, registerDesktopEnv 等) N/A 脚本使用 DESKTOP_BUILD 环境变量判断

7.1 统一 @lobechat/const 中的 isDesktop

// packages/const/src/version.ts — Before:
export const isDesktop =
  typeof import.meta !== 'undefined' && import.meta.env
    ? import.meta.env.VITE_IS_DESKTOP_APP === '1'
    : process.env.NEXT_PUBLIC_IS_DESKTOP_APP === '1';

// After:
export const isDesktop = typeof __ELECTRON__ !== 'undefined' && !!__ELECTRON__;

说明__ELECTRON__ 由 Vite define 在构建时注入。所有 Vite config 均须声明此常量(electron-vite renderer 为 true,根目录 Web SPA 为 false),避免运行时 ReferenceError。无需环境变量。

需同步添加全局类型声明:

// src/types/global.d.ts 或 packages/const/src/global.d.ts
declare const __ELECTRON__: boolean | undefined;
declare const __MOBILE__: boolean | undefined;

7.2 三个 builtin-tool 包统一 re-export

这三个包各自独立定义了 isDesktop,应改为从 @lobechat/const re-export:

// packages/builtin-tool-skills/src/const.ts — Before:
export const isDesktop = process.env.NEXT_PUBLIC_IS_DESKTOP_APP === '1';

// After:
export { isDesktop } from '@lobechat/const';
// packages/builtin-tool-gtd/src/const.ts — Before:
export const isDesktop =
  typeof import.meta !== 'undefined' && import.meta.env
    ? import.meta.env.VITE_IS_DESKTOP_APP === '1'
    : process.env.NEXT_PUBLIC_IS_DESKTOP_APP === '1';

// After:
export { isDesktop } from '@lobechat/const';
// packages/builtin-tool-group-management/src/const.ts — Before:
// (同 builtin-tool-gtd)

// After:
export { isDesktop } from '@lobechat/const';

7.3 脚本改用 DESKTOP_BUILD

4 个脚本文件中的 isDesktop 判断从 NEXT_PUBLIC_IS_DESKTOP_APP 改为 DESKTOP_BUILD

// scripts/prebuild.mts — Before:
const isDesktop = process.env.NEXT_PUBLIC_IS_DESKTOP_APP === '1';
// After:
const isDesktop = process.env.DESKTOP_BUILD === 'true';

// scripts/runNextDesktop.mts — Before:
const isDesktop = process.env.NEXT_PUBLIC_IS_DESKTOP_APP === '1';
// After:
const isDesktop = process.env.DESKTOP_BUILD === 'true';

// scripts/registerDesktopEnv.cjs — Before:
const isDesktop = process.env.NEXT_PUBLIC_IS_DESKTOP_APP === '1';
// After:
const isDesktop = process.env.DESKTOP_BUILD === 'true';

// scripts/migrateServerDB/index.ts — Before:
const isDesktop = process.env.NEXT_PUBLIC_IS_DESKTOP_APP === '1';
// After:
const isDesktop = process.env.DESKTOP_BUILD === 'true';

7.4 删除环境变量文件中的旧变量

# .env.desktop — Before:
NEXT_PUBLIC_IS_DESKTOP_APP=1

# After:
# 删除此行。DESKTOP_BUILD 无需在 .env 中定义,
# apps/desktop 下的构建天然即为 desktop build,
# 由 electron-vite config 的 define 直接注入 __ELECTRON__=true。

7.5 更新 package.json scripts

// Before:
"desktop:build:renderer": "cross-env ... NEXT_PUBLIC_IS_DESKTOP_APP=1 tsx scripts/electronWorkflow/buildNextApp.mts",
"dev:desktop": "cross-env NEXT_PUBLIC_IS_DESKTOP_APP=1 tsx scripts/runNextDesktop.mts dev -p 3015",

// After:
// desktop:build:renderer 已在 Phase 2 中删除
"dev:desktop": "cross-env DESKTOP_BUILD=true tsx scripts/runNextDesktop.mts dev -p 3015",

7.6 更新 vite.config.ts

// Before:
define: {
  '__MOBILE__': JSON.stringify(isMobile),
  'process.env.NEXT_PUBLIC_IS_DESKTOP_APP': JSON.stringify(isElectron ? '1' : '0'),
},

// After:
define: {
  '__MOBILE__': JSON.stringify(isMobile),
  '__ELECTRON__': JSON.stringify(isElectron),
},

7.7 全局搜索清理

完成以上改动后,全局搜索以确保无遗漏:

# 应返回零结果
rg 'NEXT_PUBLIC_IS_DESKTOP_APP' --type-not md
rg 'VITE_IS_DESKTOP_APP' --type-not md

7.8 变更总结

文件 操作
packages/const/src/version.ts 修改isDesktop 改用 __ELECTRON__
packages/builtin-tool-skills/src/const.ts 修改 — re-export from @lobechat/const
packages/builtin-tool-gtd/src/const.ts 修改 — re-export from @lobechat/const
packages/builtin-tool-group-management/src/const.ts 修改 — re-export from @lobechat/const
src/types/global.d.ts 修改 — 添加 __ELECTRON__ 类型声明
scripts/prebuild.mts 修改DESKTOP_BUILD
scripts/runNextDesktop.mts 修改DESKTOP_BUILD
scripts/registerDesktopEnv.cjs 修改DESKTOP_BUILD
scripts/migrateServerDB/index.ts 修改DESKTOP_BUILD
.env.desktop 修改 — 替换环境变量
package.json(根) 修改 — 更新 scripts
vite.config.ts 修改define 改用 __ELECTRON__

净效果:完全删除 NEXT_PUBLIC_IS_DESKTOP_APPVITE_IS_DESKTOP_APP 两个环境变量,统一为编译时常量 __ELECTRON__。消除 4 处重复的 isDesktop 定义,收归 @lobechat/const 单一来源。


Phase 8: 验证

8.1 构建验证

# 完整 Electron 构建(main + preload + renderer)
cd apps/desktop && npm run build:main
# 确认 dist/renderer/ 输出正确(index.html + assets/)

# 完整打包
npm run desktop:package:local

8.2 功能验证

  • Electron 应用启动,加载 Vite SPA Renderer
  • app://renderer protocol 正确响应
  • 路由跳转正常(chat, settings, discover 等)
  • 静态资源(JS/CSS/ 图片)正确加载
  • i18n 切换正常
  • Analytics(desktop Umami)正常
  • Deep link(lobehub:// protocol)正常

8.3 对比

指标 Before(Next.js export) After(electron-vite renderer)
构建命令 buildNextApp.mts + moveNextExports.ts + electron-vite build(分步) electron-vite build(一步)
构建时间 ~60-120s(shadow workspace + modifiers + next build + copy) ~20-30s(electron-vite 统一构建)
构建复杂度 11 个 AST modifier + shadow workspace + 文件复制 零 modifier,标准 Vite 构建
维护成本 源码结构变化需同步更新 modifier .desktop 后缀文件,与源码同步维护
产物结构 dist/next/ 多页面 HTML + _next/ 资源 dist/renderer/index.html + assets/

变更总结

文件 操作 Phase
scripts/electronWorkflow/modifiers/ 删除整个目录(13 个文件) 1
scripts/electronWorkflow/buildNextApp.mts 删除 2
scripts/electronWorkflow/moveNextExports.ts 删除 2
apps/desktop/index.html 新增 — Renderer HTML 入口 2
apps/desktop/electron.vite.config.ts 修改 — 增加 renderer entry 2
src/utils/i18n/loadI18nNamespaceModule.desktop.ts 新增 — eager glob 同步 i18n 3
src/utils/locale.desktop.ts 新增 — eager glob 同步 antd locale 3
src/libs/getUILocaleAndResources.desktop.ts 新增 — eager glob 同步 UI locale 3
src/layout/SPAGlobalProvider/Locale.desktop.tsx 新增 — eager glob 同步 dayjs locale 3
apps/desktop/src/main/const/dir.ts 修改nextExportDirrendererDir 4
apps/desktop/src/main/core/infrastructure/RendererUrlManager.ts 修改 — SPA fallback 逻辑 4
apps/desktop/src/main/core/infrastructure/RendererProtocolManager.ts 修改RENDERER_DIR = 'renderer' 4
apps/desktop/electron-builder.mjs 修改dist/nextdist/renderer 5
package.json(根) 修改 — 移除 desktop:build:renderer*,更新 scripts 6, 7
packages/const/src/version.ts 修改isDesktop 改用 __ELECTRON__ 7
packages/builtin-tool-skills/src/const.ts 修改 — re-export from @lobechat/const 7
packages/builtin-tool-gtd/src/const.ts 修改 — re-export from @lobechat/const 7
packages/builtin-tool-group-management/src/const.ts 修改 — re-export from @lobechat/const 7
src/types/global.d.ts 修改__ELECTRON__ 类型声明 7
vite.config.ts 修改define 改用 __ELECTRON__ 7
scripts/prebuild.mts 修改DESKTOP_BUILD 7
scripts/runNextDesktop.mts 修改DESKTOP_BUILD 7
scripts/registerDesktopEnv.cjs 修改DESKTOP_BUILD 7
scripts/migrateServerDB/index.ts 修改DESKTOP_BUILD 7
.env.desktop 修改 — 替换环境变量 7

净减少:~2500 行 modifier 代码 + shadow workspace 构建逻辑 + 中间复制脚本 + 2 个环境变量(NEXT_PUBLIC_IS_DESKTOP_APPVITE_IS_DESKTOP_APP) + 4 处重复 isDesktop 定义。 构建流程:从 4 步(buildNextAppmoveNextExportselectron-vite buildelectron-builder)简化为 2 步(electron-vite buildelectron-builder)。 isDesktop 判断:从 5 处分散定义(@lobechat/const + 3 个 builtin-tool + 4 个脚本)统一为 @lobechat/const 单一来源 + 编译时常量 __ELECTRON__

Plan: Electron Renderer Manager 适配 electron-vite Dev Server

Context

前序 Plan(03-electron-vite-renderer-migration.md)已完成 Renderer 从 Next.js static export 到 Vite SPA 的迁移。但 RendererUrlManager 中仍残留 Next.js 逻辑:

  • 硬编码 http://localhost:3015 作为 dev 模式 renderer URL,实际已不再由 Next.js 提供
  • electron-vite 在 electron-vite dev 时自行启动 renderer Vite dev server,并通过 process.env['ELECTRON_RENDERER_URL'] 注入 URL 到 main process
  • 日志信息仍引用 "Next dev server"
  • Browser.ts 注释仍引用 app://next

核心变更

  • RendererUrlManager.configureRendererLoader() 读取 process.env['ELECTRON_RENDERER_URL'] 替代硬编码端口
  • 清除所有 Next.js 残留引用(日志、注释)
  • 更新测试

Phase 1: RendererUrlManager 适配 electron-vite

文件: apps/desktop/src/main/core/infrastructure/RendererUrlManager.ts

1.1 移除硬编码 URL

删除:

const devDefaultRendererUrl = 'http://localhost:3015';

1.2 修改 configureRendererLoader()

原逻辑:

configureRendererLoader() {
  if (isDev && !this.rendererStaticOverride) {
    this.rendererLoadedUrl = devDefaultRendererUrl;
    this.setupDevRenderer();
    return;
  }
  // ...
}

新逻辑:

configureRendererLoader() {
  const electronRendererUrl = process.env['ELECTRON_RENDERER_URL'];

  if (isDev && !this.rendererStaticOverride && electronRendererUrl) {
    this.rendererLoadedUrl = electronRendererUrl;
    this.setupDevRenderer();
    return;
  }

  if (isDev && !this.rendererStaticOverride && !electronRendererUrl) {
    logger.warn('Dev mode: ELECTRON_RENDERER_URL not set, falling back to protocol handler');
  }

  if (isDev && this.rendererStaticOverride) {
    logger.warn('Dev mode: DESKTOP_RENDERER_STATIC enabled, using static renderer handler');
  }

  this.setupProdRenderer();
}

1.3 更新日志信息

  • setupDevRenderer(): "renderer served from Next dev server" → "renderer served from electron-vite dev server at %URL%"
  • setupProdRenderer(): "serve static Next export assets" → "serve static renderer assets"(注释 + 日志)

Phase 2: 清理残留 Next.js 引用

2.1 Browser.ts 注释修正

文件: apps/desktop/src/main/core/browser/Browser.ts

Line 494 注释:

// In production, the renderer uses app://next protocol which triggers CORS

改为:

// In production, the renderer uses app://renderer protocol which triggers CORS

2.2 全局搜索验证

搜索 apps/desktop/ 下所有 next 相关残留引用(排除 node_modules、dist),确认无遗漏:

  • app://next
  • Next dev server
  • Next export
  • nextExport

Phase 3: 更新测试

文件: apps/desktop/src/main/core/infrastructure/__tests__/RendererUrlManager.test.ts

3.1 添加 dev 模式测试

新增测试用例:

  1. dev + ELECTRON_RENDERER_URL 已设置: buildRendererUrl('/') 返回 process.env.ELECTRON_RENDERER_URL + '/'
  2. dev + ELECTRON_RENDERER_URL 未设置: 回退到 protocol handler(app://renderer/
  3. dev + DESKTOP_RENDERER_STATIC: 无论 ELECTRON_RENDERER_URL 是否存在,都使用 protocol handler

需要 mock:

  • @/const/envisDevtrue
  • process.env['ELECTRON_RENDERER_URL'] 设置 / 清除

Phase 4: 验证

  1. 检查 electron-vite dev 启动时 main process 正确读取 ELECTRON_RENDERER_URL
  2. 检查 DESKTOP_RENDERER_STATIC=1 仍可强制使用 protocol handler
  3. 运行 RendererUrlManager 和 RendererProtocolManager 测试

Plan: Debug Proxy — 线上域名调试本地 Dev Server

Context

需求

开发者需要在线上生产域名下直接调试本地 Vite dev server 的代码,享有:

  • 线上 origin 的 cookie/auth/CORS — 无需本地搭建完整后端,API 请求自动携带线上凭证
  • 本地 HMR 热更新 — 修改代码即时生效
  • 真实生产环境 — 可复现线上才出现的问题(CORS 策略、CDN 行为、auth 流程等)

与现有 dev 模式的区别

维度 现有 next dev + vite dev 联调 Debug Proxy
运行位置 本地 localhost:3010 + localhost:3011 浏览器打开线上域名
后端 本地 Next.js server 线上生产 API
Auth/Cookie 本地 session(需单独配置) 线上真实 session
前端 本地 Vite dev server 本地 Vite dev server(通过 proxy 加载)
适用场景 日常开发 调试线上问题、验证线上环境行为

参考实现

Folo 项目的 debug_proxy.html:在 Electron renderer 中加载远端 Vite dev server 内容,通过 fetch + DOM 解析 + script rebase 实现。本方案将此模式泛化为 Web 通用方案。


Phase 1: 创建 Debug Proxy Next.js Route

目标:新增 Next.js catch-all route /__dangerous_local_dev_proxy/,部署到线上后可通过 URL 参数指定本地 dev server 地址,加载本地 bundle。使用 catch-all 使 SPA 客户端路由刷新时仍返回同一 HTML。

1.1 新增 src/app/__dangerous_local_dev_proxy/[[...path]]/route.ts

  • export const dynamic = 'force-static' — 构建时静态生成,无运行时开销
  • GET() 返回内联 HTML,包含:
    1. 解析 ?debug-host= 参数,fallback sessionStorage → 默认 localhost:3011
    2. Worker 跨域补丁(在任何模块加载前注入)
    3. React Refresh runtime 注入
    4. fetch 线上 /spa/{locale}/chat 提取 __SERVER_CONFIG__
    5. fetch 本地 dev server HTML,解析 DOM,rebase scripts/styles 到 dev server origin

1.2 关键设计说明

要点 说明
路径命名 __dangerous_local_dev_proxy — 下划线前缀 + dangerous 命名,明确表达风险性
catch-all [[...path]] — SPA 客户端路由刷新时返回同一 HTML
force-static 构建时生成静态响应,无服务端运行时成本
host 来源 URL 参数 ?debug-host= > sessionStorage 缓存 > 默认 localhost:3011
sessionStorage 持久化 首次设置后刷新无需再带参数;?reset 可清除
React Refresh 必须在 app bundle 加载前注入,否则 HMR 不生效
Script rebase 所有 <script src> 的相对路径重写为 dev server 绝对 URL
内联 script rewrite from "/xxx" 形式的 ESM import 路径也需 rebase
Worker 补丁 dev server 与线上不同源,Worker 需通过 Blob URL 中转
__SERVER_CONFIG__ 从线上 SPA route 的 HTML 中提取,确保 app 初始化时有完整配置
__DEBUG_PROXY__ 标志 供运行时判断是否处于 debug proxy 模式(如需差异化行为)

Phase 2: Vite Dev Server CORS 配置

目标:确保本地 Vite dev server 允许线上域名跨域 fetch 其资源。

2.1 修改 vite.config.ts

server 配置中添加 CORS 支持:

server: {
  cors: true, // 允许任意 origin 跨域请求(dev only)
  port: 3011,
  // ... 现有 proxy 配置
},

cors: true 在 Vite dev server 中等价于 Access-Control-Allow-Origin: *,仅影响开发环境。

2.2 验证

从线上域名打开 /__dangerous_local_dev_proxy/?debug-host=http://localhost:3011,浏览器 Network 面板无 CORS 错误。


Phase 3: API 请求路径处理

目标:确保 SPA 在 debug proxy 模式下的 API 请求正确发往线上 origin。

3.1 分析现有 API 路径

SPA 中的 API 调用使用相对路径:/api/*/trpc/*/webapi/*/oidc/*

  • 在 debug proxy 模式下,浏览器 origin 为线上域名(如 https://app.lobehub.com
  • 相对路径 /api/xxx 会自动发往 https://app.lobehub.com/api/xxx天然正确
  • 无需任何改动,API 请求自然携带线上 cookie

3.2 Vite HMR WebSocket 连接

Vite dev server 的 HMR 通过 WebSocket 连接到 localhost:3011。在 debug proxy 模式下,Vite client 需要知道 WS 地址。

Vite 默认行为:当 <script src="http://localhost:3011/xxx"> 加载时,Vite client 会从 import.meta.url 推断 WS 地址为 ws://localhost:3011无需额外配置


Phase 4: __DEBUG_PROXY__ 全局标志

目标:在运行时提供一个标志,供 app 代码判断是否处于 debug proxy 模式。

4.1 类型声明

src/types/global.d.ts 中添加:

/** Set by debug-proxy when loading local dev server on production domain */
const __DEBUG_PROXY__: boolean | undefined;

4.2 潜在用途

  • 关闭 Service Worker 注册(SW 会拦截请求,干扰 debug proxy)
  • 调整 analytics 行为(避免污染线上数据)
  • 在 UI 中显示 debug 标识

本 Plan 不预设具体用途,仅提供标志位。后续按需在代码中检查 globalThis.__DEBUG_PROXY__


变更总结

文件 操作 Phase
src/app/__dangerous_local_dev_proxy/[[...path]]/route.ts 新增 — Debug Proxy catch-all route 1
vite.config.ts 修改 — 添加 cors: true 2
src/types/global.d.ts 修改 — 添加 __DEBUG_PROXY__ 类型声明 4

净增:1 个 Next.js route handler + 2 行配置改动。零运行时侵入。


使用方式

1. 本地启动 Vite dev server: bun run dev:spa
2. 打开线上域名: https://app.lobehub.com/__dangerous_local_dev_proxy/?debug-host=http://localhost:3011
3. 完成。线上 cookie/auth 自动生效,本地代码 HMR 热更新可用。

参数:
- ?debug-host=http://localhost:3011  — 指定本地 dev server 地址(首次设置后缓存到 sessionStorage)
- ?reset                             — 清除 sessionStorage 中缓存的 debug-host

注意事项

  1. HTTPS ↔ HTTP Mixed Content:线上 HTTPS 域名 fetch http://localhost 会被浏览器阻止。解决方案:

    • Chrome: 地址栏盾牌图标 → 允许不安全内容
    • 或本地 dev server 使用 HTTPS(vite --httpsmkcert 自签证书)
  2. __SERVER_CONFIG__ 竞态:debug proxy 中 config 通过异步 fetch 获取,可能晚于 app 初始化。需确保 SPAGlobalProvider 能处理 window.__SERVER_CONFIG__undefined 的初始状态(等待加载完成后再渲染)。如现有代码已处理则无需改动。

  3. Service Worker:若线上已注册 SW,它可能拦截对 localhost 的请求。debug proxy 模式下应考虑跳过 SW 注册,或在 SW 中判断 __DEBUG_PROXY__ 标志。

  4. 安全性:路径名 __dangerous_local_dev_proxy 已明确表达风险性。该 route 不含敏感数据,仅是一个加载器。恶意用户可通过 ?debug-host= 指向恶意服务器,但风险等同于在控制台执行任意 JS — 属于自损行为,不构成对其他用户的安全威胁。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment