Compare commits

...

27 Commits

Author SHA1 Message Date
Innei cdd8ec403e very important 2026-02-22 01:30:01 +08:00
Innei 8fbb592351 🔧 fix: use custom Vite plugin for module redirects instead of resolve.alias 2026-02-21 23:58:24 +08:00
Innei e20a5ab2ec feat: add Vite-compatible i18n/locale modules with import.meta.glob and resolve aliases 2026-02-21 23:41:05 +08:00
Innei a55a0be86a ♻️ refactor: rename (spa) route group to spa segment, rewrite SPA routes via middleware 2026-02-21 23:37:01 +08:00
Innei 7839976866 🔧 chore: register dev:next task in turbo.json for parallel dev startup 2026-02-21 23:30:16 +08:00
Innei b32c08c261 feat: auto-generate spaHtmlTemplates from vite build output 2026-02-21 23:29:31 +08:00
Innei 12297ad3a5 feat: set vite base to /spa/ for production builds 2026-02-21 23:28:11 +08:00
Innei 6479b395eb ♻️ refactor: remove theme/locale reads from SPAGlobalProvider 2026-02-21 23:27:23 +08:00
Innei 3054e92584 feat: add [locale] segment with force-static and SEO meta generation 2026-02-21 23:26:41 +08:00
Innei ad70ad24fe ♻️ refactor: remove locale and theme from SPAServerConfig 2026-02-21 23:20:01 +08:00
Innei 82a98e8042 feat: add locale detection script to index.html for SPA dev mode 2026-02-21 23:19:39 +08:00
Innei 27d085c374 plan2 2026-02-21 23:16:22 +08:00
Innei d1bee6488a 🗑️ chore: remove old Next.js route segment files and serwist PWA
- Delete [variants] page.tsx, error.tsx, not-found.tsx, loading.tsx
- Delete root loading.tsx and empty [[...path]] directory
- Delete unused loaders directory
- Remove @serwist/next PWA wrapper from Next.js config
2026-02-21 01:24:43 +08:00
Innei 3f41cf94d6 🔧 chore: update build scripts and Dockerfile for SPA integration
- build:docker now includes SPA build + copy steps
- dev defaults to Vite SPA, dev:next for Next.js backend
- Dockerfile copies public/spa/ assets for production
- Add public/spa/ to .gitignore (build artifact)
2026-02-21 01:22:30 +08:00
Innei 9a5410ebea ♻️ refactor: replace next-mdx-remote/rsc with react-markdown
Use client-side react-markdown for MDX rendering instead of
Next.js RSC-dependent next-mdx-remote.
2026-02-21 01:20:29 +08:00
Innei da2ea9101d ♻️ refactor: migrate @t3-oss/env-nextjs to @t3-oss/env-core
Replace framework-specific env validation with framework-agnostic version.
Add clientPrefix where client schemas exist.
2026-02-21 01:19:32 +08:00
Innei 9996f29a79 ♻️ refactor: replace Next.js-specific analytics with vanilla JS
- Google.tsx: replace @next/third-parties/google with direct gtag script
- ReactScan.tsx: replace react-scan/monitoring/next with generic script
- Desktop.tsx: replace next/script with native script injection
2026-02-21 01:16:53 +08:00
Innei 9f5ea16a7f ♻️ refactor: skip auth checks for SPA routes in middleware
SPA pages are all public (no sensitive data in HTML).
Auth is handled client-side by SPAGlobalProvider's AuthProvider.
Only Next.js auth routes and API endpoints go through session checks.
2026-02-21 01:09:53 +08:00
Innei 12450861ba ♻️ refactor: add SPA catch-all route handler with Vite dev proxy
- Create (spa)/[[...path]]/route.ts for serving SPA HTML
- Dev mode: proxy Vite dev server, rewrite asset URLs, inject Worker patch
- Prod mode: read pre-built HTML templates
- Build SPAServerConfig with analytics, theme, clientEnv, featureFlags
- Update middleware to pass SPA routes through to catch-all
2026-02-21 01:08:23 +08:00
Innei 68696fc288 feat: Phase 5 - 新建 SPAGlobalProvider
- Create SPAServerConfig type (analyticsConfig, clientEnv, theme, featureFlags, locale)
- Add window.__SERVER_CONFIG__ and __MOBILE__ to global.d.ts
- Create SPAGlobalProvider (client-only Provider tree mirroring GlobalProvider)
- Includes AuthProvider for user session support
- Update entry.desktop.tsx and entry.mobile.tsx to wrap with SPAGlobalProvider
2026-02-21 01:01:49 +08:00
Innei cd83e068f7 ♻️ refactor: Phase 4b - Next.js 抽象层替换为 react-router-dom/vanilla React
- navigation.ts: useRouter/usePathname/useSearchParams/useParams → react-router-dom
- navigation.ts: redirect/notFound → custom error throws
- navigation.ts: useServerInsertedHTML → no-op for SPA
- Link.tsx: next/link → react-router-dom Link adapter (href→to, external→<a>)
- Image.tsx: next/image → <img> wrapper with fill/style support
- dynamic.tsx: next/dynamic → React.lazy + Suspense wrapper
2026-02-21 00:59:31 +08:00
Innei f2b5539246 ♻️ refactor: Phase 4a - Auth 页面改用直接 next/navigation 和 next/link
- 9 auth files: @/libs/next/navigation → next/navigation
- 5 auth files: @/libs/next/Link → next/link
- Auth pages remain in Next.js App Router, need direct Next.js imports
2026-02-21 00:58:03 +08:00
Innei 389feb84cb ♻️ refactor: Phase 3 - 第一方包 Next.js 解耦
- Replace next/link with <a> in builtin-tool-web-browsing (4 files, external links)
- Replace next/image with <img> in builtin-tool-agent-builder/InstallPlugin.tsx
- Add Vite import.meta.env compat for isDesktop in const/version.ts, builtin-tool-gtd, builtin-tool-group-management
2026-02-21 00:57:56 +08:00
Innei 85e4d55bd5 🏗️ chore: Phase 2 - Vite 工程搭建
- Add vite.config.ts with dual build (desktop/mobile via MOBILE env)
- Add index.html SPA template with __SERVER_CONFIG__ placeholder
- Add entry.desktop.tsx and entry.mobile.tsx SPA entry points
- Add dev:spa, dev:spa:mobile, build:spa, build:spa:copy scripts
- Install @vitejs/plugin-react and linkedom
2026-02-21 00:57:48 +08:00
Innei 90521e0b99 🔧 refactor: Phase 1 - 环境变量整治
- Fix Pyodide env var mismatch (NEXT_PUBLIC_PYPI_INDEX_URL → pythonEnv.NEXT_PUBLIC_PYODIDE_PIP_INDEX_URL)
- Consolidate python.ts to use pythonEnv instead of direct process.env
- Remove NEXT_PUBLIC_ prefix from server-side MARKET_BASE_URL (5 files)
2026-02-21 00:57:40 +08:00
Innei c4e7aae2ee 📝 docs: update SPA plan for dev mode Worker cross-origin handling
- Clarified the handling of Worker cross-origin issues in dev mode, emphasizing the need for `workerPatch` to wrap cross-origin URLs as blob URLs.
- Enhanced the explanation of the dev mode's resource URL rewriting process for better understanding.

Signed-off-by: Innei <tukon479@gmail.com>
2026-02-21 00:42:22 +08:00
Innei b8610dc49d init plan 2026-02-21 00:40:53 +08:00
81 changed files with 2679 additions and 326 deletions
+4 -1
View File
@@ -52,6 +52,7 @@ bun.lockb
# Build outputs
dist/
public/spa/
es/
lib/
.next/
@@ -83,6 +84,7 @@ public/sw*
public/swe-worker*
# Generated files
src/app/spa/[locale]/[[...path]]/spaHtmlTemplates.ts
public/*.js
public/sitemap.xml
public/sitemap-index.xml
@@ -127,4 +129,5 @@ out
i18n-unused-keys-report.json
.vitest-reports
pnpm-lock.yaml
pnpm-lock.yaml
.turbo
+2
View File
@@ -116,6 +116,8 @@ COPY --from=base /distroless/ /
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder /app/.next/standalone /app/
# Copy SPA assets (Vite build output)
COPY --from=builder /app/public/spa /app/public/spa
# Copy Next export output for desktop renderer
COPY --from=builder /app/apps/desktop/dist/next /app/apps/desktop/dist/next
+203
View File
@@ -0,0 +1,203 @@
import { type NextRequest } from 'next/server';
import { isRtlLang } from 'rtl-detect';
import { getServerFeatureFlagsValue } from '@/config/featureFlags';
import { DEFAULT_LANG, LOBE_LOCALE_COOKIE } from '@/const/locale';
import { analyticsEnv } from '@/envs/analytics';
import { appEnv } from '@/envs/app';
import { fileEnv } from '@/envs/file';
import { pythonEnv } from '@/envs/python';
import { getServerGlobalConfig } from '@/server/globalConfig';
import { serializeForHtml } from '@/server/utils/serializeForHtml';
import {
type AnalyticsConfig,
type SPAClientEnv,
type SPAServerConfig,
type SPAThemeConfig,
} from '@/types/spaServerConfig';
import { parseBrowserLanguage } from '@/utils/locale';
import { desktopHtmlTemplate, mobileHtmlTemplate } from './spaHtmlTemplates';
const isDev = process.env.NODE_ENV === 'development';
const VITE_DEV_ORIGIN = process.env.VITE_DEV_ORIGIN || 'http://localhost:3011';
async function rewriteViteAssetUrls(html: string): Promise<string> {
const { parseHTML } = await import('linkedom');
const { document } = parseHTML(html);
document.querySelectorAll('script[src]').forEach((el) => {
const src = el.getAttribute('src');
if (src && src.startsWith('/')) {
el.setAttribute('src', `${VITE_DEV_ORIGIN}${src}`);
}
});
document.querySelectorAll('link[href]').forEach((el) => {
const href = el.getAttribute('href');
if (href && href.startsWith('/')) {
el.setAttribute('href', `${VITE_DEV_ORIGIN}${href}`);
}
});
// Rewrite inline module scripts (e.g., Vite's React refresh preamble)
document.querySelectorAll('script[type="module"]:not([src])').forEach((el) => {
const text = el.textContent || '';
if (text.includes('/@')) {
el.textContent = text.replaceAll(
/from\s+["'](\/[@\w].*?)["']/g,
(_match, p) => `from "${VITE_DEV_ORIGIN}${p}"`,
);
}
});
// Patch Worker constructor to wrap cross-origin Vite URLs as blob URLs
const workerPatch = document.createElement('script');
workerPatch.textContent = `(function(){
var O=globalThis.Worker;
globalThis.Worker=function(u,o){
var h=typeof u==='string'?u:u instanceof URL?u.href:'';
if(h.startsWith('${VITE_DEV_ORIGIN}')){
var b=new Blob(['import "'+h+'";'],{type:'application/javascript'});
return new O(URL.createObjectURL(b),Object.assign({},o,{type:'module'}));
}return new O(u,o)};
globalThis.Worker.prototype=O.prototype;
})();`;
const head = document.querySelector('head');
if (head?.firstChild) {
head.insertBefore(workerPatch, head.firstChild);
}
return document.toString();
}
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);
}
if (isMobile) {
return mobileHtmlTemplate;
}
return desktopHtmlTemplate;
}
function buildAnalyticsConfig(): AnalyticsConfig {
const config: AnalyticsConfig = {};
if (analyticsEnv.ENABLE_GOOGLE_ANALYTICS && analyticsEnv.GOOGLE_ANALYTICS_MEASUREMENT_ID) {
config.google = { measurementId: analyticsEnv.GOOGLE_ANALYTICS_MEASUREMENT_ID };
}
if (analyticsEnv.ENABLED_PLAUSIBLE_ANALYTICS && analyticsEnv.PLAUSIBLE_DOMAIN) {
config.plausible = {
domain: analyticsEnv.PLAUSIBLE_DOMAIN,
scriptBaseUrl: analyticsEnv.PLAUSIBLE_SCRIPT_BASE_URL,
};
}
if (analyticsEnv.ENABLED_UMAMI_ANALYTICS && analyticsEnv.UMAMI_WEBSITE_ID) {
config.umami = {
scriptUrl: analyticsEnv.UMAMI_SCRIPT_URL,
websiteId: analyticsEnv.UMAMI_WEBSITE_ID,
};
}
if (analyticsEnv.ENABLED_CLARITY_ANALYTICS && analyticsEnv.CLARITY_PROJECT_ID) {
config.clarity = { projectId: analyticsEnv.CLARITY_PROJECT_ID };
}
if (analyticsEnv.ENABLED_POSTHOG_ANALYTICS && analyticsEnv.POSTHOG_KEY) {
config.posthog = {
debug: analyticsEnv.DEBUG_POSTHOG_ANALYTICS,
host: analyticsEnv.POSTHOG_HOST,
key: analyticsEnv.POSTHOG_KEY,
};
}
if (analyticsEnv.REACT_SCAN_MONITOR_API_KEY) {
config.reactScan = { apiKey: analyticsEnv.REACT_SCAN_MONITOR_API_KEY };
}
if (analyticsEnv.ENABLE_VERCEL_ANALYTICS) {
config.vercel = {
debug: analyticsEnv.DEBUG_VERCEL_ANALYTICS,
enabled: true,
};
}
if (analyticsEnv.DESKTOP_PROJECT_ID && analyticsEnv.DESKTOP_UMAMI_BASE_URL) {
config.desktop = {
baseUrl: analyticsEnv.DESKTOP_UMAMI_BASE_URL,
projectId: analyticsEnv.DESKTOP_PROJECT_ID,
};
}
return config;
}
function buildThemeConfig(): SPAThemeConfig {
return {
cdnUseGlobal: appEnv.CDN_USE_GLOBAL,
customFontFamily: appEnv.CUSTOM_FONT_FAMILY,
customFontURL: appEnv.CUSTOM_FONT_URL,
};
}
function buildClientEnv(): SPAClientEnv {
return {
marketBaseUrl: appEnv.NEXT_PUBLIC_MARKET_BASE_URL,
pyodideIndexUrl: pythonEnv.NEXT_PUBLIC_PYODIDE_INDEX_URL,
pyodidePipIndexUrl: pythonEnv.NEXT_PUBLIC_PYODIDE_PIP_INDEX_URL,
s3FilePath: fileEnv.NEXT_PUBLIC_S3_FILE_PATH,
};
}
export async function GET(request: NextRequest) {
const ua = request.headers.get('user-agent') || '';
const isMobile = /Android|iPhone|iPad|iPod|Mobile/i.test(ua);
const cookieLocale = request.cookies.get(LOBE_LOCALE_COOKIE)?.value;
const browserLanguage = parseBrowserLanguage(request.headers, DEFAULT_LANG);
const locale = cookieLocale || browserLanguage;
const serverConfig = await getServerGlobalConfig();
const featureFlags = getServerFeatureFlagsValue();
const analyticsConfig = buildAnalyticsConfig();
const theme = buildThemeConfig();
const clientEnv = buildClientEnv();
const spaConfig: SPAServerConfig = {
analyticsConfig,
clientEnv,
config: serverConfig,
featureFlags,
isMobile,
locale,
theme,
};
const dir = isRtlLang(locale) ? 'rtl' : 'ltr';
let html = await getTemplate(isMobile);
html = html.replace(
/window\.__SERVER_CONFIG__\s*=\s*undefined;\s*\/\*\s*SERVER_CONFIG\s*\*\//,
`window.__SERVER_CONFIG__ = ${serializeForHtml(spaConfig)};`,
);
html = html.replace('<!--LOCALE-->', locale);
html = html.replace('<!--DIR-->', dir);
html = html.replace('<!--SEO_META-->', '');
html = html.replace('<!--ANALYTICS_SCRIPTS-->', '');
return new Response(html, {
headers: {
'cache-control': 'private, no-cache',
'content-type': 'text/html; charset=utf-8',
'vary': 'Accept-Language, User-Agent, Cookie',
},
});
}
+35
View File
@@ -0,0 +1,35 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!--SEO_META-->
<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>
<script>
window.__SERVER_CONFIG__ = undefined; /* SERVER_CONFIG */
</script>
</head>
<body>
<div id="root" style="height: 100%"></div>
<!--ANALYTICS_SCRIPTS-->
<script type="module" src="/src/entry.desktop.tsx"></script>
</body>
</html>
+15 -3
View File
@@ -33,10 +33,13 @@
],
"scripts": {
"prebuild": "tsx scripts/prebuild.mts && npm run lint",
"build": "cross-env NODE_OPTIONS=--max-old-space-size=8192 next build --webpack",
"build": "bun run build:spa && bun run build:next",
"postbuild": "npm run build-sitemap && npm run build-migrate-db",
"build:analyze": "NODE_OPTIONS=--max-old-space-size=81920 ANALYZE=true next build --webpack",
"build:docker": "npm run prebuild && NODE_OPTIONS=--max-old-space-size=8192 DOCKER=true next build --webpack && npm run build-sitemap",
"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": "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:vercel": "tsx scripts/prebuild.mts && npm run lint:ts && npm run lint:style && npm run type-check:tsc && npm run lint:circular && cross-env NODE_OPTIONS=--max-old-space-size=6144 next build --webpack && npm run postbuild",
"build-migrate-db": "bun run db:migrate",
"build-sitemap": "tsx ./scripts/buildSitemapIndex/index.ts",
@@ -56,7 +59,7 @@
"desktop:package:app:platform": "tsx scripts/electronWorkflow/buildElectron.ts",
"desktop:package:local": "npm run desktop:build:renderer:all && npm run package:local --prefix=./apps/desktop",
"desktop:package:local:reuse": "npm run package:local:reuse --prefix=./apps/desktop",
"dev": "next dev -p 3010",
"dev": "turbo run dev:next dev:spa --filter=@lobehub/lobehub",
"dev:bun": "bun --bun next dev -p 3010",
"dev:desktop": "cross-env NEXT_PUBLIC_IS_DESKTOP_APP=1 tsx scripts/runNextDesktop.mts dev -p 3015",
"dev:desktop:static": "cross-env DESKTOP_RENDERER_STATIC=1 npm run dev --prefix=./apps/desktop",
@@ -64,6 +67,9 @@
"dev:docker:down": "docker compose -f docker-compose/dev/docker-compose.yml down",
"dev:docker:reset": "docker compose -f docker-compose/dev/docker-compose.yml down -v && rm -rf docker-compose/dev/data && npm run dev:docker && pnpm db:migrate",
"dev:mobile": "next dev -p 3018",
"dev:next": "next dev -p 3010",
"dev:spa": "vite --port 3011",
"dev:spa:mobile": "cross-env MOBILE=true vite --port 3012",
"docs:cdn": "npm run workflow:docs-cdn && npm run lint:mdx",
"docs:i18n": "lobe-i18n md && npm run lint:mdx",
"docs:seo": "lobe-seo && npm run lint:mdx",
@@ -240,6 +246,7 @@
"@react-three/fiber": "^9.5.0",
"@saintno/comfyui-sdk": "^0.2.49",
"@serwist/next": "^9.5.0",
"@t3-oss/env-core": "^0.13.10",
"@t3-oss/env-nextjs": "^0.13.10",
"@tanstack/react-query": "^5.90.20",
"@trpc/client": "^11.8.1",
@@ -338,6 +345,7 @@
"react-hotkeys-hook": "^5.2.3",
"react-i18next": "^16.5.3",
"react-lazy-load": "^4.0.1",
"react-markdown": "^10.1.0",
"react-pdf": "^10.3.0",
"react-responsive": "^10.0.1",
"react-rnd": "^10.5.2",
@@ -423,6 +431,7 @@
"@types/ws": "^8.18.1",
"@types/xast": "^2.0.4",
"@typescript/native-preview": "7.0.0-dev.20260207.1",
"@vitejs/plugin-react": "^5.1.4",
"@vitest/coverage-v8": "^3.2.4",
"ajv-keywords": "^5.1.0",
"code-inspector-plugin": "1.3.3",
@@ -446,6 +455,7 @@
"import-in-the-middle": "^2.0.5",
"just-diff": "^6.0.2",
"knip": "^5.82.1",
"linkedom": "^0.18.12",
"lint-staged": "^16.2.7",
"markdown-table": "^3.0.4",
"mcp-hello-world": "^1.1.2",
@@ -464,11 +474,13 @@
"serwist": "^9.5.0",
"stylelint": "^16.12.0",
"tsx": "^4.21.0",
"turbo": "^2.8.10",
"type-fest": "^5.4.1",
"typescript": "^5.9.3",
"unified": "^11.0.5",
"unist-util-visit": "^5.1.0",
"vite": "^7.3.1",
"vite-tsconfig-paths": "^6.1.1",
"vitest": "^3.2.4"
},
"packageManager": "pnpm@10.20.0",
@@ -4,7 +4,6 @@ import { KLAVIS_SERVER_TYPES, LOBEHUB_SKILL_PROVIDERS } from '@lobechat/const';
import type { BuiltinInterventionProps } from '@lobechat/types';
import { Avatar, Flexbox } from '@lobehub/ui';
import { CheckCircle } from 'lucide-react';
import Image from 'next/image';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -101,8 +100,7 @@ const InstallPluginIntervention = memo<BuiltinInterventionProps<InstallPluginPar
>
<Flexbox horizontal align="center" gap={12}>
{icon ? (
<Image
unoptimized
<img
alt={klavisTypeInfo?.label || identifier}
height={40}
src={icon}
@@ -144,8 +142,7 @@ const InstallPluginIntervention = memo<BuiltinInterventionProps<InstallPluginPar
>
<Flexbox horizontal align="center" gap={12}>
{icon ? (
<Image
unoptimized
<img
alt={lobehubSkillProviderInfo?.label || identifier}
height={40}
src={icon}
@@ -188,8 +185,7 @@ const InstallPluginIntervention = memo<BuiltinInterventionProps<InstallPluginPar
>
<Flexbox horizontal align="center" gap={12}>
{pluginIcon && typeof pluginIcon === 'string' && pluginIcon.startsWith('http') ? (
<Image
unoptimized
<img
alt={pluginName}
height={40}
src={pluginIcon}
@@ -1 +1,4 @@
export const isDesktop = process.env.NEXT_PUBLIC_IS_DESKTOP_APP === '1';
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';
+4 -1
View File
@@ -1 +1,4 @@
export const isDesktop = process.env.NEXT_PUBLIC_IS_DESKTOP_APP === '1';
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';
@@ -14,7 +14,6 @@ import {
import { Descriptions } from 'antd';
import { createStaticStyles } from 'antd-style';
import { ExternalLink } from 'lucide-react';
import Link from 'next/link';
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -163,17 +162,17 @@ const PageContent = memo<PageContentProps>(({ result }) => {
)}
<Flexbox horizontal align={'center'} className={styles.url} gap={4}>
{siteName && <div>{siteName} · </div>}
<Link
<a
className={styles.url}
href={url}
rel={'nofollow'}
rel={'nofollow noreferrer'}
style={{ display: 'flex', gap: 4 }}
target={'_blank'}
onClick={stopPropagation}
>
{result.originalUrl}
<Icon icon={ExternalLink} />
</Link>
</a>
</Flexbox>
<div className={styles.footer}>
@@ -2,7 +2,6 @@
import { CopyButton, Flexbox, Skeleton } from '@lobehub/ui';
import { createStaticStyles, cx } from 'antd-style';
import Link from 'next/link';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -44,9 +43,9 @@ const LoadingCard = memo<{ url: string }>(({ url }) => {
return (
<Flexbox className={styles.container}>
<Flexbox horizontal className={styles.cardBody} justify={'space-between'}>
<Link href={url} rel={'nofollow'} target={'_blank'}>
<a href={url} rel={'nofollow noreferrer'} target={'_blank'}>
<div className={styles.text}>{url}</div>
</Link>
</a>
<CopyButton content={url} size={'small'} />
</Flexbox>
<Flexbox gap={4} paddingInline={16}>
@@ -5,7 +5,6 @@ import { ActionIcon, Alert, Block, Flexbox, Text, stopPropagation } from '@lobeh
import { Descriptions } from 'antd';
import { createStaticStyles } from 'antd-style';
import { ExternalLink } from 'lucide-react';
import Link from 'next/link';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -114,9 +113,9 @@ const CrawlerResultCard = memo<CrawlerData>(({ result, messageId, crawler, origi
<Flexbox gap={8} paddingBlock={8} paddingInline={12}>
<Flexbox horizontal align={'center'} className={styles.titleRow} justify={'space-between'}>
<Text ellipsis>{title || originalUrl}</Text>
<Link href={url} target={'_blank'} onClick={stopPropagation}>
<a href={url} rel={'noreferrer'} target={'_blank'} onClick={stopPropagation}>
<ActionIcon icon={ExternalLink} size={'small'} />
</Link>
</a>
</Flexbox>
<Text ellipsis={{ rows: 2 }} fontSize={12} type={'secondary'}>
{description || result.content?.slice(0, 40)}
@@ -1,7 +1,6 @@
import type { UniformSearchResult } from '@lobechat/types';
import { Block, Flexbox, Text } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import Link from 'next/link';
import type { CSSProperties } from 'react';
import { memo } from 'react';
@@ -24,7 +23,7 @@ const SearchResultItem = memo<UniformSearchResult & { style?: CSSProperties }>(
const urlObj = new URL(url);
const host = urlObj.hostname;
return (
<Link href={url} target={'_blank'}>
<a href={url} rel={'noreferrer'} target={'_blank'}>
<Block
clickable
className={styles.container}
@@ -41,7 +40,7 @@ const SearchResultItem = memo<UniformSearchResult & { style?: CSSProperties }>(
</Text>
</Flexbox>
</Block>
</Link>
</a>
);
},
);
+4 -1
View File
@@ -4,7 +4,10 @@ import pkg from '../../../package.json';
export const CURRENT_VERSION = pkg.version;
export const isDesktop = process.env.NEXT_PUBLIC_IS_DESKTOP_APP === '1';
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';
// @ts-ignore
export const isCustomBranding = BRANDING_NAME !== 'LobeHub';
+587
View File
@@ -0,0 +1,587 @@
# 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 pagecatch-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 配置(两次 builddesktop + 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
```ts
// 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 原生)
],
});
```
构建命令:
```bash
# Desktop bundle
vite build
# Mobile bundle
MOBILE=true vite build
```
### 2.3 HTML 模板格式
单一 `index.html`Vite build 时根据 `__MOBILE__` 选择不同入口 tsx
```html
<!-- 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.tsx`。`window.__SERVER_CONFIG__` 的占位在 prod 由 Next.js catch-all route 替换注入。
### 2.4 SPA 入口文件
```tsx
// 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 开发代理配置
```ts
// 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/link``Result.tsx``SearchResultItem.tsx``Loading.tsx``PageContent/index.tsx`)。
**操作**:改为从 `react-router-dom` 导入 `Link`,或创建 adapter
```tsx
// packages/builtin-tool-web-browsing/src/client/Link.tsx
export { Link } from 'react-router-dom';
```
### 3.2 `packages/builtin-tool-agent-builder`
1 文件引用 `next/image``InstallPlugin.tsx`,使用 `<Image unoptimized />`)。
**操作**`unoptimized``next/image` 等价于 `<img>`,直接替换。
### 3.3 `packages/const` 和 `packages/builtin-tool-*`
3 文件引用 `process.env.NEXT_PUBLIC_IS_DESKTOP_APP`
**操作**Phase 2 中 Vite define 已处理。后续统一改为从共享 const 导入:
```ts
// 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.ts``auth.ts` 引用 `next/headers`。仅服务端使用,**无需改动**。
**验证**Vite SPA 工程能 `import` 这些 packages 并编译通过。
---
## Phase 4: Next.js 抽象层替换
**目标**:将 `src/libs/next/` 的 wrapper 指向非 Next 实现。
### 4.1 `navigation.ts`
现有导出 → 替换为:
| 导出 | 替换 |
| ----------------- | --------------------------------------------------------------------------------- |
| `useRouter` | `react-router-dom``useNavigate` 封装(兼容 `.push()/.replace()/.back()` API |
| `usePathname` | `react-router-dom``useLocation().pathname` |
| `useSearchParams` | `react-router-dom``useSearchParams` |
| `useParams` | `react-router-dom``useParams` |
| `redirect` | `react-router-dom``Navigate` 组件或 `useNavigate` |
| `notFound` | 自定义 throwSPA 内由 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-dom``Link`。注意 `next/link``href` prop 在 react-router-dom 中为 `to`
**关键文件**`src/libs/next/Link.tsx`
**影响范围**:需检查所有 `import Link from '@/libs/next'` 的用法中 prop 差异
### 4.3 `Image.tsx`
替换为 `<img>` 标签。现有 `next/image``fill`/`sizes`/`priority` 等 prop 需要适配或移除。
**关键文件**`src/libs/next/Image.tsx`
### 4.4 `dynamic.tsx`
替换为 `React.lazy + Suspense`。项目已有 `src/utils/router.tsx``dynamicElement` 工具可参考。
**关键文件**`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 ComponentSPA 无法使用。新建纯客户端版本:
```tsx
// 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` 注入的变量:
```ts
// 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.ts``Window.__SERVER_CONFIG__` + `__MOBILE__` 类型声明)
- `src/store/serverConfig/Provider.tsx`
- `src/components/Analytics/*.tsx`
- 现有 `src/layout/GlobalProvider/index.tsx` **不修改**
**验证**SPA 启动后 Zustand store 正确初始化 serverConfiguser 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` 中的实现
```ts
// 伪代码
async function getTemplate(isMobile: boolean): Promise<string> {
if (isDev) {
// 代理 Vite dev server HTMLrewrite 资源 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 缓存策略
```ts
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 页面全量 publicHTML 本身无敏感数据)
- 登录态检查由 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/CSScontent hash
└── mobile/
├── index.html # mobile 入口模板
└── assets/
```
### 9.2 构建脚本
```jsonc
// 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 适配
```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.tsx``layout.tsx``loading.tsx``metadata.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 正确初始化 serverConfiguser 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-react``babel` 选项)。
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.ts``resolve.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 迁移完成。
+659
View File
@@ -0,0 +1,659 @@
# 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(移除 `locale``theme`);catch-all 加 `[locale]` 段实现 `force-static` + 按语系生成 SEO metalocale 由 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 metatitle/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.js3010)。
---
## 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`
```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=` 持久化至 cookie90 天 = 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**
```bash
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,不做合并**
```typescript
// 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: string``theme: SPAThemeConfig`
- `SPAClientEnv` 不变(不合并 theme 字段)
**Step 2: 验证类型**
Run: `bunx tsc --noEmit --pretty src/types/spaServerConfig.ts` (预期此文件本身无错,后续文件会报错待 Task 4/5 修复)
**Step 3: Commit**
```bash
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-Language``RouteVariants.serializeVariants`、cookie 持久化)保持不变。SPA 路由当前走 `NextResponse.next()` 透传至 catch-all,无需改动 — `[locale]` 段将在 Task 5 中通过 `generateStaticParams` 静态生成,不需要 middleware rewrite。
**Step 1: 确认 SPA pass-through 逻辑**
`define-config.ts:102` 处:
```typescript
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.ts``src/app/(spa)/[locale]/[[...path]]/route.ts`
- Move: `src/app/(spa)/[[...path]]/spaHtmlTemplates.ts``src/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]` 目录**
```bash
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`
```typescript
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',
},
});
}
```
变更要点:
- 删除: `buildThemeConfig``SPAThemeConfig` import、`isRtlLang``parseBrowserLanguage``DEFAULT_LANG``LOBE_LOCALE_COOKIE``NextRequest`
- 删除: `cookieLocale``browserLanguage``locale` 检测、`dir``theme`
- 删除: `<!--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` 不再包含 `locale``theme`
- `GET` 函数签名从 `NextRequest` 改为 `Request`force-static 限制)
**Step 3: Commit**
```bash
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. `locale``serverConfig` 已无 `locale` 字段,将 `serverConfig?.locale ?? document.documentElement.lang ?? 'en-US'` 简化为 `document.documentElement.lang || 'en-US'`
2. `theme``serverConfig` 已无 `theme` 字段,删除 `customFontFamily`/`customFontURL`/`globalCDN` 三个 prop 传递,`<AppTheme>` 不传 prop(均 optional
```typescript
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**
```bash
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 配置**
```typescript
const isDev = process.env.NODE_ENV !== 'production';
export default defineConfig({
base: isDev ? '/' : '/spa/',
// ... 其余不变
});
```
> 注:`mode` 参数也可用,但 `process.env.NODE_ENV` 更直接。`vite build` 默认 `NODE_ENV=production``vite`dev server)默认 `NODE_ENV=development`。
**Step 2: Commit**
```bash
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: 创建生成脚本**
```typescript
// 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`
```jsonc
{
"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 的 `getTemplate` 走 `fetch(VITE_DEV_ORIGIN)` 分支,不依赖此文件(prod template 为空字符串不影响 dev)。
**Step 4: Commit**
```bash
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。若有报错,修复引用旧 `SPAThemeConfig``serverConfig.locale` / `serverConfig.theme` 的文件。
**Step 2: 检查 catch-all.eg.ts**
此文件为参考实现,若引用了 `SPAThemeConfig`,删除或更新。
**Step 3: 最终 Commit**
```bash
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.ts``import.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.tsx``import Link from 'next/link'` → 删除,`<Link>``<a>`
### 14c: mdx/Image — `plaiceholder` → `sharp`
`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.ts``next/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` | 删除 `SPAThemeConfig``locale``theme``SPAClientEnv` 不变 |
| `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 propimport 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:next``build:spa` 追加模板生成 |
| `turbo.json` | `dev``dev:next` 任务定义修复 |
+23
View File
@@ -0,0 +1,23 @@
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');
@@ -4,7 +4,7 @@ import { NextResponse } from 'next/server';
import { getTrustedClientTokenForSession } from '@/libs/trusted-client';
import { MarketService } from '@/server/services/market';
const MARKET_BASE_URL = process.env.NEXT_PUBLIC_MARKET_BASE_URL || 'https://market.lobehub.com';
const MARKET_BASE_URL = process.env.MARKET_BASE_URL || 'https://market.lobehub.com';
type RouteContext = {
params: Promise<{
@@ -9,7 +9,7 @@ import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import AuthCard from '@/features/AuthCard';
import Link from '@/libs/next/Link';
import Link from 'next/link';
const normalizeErrorCode = (code?: string | null) =>
(code || 'UNKNOWN').trim().toUpperCase().replaceAll('-', '_');
@@ -6,7 +6,7 @@ import { parseAsString, useQueryState } from 'nuqs';
import React, { memo } from 'react';
import { useTranslation } from 'react-i18next';
import Link from '@/libs/next/Link';
import Link from 'next/link';
const FailedPage = memo(() => {
const { t } = useTranslation('oauth');
@@ -5,7 +5,7 @@ import { Result } from 'antd';
import React, { memo, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSearchParams } from '@/libs/next/navigation';
import { useSearchParams } from 'next/navigation';
const SuccessPage = memo(() => {
const { t } = useTranslation('oauth');
@@ -1,5 +1,5 @@
import { authEnv } from '@/envs/auth';
import { notFound } from '@/libs/next/navigation';
import { notFound } from 'next/navigation';
import { defaultClients } from '@/libs/oidc-provider/config';
import { OIDCService } from '@/server/services/oidc';
@@ -4,8 +4,8 @@ import { Button } from '@lobehub/ui';
import { ChevronLeftIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import Link from '@/libs/next/Link';
import { useRouter, useSearchParams } from '@/libs/next/navigation';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import AuthCard from '../../../../features/AuthCard';
import { ResetPasswordContent } from './ResetPasswordContent';
@@ -9,7 +9,7 @@ import { useBusinessSignin } from '@/business/client/hooks/useBusinessSignin';
import { message } from '@/components/AntdStaticMethods';
import { requestPasswordReset, signIn } from '@/libs/better-auth/auth-client';
import { isBuiltinProvider, normalizeProviderId } from '@/libs/better-auth/utils/client';
import { useRouter, useSearchParams } from '@/libs/next/navigation';
import { useRouter, useSearchParams } from 'next/navigation';
import { useServerConfigStore } from '@/store/serverConfig';
import { serverConfigSelectors } from '@/store/serverConfig/selectors';
@@ -6,8 +6,8 @@ import { Lock, Mail } from 'lucide-react';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import Link from '@/libs/next/Link';
import { useSearchParams } from '@/libs/next/navigation';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { AuthCard } from '../../../../../features/AuthCard';
import { type SignUpFormValues } from './useSignUp';
@@ -7,7 +7,7 @@ import { type BusinessSignupFomData } from '@/business/client/hooks/useBusinessS
import { useBusinessSignup } from '@/business/client/hooks/useBusinessSignup';
import { message } from '@/components/AntdStaticMethods';
import { signUp } from '@/libs/better-auth/auth-client';
import { useRouter, useSearchParams } from '@/libs/next/navigation';
import { useRouter, useSearchParams } from 'next/navigation';
import { useServerConfigStore } from '@/store/serverConfig';
import { serverConfigSelectors } from '@/store/serverConfig/selectors';
@@ -4,8 +4,8 @@ import { Button } from '@lobehub/ui';
import { ChevronLeftIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import Link from '@/libs/next/Link';
import { useSearchParams } from '@/libs/next/navigation';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import AuthCard from '../../../../features/AuthCard';
import { VerifyEmailContent } from './VerifyEmailContent';
-3
View File
@@ -1,3 +0,0 @@
'use client';
export { default } from '@/components/Error';
-106
View File
@@ -1,106 +0,0 @@
import { type LoaderFunctionArgs } from 'react-router-dom';
/**
* Generic route params loader
* Extracts all params from the route and returns them
* Usage: loader: routeParamsLoader
* Access in component: const params = useLoaderData<RouteParams>();
*/
export interface RouteParams {
[key: string]: string | undefined;
}
export const routeParamsLoader = ({ params }: LoaderFunctionArgs): RouteParams => {
return params;
};
/**
* Specific loader for routes with 'slug' param
* Returns: { slug: string }
*/
export interface SlugParams {
slug: string;
}
export const slugLoader = ({ params }: LoaderFunctionArgs): SlugParams => {
if (!params.slug) {
throw new Error('Slug parameter is required');
}
return { slug: params.slug };
};
export const agentIdLoader = ({ params }: LoaderFunctionArgs): { agentId: string } => {
if (!params.aid) {
throw new Error('Slug parameter is required');
}
return { agentId: params.aid };
};
export const groupIdLoader = ({ params }: LoaderFunctionArgs): { groupId: string } => {
if (!params.gid) {
throw new Error('Group ID parameter is required');
}
return { groupId: params.gid };
};
/**
* Specific loader for routes with 'id' param
* Returns: { id: string }
*/
export interface IdParams {
id: string;
}
export const idLoader = ({ params }: LoaderFunctionArgs): IdParams => {
if (!params.id) {
throw new Error('ID parameter is required');
}
return { id: params.id };
};
/**
* Specific loader for settings tab routes
* Returns: { tab: string }
*/
export interface SettingsTabParams {
tab: string;
}
export const settingsTabLoader = ({ params }: LoaderFunctionArgs): SettingsTabParams => {
if (!params.tab) {
throw new Error('Tab parameter is required');
}
return { tab: params.tab };
};
/**
* Specific loader for provider detail routes
* Returns: { providerId: string }
*/
export interface ProviderIdParams {
providerId: string;
}
export const providerIdLoader = ({ params }: LoaderFunctionArgs): ProviderIdParams => {
if (!params.providerId) {
throw new Error('Provider ID parameter is required');
}
return { providerId: params.providerId };
};
/**
* Specific loader for memory type routes
* Returns: { type: MemoryType }
*/
export type MemoryType = 'identities' | 'contexts' | 'preferences' | 'experiences';
export interface MemoryTypeParams {
type: MemoryType;
}
export const memoryTypeLoader = ({ params }: LoaderFunctionArgs): MemoryTypeParams => {
if (!params.type) {
throw new Error('Memory type parameter is required');
}
return { type: params.type as MemoryType };
};
-3
View File
@@ -1,3 +0,0 @@
import Loading from '@/components/Loading/BrandTextLoading';
export default () => <Loading debugId="Variants" />;
-1
View File
@@ -1 +0,0 @@
export { default } from '@/components/404';
-23
View File
@@ -1,23 +0,0 @@
import Loading from '@/components/Loading/BrandTextLoading';
import dynamic from '@/libs/next/dynamic';
import { type DynamicLayoutProps } from '@/types/next';
import { RouteVariants } from '@/utils/server/routeVariants';
import DesktopRouter from './router';
const MobileRouter = dynamic(() => import('./(mobile)'), {
loading: () => <Loading debugId={'Root'} />,
});
export default async (props: DynamicLayoutProps) => {
// Get isMobile from variants parameter on server side
const isMobile = await RouteVariants.getIsMobile(props);
// Conditionally load and render based on device type
// Using native dynamic import ensures complete code splitting
// Mobile and Desktop bundles will be completely separate
if (isMobile) return <MobileRouter />;
return <DesktopRouter />;
};
-3
View File
@@ -1,3 +0,0 @@
import Loading from '@/components/Loading/BrandTextLoading';
export default () => <Loading debugId="App Root" />;
+216
View File
@@ -0,0 +1,216 @@
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 { 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';
async function rewriteViteAssetUrls(html: string): Promise<string> {
const { parseHTML } = await import('linkedom');
const { document } = parseHTML(html);
document.querySelectorAll('script[src]').forEach((el: Element) => {
const src = el.getAttribute('src');
if (src && src.startsWith('/')) {
el.setAttribute('src', `${VITE_DEV_ORIGIN}${src}`);
}
});
document.querySelectorAll('link[href]').forEach((el: Element) => {
const href = el.getAttribute('href');
if (href && href.startsWith('/')) {
el.setAttribute('href', `${VITE_DEV_ORIGIN}${href}`);
}
});
document.querySelectorAll('script[type="module"]:not([src])').forEach((el: Element) => {
const text = el.textContent || '';
if (text.includes('/@')) {
el.textContent = text.replaceAll(
/from\s+["'](\/[@\w].*?)["']/g,
(_match: string, p: string) => `from "${VITE_DEV_ORIGIN}${p}"`,
);
}
});
const workerPatch = document.createElement('script');
workerPatch.textContent = `(function(){
var O=globalThis.Worker;
globalThis.Worker=function(u,o){
var h=typeof u==='string'?u:u instanceof URL?u.href:'';
if(h.startsWith('${VITE_DEV_ORIGIN}')){
var b=new Blob(['import "'+h+'";'],{type:'application/javascript'});
return new O(URL.createObjectURL(b),Object.assign({},o,{type:'module'}));
}return new O(u,o)};
globalThis.Worker.prototype=O.prototype;
})();`;
const head = document.querySelector('head');
if (head?.firstChild) {
head.insertBefore(workerPatch, head.firstChild);
}
return document.toString();
}
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 {
const config: AnalyticsConfig = {};
if (analyticsEnv.ENABLE_GOOGLE_ANALYTICS && analyticsEnv.GOOGLE_ANALYTICS_MEASUREMENT_ID) {
config.google = { measurementId: analyticsEnv.GOOGLE_ANALYTICS_MEASUREMENT_ID };
}
if (analyticsEnv.ENABLED_PLAUSIBLE_ANALYTICS && analyticsEnv.PLAUSIBLE_DOMAIN) {
config.plausible = {
domain: analyticsEnv.PLAUSIBLE_DOMAIN,
scriptBaseUrl: analyticsEnv.PLAUSIBLE_SCRIPT_BASE_URL,
};
}
if (analyticsEnv.ENABLED_UMAMI_ANALYTICS && analyticsEnv.UMAMI_WEBSITE_ID) {
config.umami = {
scriptUrl: analyticsEnv.UMAMI_SCRIPT_URL,
websiteId: analyticsEnv.UMAMI_WEBSITE_ID,
};
}
if (analyticsEnv.ENABLED_CLARITY_ANALYTICS && analyticsEnv.CLARITY_PROJECT_ID) {
config.clarity = { projectId: analyticsEnv.CLARITY_PROJECT_ID };
}
if (analyticsEnv.ENABLED_POSTHOG_ANALYTICS && analyticsEnv.POSTHOG_KEY) {
config.posthog = {
debug: analyticsEnv.DEBUG_POSTHOG_ANALYTICS,
host: analyticsEnv.POSTHOG_HOST,
key: analyticsEnv.POSTHOG_KEY,
};
}
if (analyticsEnv.REACT_SCAN_MONITOR_API_KEY) {
config.reactScan = { apiKey: analyticsEnv.REACT_SCAN_MONITOR_API_KEY };
}
if (analyticsEnv.ENABLE_VERCEL_ANALYTICS) {
config.vercel = {
debug: analyticsEnv.DEBUG_VERCEL_ANALYTICS,
enabled: true,
};
}
if (
process.env.NEXT_PUBLIC_DESKTOP_PROJECT_ID &&
process.env.NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL
) {
config.desktop = {
baseUrl: process.env.NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL,
projectId: process.env.NEXT_PUBLIC_DESKTOP_PROJECT_ID,
};
}
return config;
}
function buildClientEnv(): SPAClientEnv {
return {
marketBaseUrl: appEnv.MARKET_BASE_URL,
pyodideIndexUrl: pythonEnv.NEXT_PUBLIC_PYODIDE_INDEX_URL,
pyodidePipIndexUrl: pythonEnv.NEXT_PUBLIC_PYODIDE_PIP_INDEX_URL,
s3FilePath: fileEnv.NEXT_PUBLIC_S3_FILE_PATH,
};
}
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',
},
});
}
@@ -0,0 +1,6 @@
// Auto-generated by scripts/generateSpaTemplates.mts after vite build
// Do not edit manually
export const desktopHtmlTemplate = '';
export const mobileHtmlTemplate = '';
+17 -13
View File
@@ -1,19 +1,23 @@
'use client';
import Script from 'next/script';
import { memo } from 'react';
import { memo, useEffect } from 'react';
import urlJoin from 'url-join';
const DesktopAnalytics = memo(
() =>
process.env.NEXT_PUBLIC_DESKTOP_PROJECT_ID &&
process.env.NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL && (
<Script
defer
data-website-id={process.env.NEXT_PUBLIC_DESKTOP_PROJECT_ID}
src={urlJoin(process.env.NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL, 'script.js')}
/>
),
);
const DesktopAnalytics = memo(() => {
const projectId = process.env.NEXT_PUBLIC_DESKTOP_PROJECT_ID;
const baseUrl = process.env.NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL;
useEffect(() => {
if (!projectId || !baseUrl) return;
const script = document.createElement('script');
script.src = urlJoin(baseUrl, 'script.js');
script.defer = true;
script.dataset.websiteId = projectId;
document.head.appendChild(script);
}, [projectId, baseUrl]);
return null;
});
export default DesktopAnalytics;
+23 -2
View File
@@ -1,7 +1,28 @@
import { GoogleAnalytics as GA } from '@next/third-parties/google';
'use client';
import { memo, useEffect } from 'react';
import { analyticsEnv } from '@/envs/analytics';
const GoogleAnalytics = () => <GA gaId={analyticsEnv.GOOGLE_ANALYTICS_MEASUREMENT_ID!} />;
const GoogleAnalytics = memo(() => {
const gaId = analyticsEnv.GOOGLE_ANALYTICS_MEASUREMENT_ID;
useEffect(() => {
if (!gaId) return;
const script = document.createElement('script');
script.src = `https://www.googletagmanager.com/gtag/js?id=${gaId}`;
script.async = true;
document.head.appendChild(script);
(window as any).dataLayer = (window as any).dataLayer || [];
function gtag(...args: any[]) {
(window as any).dataLayer.push(args);
}
gtag('js', new Date());
gtag('config', gaId);
}, [gaId]);
return null;
});
export default GoogleAnalytics;
@@ -0,0 +1,39 @@
import { type ReactNode } from 'react';
import { memo } from 'react';
import { LobeAnalyticsProvider } from '@/components/Analytics/LobeAnalyticsProvider';
import type { SPAServerConfig } from '@/types/spaServerConfig';
import { isDev } from '@/utils/env';
type Props = {
children: ReactNode;
};
export const LobeAnalyticsProviderWrapper = memo<Props>(({ children }) => {
const serverConfig: SPAServerConfig | undefined = window.__SERVER_CONFIG__;
const analytics = serverConfig?.analyticsConfig;
return (
<LobeAnalyticsProvider
ga4Config={{
debug: isDev,
enabled: !!analytics?.google?.measurementId,
gtagConfig: {
debug_mode: isDev,
},
measurementId: analytics?.google?.measurementId ?? '',
}}
postHogConfig={{
debug: analytics?.posthog?.debug ?? false,
enabled: !!analytics?.posthog?.key,
host: analytics?.posthog?.host ?? '',
key: analytics?.posthog?.key ?? '',
person_profiles: 'always',
}}
>
{children}
</LobeAnalyticsProvider>
);
});
LobeAnalyticsProviderWrapper.displayName = 'LobeAnalyticsProviderWrapper';
+21 -4
View File
@@ -1,11 +1,28 @@
import { Monitoring } from 'react-scan/monitoring/next';
'use client';
import { memo, useEffect } from 'react';
interface ReactScanProps {
apiKey: string;
}
const ReactScan = ({ apiKey }: ReactScanProps) => (
<Monitoring apiKey={apiKey} url="https://monitoring.react-scan.com/api/v1/ingest" />
);
const ReactScan = memo(({ apiKey }: ReactScanProps) => {
useEffect(() => {
if (!apiKey) return;
const script = document.createElement('script');
script.src = 'https://unpkg.com/react-scan/dist/auto.global.js';
script.dataset.apiKey = apiKey;
script.dataset.url = 'https://monitoring.react-scan.com/api/v1/ingest';
script.async = true;
document.head.appendChild(script);
return () => {
document.head.removeChild(script);
};
}, [apiKey]);
return null;
});
export default ReactScan;
@@ -4,7 +4,6 @@ import { HeartFilled } from '@ant-design/icons';
import { ActionIcon, Button, Flexbox } from '@lobehub/ui';
import { createStaticStyles, cssVar } from 'antd-style';
import { X } from 'lucide-react';
import Link from 'next/link';
import { type ReactNode } from 'react';
import { memo } from 'react';
@@ -76,7 +75,7 @@ const HighlightNotification = memo<HighlightNotificationProps>(
{title && <div className={styles.title}>{title}</div>}
{description && <div className={styles.description}>{description}</div>}
{actionLabel && (
<Link
<a
className={styles.action}
href={actionHref || '/'}
rel="noopener noreferrer"
@@ -86,7 +85,7 @@ const HighlightNotification = memo<HighlightNotificationProps>(
<Button block icon={HeartFilled} size="small" type="primary">
{actionLabel}
</Button>
</Link>
</a>
)}
</Flexbox>
</Flexbox>
+8
View File
@@ -0,0 +1,8 @@
import { Image } from '@lobehub/ui/mdx';
import { type FC } from 'react';
const ImageWrapper: FC<{ alt: string; src: string }> = ({ alt, src, ...rest }) => {
return <Image alt={alt} src={src} {...rest} />;
};
export default ImageWrapper;
+18 -20
View File
@@ -1,9 +1,8 @@
import { type TypographyProps } from '@lobehub/ui';
import { Typography as Typo } from '@lobehub/ui';
import { mdxComponents } from '@lobehub/ui/mdx';
import { type MDXRemoteProps } from 'next-mdx-remote/rsc';
import { MDXRemote } from 'next-mdx-remote/rsc';
import { type FC } from 'react';
import Markdown, { type Components } from 'react-markdown';
import remarkGfm from 'remark-gfm';
import CodeBlock from './CodeBlock';
@@ -29,27 +28,26 @@ export const Typography = ({
);
};
export const CustomMDX: FC<MDXRemoteProps & { mobile?: boolean }> = ({ mobile, ...rest }) => {
// ref: https://github.com/hashicorp/next-mdx-remote/issues/405
const list: any = {};
Object.entries({
...mdxComponents,
Image: Image,
a: Link,
pre: CodeBlock,
...rest.components,
}).forEach(([key, Render]: any) => {
list[key] = (props: any) => <Render {...props} />;
});
interface CustomMDXProps {
components?: Components;
mobile?: boolean;
source: string;
}
export const CustomMDX: FC<CustomMDXProps> = ({ mobile, source, components: extraComponents }) => {
const components: Components = {
...(mdxComponents as Components),
a: Link as Components['a'],
img: Image as Components['img'],
pre: CodeBlock as Components['pre'],
...extraComponents,
};
return (
<Typography mobile={mobile}>
<MDXRemote
{...rest}
components={list}
// @ts-ignore
options={{ mdxOptions: { remarkPlugins: [remarkGfm] } }}
/>
<Markdown components={components} remarkPlugins={[remarkGfm]}>
{source}
</Markdown>
</Typography>
);
};
+17
View File
@@ -0,0 +1,17 @@
import { createRoot } from 'react-dom/client';
import { BrowserRouter, Routes } from 'react-router-dom';
import SPAGlobalProvider from '@/layout/SPAGlobalProvider';
import { renderRoutes } from '@/utils/router';
import { desktopRoutes } from './app/[variants]/router/desktopRouter.config';
const App = () => (
<SPAGlobalProvider>
<BrowserRouter>
<Routes>{renderRoutes(desktopRoutes)}</Routes>
</BrowserRouter>
</SPAGlobalProvider>
);
createRoot(document.getElementById('root')!).render(<App />);
+17
View File
@@ -0,0 +1,17 @@
import { createRoot } from 'react-dom/client';
import { BrowserRouter, Routes } from 'react-router-dom';
import SPAGlobalProvider from '@/layout/SPAGlobalProvider';
import { renderRoutes } from '@/utils/router';
import { mobileRoutes } from './app/[variants]/(mobile)/router/mobileRouter.config';
const App = () => (
<SPAGlobalProvider>
<BrowserRouter>
<Routes>{renderRoutes(mobileRoutes)}</Routes>
</BrowserRouter>
</SPAGlobalProvider>
);
createRoot(document.getElementById('root')!).render(<App />);
+1 -1
View File
@@ -1,5 +1,5 @@
/* eslint-disable sort-keys-fix/sort-keys-fix */
import { createEnv } from '@t3-oss/env-nextjs';
import { createEnv } from '@t3-oss/env-core';
import { z } from 'zod';
export const getAnalyticsConfig = () => {
+2 -1
View File
@@ -1,5 +1,5 @@
/* eslint-disable sort-keys-fix/sort-keys-fix */
import { createEnv } from '@t3-oss/env-nextjs';
import { createEnv } from '@t3-oss/env-core';
import { z } from 'zod';
const isInVercel = process.env.VERCEL === '1';
@@ -36,6 +36,7 @@ const PLUGINS_INDEX_URL = 'https://registry.npmmirror.com/@lobehub/plugins-index
export const getAppConfig = () => {
return createEnv({
clientPrefix: 'NEXT_PUBLIC_',
client: {
NEXT_PUBLIC_ENABLE_SENTRY: z.boolean(),
},
+2 -1
View File
@@ -1,5 +1,5 @@
/* eslint-disable sort-keys-fix/sort-keys-fix , typescript-sort-keys/interface */
import { createEnv } from '@t3-oss/env-nextjs';
import { createEnv } from '@t3-oss/env-core';
import { z } from 'zod';
declare global {
@@ -107,6 +107,7 @@ declare global {
export const getAuthConfig = () => {
return createEnv({
clientPrefix: 'NEXT_PUBLIC_',
client: {},
server: {
AUTH_SECRET: z.string().optional(),
+1 -1
View File
@@ -1,5 +1,5 @@
/* eslint-disable sort-keys-fix/sort-keys-fix */
import { createEnv } from '@t3-oss/env-nextjs';
import { createEnv } from '@t3-oss/env-core';
import { z } from 'zod';
declare global {
+2 -1
View File
@@ -1,4 +1,4 @@
import { createEnv } from '@t3-oss/env-nextjs';
import { createEnv } from '@t3-oss/env-core';
import { z } from 'zod';
const DEFAULT_S3_FILE_PATH = 'files';
@@ -13,6 +13,7 @@ export const getFileConfig = () => {
const S3_PUBLIC_DOMAIN = process.env.S3_PUBLIC_DOMAIN || process.env.NEXT_PUBLIC_S3_DOMAIN;
return createEnv({
clientPrefix: 'NEXT_PUBLIC_',
client: {
/**
* @deprecated
+1 -1
View File
@@ -1,4 +1,4 @@
import { createEnv } from '@t3-oss/env-nextjs';
import { createEnv } from '@t3-oss/env-core';
import { z } from 'zod';
import { MAX_DEFAULT_IMAGE_NUM, MIN_DEFAULT_IMAGE_NUM } from '@/const/settings';
+1 -1
View File
@@ -1,4 +1,4 @@
import { createEnv } from '@t3-oss/env-nextjs';
import { createEnv } from '@t3-oss/env-core';
import { z } from 'zod';
export const knowledgeEnv = createEnv({
+1 -1
View File
@@ -1,5 +1,5 @@
/* eslint-disable sort-keys-fix/sort-keys-fix , typescript-sort-keys/interface */
import { createEnv } from '@t3-oss/env-nextjs';
import { createEnv } from '@t3-oss/env-core';
import { z } from 'zod';
export const getLangfuseConfig = () => {
+1 -1
View File
@@ -1,5 +1,5 @@
/* eslint-disable sort-keys-fix/sort-keys-fix , typescript-sort-keys/interface */
import { createEnv } from '@t3-oss/env-nextjs';
import { createEnv } from '@t3-oss/env-core';
import { z } from 'zod';
export const getLLMConfig = () => {
+2 -1
View File
@@ -1,8 +1,9 @@
import { createEnv } from '@t3-oss/env-nextjs';
import { createEnv } from '@t3-oss/env-core';
import { z } from 'zod';
export const getPythonConfig = () => {
return createEnv({
clientPrefix: 'NEXT_PUBLIC_',
client: {
NEXT_PUBLIC_PYODIDE_INDEX_URL: z.string().url().optional(),
NEXT_PUBLIC_PYODIDE_PIP_INDEX_URL: z.string().url().optional(),
+1 -1
View File
@@ -1,5 +1,5 @@
/* eslint-disable sort-keys-fix/sort-keys-fix */
import { createEnv } from '@t3-oss/env-nextjs';
import { createEnv } from '@t3-oss/env-core';
import { z } from 'zod';
import { type RedisConfig } from '@/libs/redis';
+1 -1
View File
@@ -1,4 +1,4 @@
import { createEnv } from '@t3-oss/env-nextjs';
import { createEnv } from '@t3-oss/env-core';
import { z } from 'zod';
export const getToolsConfig = () => {
+18
View File
@@ -0,0 +1,18 @@
import { isDesktop } from '@lobechat/const';
import { type PropsWithChildren } from 'react';
import BetterAuth from './BetterAuth';
import Desktop from './Desktop';
const AuthProvider = ({ children }: PropsWithChildren) => {
if (isDesktop) {
return <Desktop>{children}</Desktop>;
}
// In SPA/Vite mode, always use BetterAuth.
// If auth is not configured on the server, useSession() will return no session
// and the user will be treated as not signed in — same effect as NoAuth.
return <BetterAuth>{children}</BetterAuth>;
};
export default AuthProvider;
+1 -1
View File
@@ -1,10 +1,10 @@
'use client';
import { StyleProvider } from 'antd-style';
import { useServerInsertedHTML } from 'next/navigation';
import { type PropsWithChildren } from 'react';
import { isDesktop } from '@/const/version';
import { useServerInsertedHTML } from '@/libs/next/navigation';
const StyleRegistry = ({ children }: PropsWithChildren) => {
useServerInsertedHTML(() => {
+82
View File
@@ -0,0 +1,82 @@
'use client';
import { ConfigProvider } from 'antd';
import dayjs from 'dayjs';
import { type PropsWithChildren, memo, useEffect, useState } from 'react';
import { isRtlLang } from 'rtl-detect';
import { createI18nNext } from '@/locales/create';
import { getAntdLocale } from '@/utils/locale';
import Editor from '@/layout/GlobalProvider/Editor';
const dayjsLocaleLoaders = import.meta.glob<{ default: ILocale }>('/node_modules/dayjs/locale/*.js');
const updateDayjs = async (lang: string) => {
const locale = lang.toLowerCase() === 'en-us' ? 'en' : lang.toLowerCase();
const key = `/node_modules/dayjs/locale/${locale}.js`;
const loader = dayjsLocaleLoaders[key] ?? dayjsLocaleLoaders['/node_modules/dayjs/locale/en.js'];
try {
const mod = await loader!();
dayjs.locale(mod.default);
} catch {
console.warn(`dayjs locale for ${lang} not found, fallback to en`);
const fallback = await dayjsLocaleLoaders['/node_modules/dayjs/locale/en.js']!();
dayjs.locale(fallback.default);
}
};
interface LocaleLayoutProps extends PropsWithChildren {
antdLocale?: any;
defaultLang?: string;
}
const Locale = memo<LocaleLayoutProps>(({ children, defaultLang, antdLocale }) => {
const [i18n] = useState(() => createI18nNext(defaultLang));
const [lang, setLang] = useState(defaultLang);
const [locale, setLocale] = useState(antdLocale);
if (!i18n.instance.isInitialized)
i18n.init().then(async () => {
if (!lang) return;
await updateDayjs(lang);
});
useEffect(() => {
const handleLang = async (lng: string) => {
setLang(lng);
if (lang === lng) return;
const newLocale = await getAntdLocale(lng);
setLocale(newLocale);
await updateDayjs(lng);
};
i18n.instance.on('languageChanged', handleLang);
return () => {
i18n.instance.off('languageChanged', handleLang);
};
}, [i18n, lang]);
const documentDir = isRtlLang(lang!) ? 'rtl' : 'ltr';
return (
<ConfigProvider
direction={documentDir}
locale={locale}
theme={{
components: {
Button: {
contentFontSizeSM: 12,
},
},
}}
>
<Editor>{children}</Editor>
</ConfigProvider>
);
});
Locale.displayName = 'Locale';
export default Locale;
+82
View File
@@ -0,0 +1,82 @@
'use client';
import { ENABLE_BUSINESS_FEATURES } from '@lobechat/business-const';
import { ContextMenuHost, ModalHost, ToastHost, TooltipGroup } from '@lobehub/ui';
import { domMax, LazyMotion } from 'motion/react';
import { type PropsWithChildren } from 'react';
import { memo, Suspense } from 'react';
import { ReferralProvider } from '@/business/client/ReferralProvider';
import { LobeAnalyticsProviderWrapper } from '@/components/Analytics/LobeAnalyticsProviderWrapper';
import { DragUploadProvider } from '@/components/DragUploadZone/DragUploadProvider';
import { isDesktop } from '@/const/version';
// DevPanel uses node:fs (getCacheEntries) which is not available in browser/Vite
// import DevPanel from '@/features/DevPanel';
import AuthProvider from '@/layout/AuthProvider';
import AppTheme from '@/layout/GlobalProvider/AppTheme';
import { FaviconProvider } from '@/layout/GlobalProvider/FaviconProvider';
import { GroupWizardProvider } from '@/layout/GlobalProvider/GroupWizardProvider';
import ImportSettings from '@/layout/GlobalProvider/ImportSettings';
import Locale from './Locale';
import NextThemeProvider from '@/layout/GlobalProvider/NextThemeProvider';
import QueryProvider from '@/layout/GlobalProvider/Query';
import ServerVersionOutdatedAlert from '@/layout/GlobalProvider/ServerVersionOutdatedAlert';
import StoreInitialization from '@/layout/GlobalProvider/StoreInitialization';
import StyleRegistry from '@/layout/GlobalProvider/StyleRegistry';
import { ServerConfigStoreProvider } from '@/store/serverConfig/Provider';
import type { SPAServerConfig } from '@/types/spaServerConfig';
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>
<ServerConfigStoreProvider
featureFlags={serverConfig?.featureFlags}
isMobile={isMobile}
serverConfig={serverConfig?.config}
>
<QueryProvider>
<AuthProvider>
<StoreInitialization />
{isDesktop && <ServerVersionOutdatedAlert />}
<FaviconProvider>
<GroupWizardProvider>
<DragUploadProvider>
<LazyMotion features={domMax}>
<TooltipGroup layoutAnimation={false}>
<LobeAnalyticsProviderWrapper>{children}</LobeAnalyticsProviderWrapper>
</TooltipGroup>
<ModalHost />
<ToastHost />
<ContextMenuHost />
</LazyMotion>
</DragUploadProvider>
</GroupWizardProvider>
</FaviconProvider>
</AuthProvider>
</QueryProvider>
<Suspense>
{ENABLE_BUSINESS_FEATURES ? <ReferralProvider /> : null}
<ImportSettings />
{/* DevPanel disabled in SPA: depends on node:fs */}
</Suspense>
</ServerConfigStoreProvider>
</AppTheme>
</NextThemeProvider>
</Locale>
</StyleRegistry>
);
});
SPAGlobalProvider.displayName = 'SPAGlobalProvider';
export default SPAGlobalProvider;
+58
View File
@@ -0,0 +1,58 @@
import { normalizeLocale } from '@/locales/resources';
type UILocaleResources = Record<string, Record<string, string>>;
const uiLocaleLoaders = import.meta.glob<{ default: UILocaleResources }>('/locales/*/ui.json');
const getUILocale = (locale: string): string => {
if (locale.startsWith('zh')) return 'zh-CN';
if (locale.startsWith('en')) return 'en-US';
return locale;
};
const loadBusinessResources = async (locale: string): Promise<UILocaleResources | null> => {
const key = `/locales/${locale}/ui.json`;
const loader = uiLocaleLoaders[key];
if (!loader) return null;
try {
const mod = await loader();
return mod.default as UILocaleResources;
} catch {
return null;
}
};
const loadLobeUIBuiltinResources = async (locale: string): Promise<UILocaleResources | null> => {
try {
const { en, zhCn } = await import('@lobehub/ui/es/i18n/resources/index');
if (locale.startsWith('zh')) return zhCn as UILocaleResources;
return en as UILocaleResources;
} catch {
return null;
}
};
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 =
(await loadBusinessResources(normalizedLocale)) ??
(await loadLobeUIBuiltinResources(normalizedLocale)) ??
(await loadBusinessResources('en-US')) ??
(await loadLobeUIBuiltinResources('en-US'));
if (!resources)
throw new Error(
`Failed to load UI resources (business + @lobehub/ui builtin) for locale=${normalizedLocale}`,
);
return {
locale: uiLocale,
resources,
};
};
+41 -7
View File
@@ -1,11 +1,45 @@
/**
* Image component wrapper for Next.js Image.
* This module provides a unified interface that can be easily replaced
* with a generic <img> or custom image component in the future.
* Image adapter — replaces next/image with a plain <img> element.
* Preserves the most common Next.js Image props for API compat.
*/
// Re-export the Image component
import { type CSSProperties, type ImgHTMLAttributes, forwardRef } from 'react';
// Re-export types
export type { ImageProps, StaticImageData } from 'next/image';
export { default } from 'next/image';
export interface StaticImageData {
blurDataURL?: string;
height: number;
src: string;
width: number;
}
export interface ImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, 'src'> {
fill?: boolean;
priority?: boolean;
quality?: number;
sizes?: string;
src: string | StaticImageData;
unoptimized?: boolean;
}
const Image = forwardRef<HTMLImageElement, ImageProps>(
({ src, fill, priority, quality, sizes, unoptimized, style, ...rest }, ref) => {
const resolvedSrc = typeof src === 'string' ? src : src.src;
const fillStyle: CSSProperties | undefined = fill
? { height: '100%', left: 0, objectFit: 'cover', position: 'absolute', top: 0, width: '100%' }
: undefined;
return (
<img
ref={ref}
src={resolvedSrc}
style={{ ...fillStyle, ...style }}
{...rest}
/>
);
},
);
Image.displayName = 'Image';
export default Image;
+33 -7
View File
@@ -1,11 +1,37 @@
/**
* Link component wrapper for Next.js Link.
* This module provides a unified interface that can be easily replaced
* with react-router-dom Link in the future.
* Link adapter — maps Next.js Link API (href prop) to react-router-dom Link (to prop).
* External URLs (http/https) are rendered as plain <a> tags.
*/
// Re-export the Link component
import { type AnchorHTMLAttributes, forwardRef } from 'react';
import { Link as RRLink } from 'react-router-dom';
// Re-export the type for props
export type { LinkProps } from 'next/link';
export { default } from 'next/link';
export interface LinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'href'> {
href: string;
prefetch?: boolean;
replace?: boolean;
scroll?: boolean;
}
const Link = forwardRef<HTMLAnchorElement, LinkProps>(
({ href, replace, prefetch, scroll, children, ...rest }, ref) => {
// External links → plain <a>
if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('//')) {
return (
<a ref={ref} href={href} {...rest}>
{children}
</a>
);
}
return (
<RRLink ref={ref} replace={replace} to={href} {...rest}>
{children}
</RRLink>
);
},
);
Link.displayName = 'Link';
export default Link;
+1 -10
View File
@@ -1,5 +1,4 @@
import analyzer from '@next/bundle-analyzer';
import withSerwistInit from '@serwist/next';
import { codeInspectorPlugin } from 'code-inspector-plugin';
import { type NextConfig } from 'next';
import { type Header, type Redirect } from 'next/dist/lib/load-custom-routes';
@@ -432,13 +431,5 @@ export function defineConfig(config: CustomNextConfig) {
const withBundleAnalyzer = process.env.ANALYZE === 'true' ? analyzer() : noWrapper;
const withPWA = isProd
? withSerwistInit({
register: false,
swDest: 'public/sw.js',
swSrc: 'src/app/sw.ts',
})
: noWrapper;
return withBundleAnalyzer(withPWA(nextConfig as NextConfig));
return withBundleAnalyzer(nextConfig as NextConfig);
}
+41 -8
View File
@@ -1,13 +1,46 @@
/**
* Dynamic import wrapper for Next.js dynamic.
* This module provides a unified interface that can be easily replaced
* with React.lazy + Suspense in the future.
* dynamic() adapter replaces next/dynamic with React.lazy + Suspense.
*
* @see Phase 3.3
* Keeps the same call-site API:
* const Comp = dynamic(() => import('./Foo'), { loading: () => <Spinner />, ssr: false });
*/
// Re-export the dynamic function
import { type ComponentType, type ReactNode, Suspense, lazy } from 'react';
// Re-export types
export type { DynamicOptions, Loader, LoaderComponent } from 'next/dynamic';
export { default } from 'next/dynamic';
export interface DynamicOptions<P = NonNullable<unknown>> {
loading?: () => ReactNode;
ssr?: boolean;
}
export type Loader<P = NonNullable<unknown>> = () => Promise<
{ default: ComponentType<P> } | ComponentType<P>
>;
export type LoaderComponent<P = NonNullable<unknown>> = ComponentType<P>;
function dynamic<P = NonNullable<unknown>>(
loader: Loader<P>,
options?: DynamicOptions<P>,
): ComponentType<P> {
const LazyComponent = lazy(async () => {
const mod = await loader();
if (typeof mod === 'function') {
return { default: mod as ComponentType<P> };
}
if ('default' in mod) {
return mod as { default: ComponentType<P> };
}
return { default: mod as unknown as ComponentType<P> };
});
const DynamicWrapper = (props: P & Record<string, unknown>) => (
<Suspense fallback={options?.loading?.() ?? null}>
{/* @ts-ignore */}
<LazyComponent {...props} />
</Suspense>
);
return DynamicWrapper as ComponentType<P>;
}
export default dynamic;
+4 -12
View File
@@ -1,22 +1,14 @@
/**
* Next.js wrapper module
* Next.js wrapper module SPA implementation
*
* This module provides unified interfaces for Next.js-specific APIs,
* making it easier to migrate from Next.js to other frameworks (e.g., Vite + React Router).
*
* Usage:
* - import { useRouter, usePathname } from '@/libs/next/navigation';
* - import Link from '@/libs/next/Link';
* - import dynamic from '@/libs/next/dynamic';
* - import Image from '@/libs/next/Image';
*
* @see RFC 147
* Provides unified interfaces that map to react-router-dom / vanilla React
* so that consumer code does not need framework-specific imports.
*/
// Navigation exports
export * from './navigation';
// Component exports (re-export as named for convenience)
// Component exports
export { default as dynamic } from './dynamic';
export { default as Image } from './Image';
export { default as Link } from './Link';
+5 -10
View File
@@ -1,22 +1,17 @@
/**
* Navigation utilities wrapper for Next.js navigation APIs.
* This module provides a unified interface that can be easily replaced
* with react-router-dom in the future.
*
* @see Phase 3.1
* Navigation utilities re-exports from next/navigation.
* In Vite/SPA mode, this file is replaced by navigation.vite.ts via the
* viteModuleRedirect plugin (see vite.config.ts).
*/
// Re-export all navigation hooks and utilities from Next.js
export {
notFound,
ReadonlyURLSearchParams,
redirect,
type ReadonlyURLSearchParams,
useParams,
usePathname,
useRouter,
useSearchParams,
useServerInsertedHTML,
} from 'next/navigation';
// Re-export types
export type { RedirectType } from 'next/navigation';
export type RedirectType = 'push' | 'replace';
+75
View File
@@ -0,0 +1,75 @@
/**
* Navigation utilities - SPA implementation via react-router-dom.
*
* Provides the same API surface as the previous Next.js navigation wrapper
* so that existing consumer code does not need to change.
*/
import {
useLocation,
useNavigate,
useParams,
useSearchParams,
} from 'react-router-dom';
// ---------------------------------------------------------------------------
// useRouter — compat wrapper around useNavigate
// ---------------------------------------------------------------------------
export function useRouter() {
const navigate = useNavigate();
return {
back: () => navigate(-1),
forward: () => navigate(1),
push: (href: string) => navigate(href),
refresh: () => navigate(0),
replace: (href: string) => navigate(href, { replace: true }),
};
}
// ---------------------------------------------------------------------------
// usePathname
// ---------------------------------------------------------------------------
export function usePathname(): string {
return useLocation().pathname;
}
// ---------------------------------------------------------------------------
// Re-exports that have the same shape in react-router-dom
// ---------------------------------------------------------------------------
export { useParams, useSearchParams };
// ---------------------------------------------------------------------------
// redirect — imperative navigation (works only inside components / loaders)
// For non-component contexts, callers should throw a Response or use navigate.
// ---------------------------------------------------------------------------
export function redirect(url: string): never {
throw new RedirectError(url);
}
class RedirectError extends Error {
url: string;
constructor(url: string) {
super(`Redirect to ${url}`);
this.url = url;
}
}
// ---------------------------------------------------------------------------
// notFound — throw to be caught by an ErrorBoundary
// ---------------------------------------------------------------------------
export function notFound(): never {
throw new NotFoundError();
}
class NotFoundError extends Error {
digest = 'NEXT_NOT_FOUND';
constructor() {
super('Not Found');
}
}
// ---------------------------------------------------------------------------
// Types kept for backward compat
// ---------------------------------------------------------------------------
export type RedirectType = 'push' | 'replace';
export type ReadonlyURLSearchParams = URLSearchParams;
+57 -32
View File
@@ -19,6 +19,19 @@ import { createRouteMatcher } from './createRouteMatcher';
const logDefault = debug('middleware:default');
const logBetterAuth = debug('middleware:better-auth');
// Auth/Next.js routes that must NOT go to SPA catch-all
const nextjsOnlyRoutes = [
'/signin',
'/signup',
'/auth-error',
'/reset-password',
'/verify-email',
'/oauth',
'/market-auth-callback',
'/discover',
'/welcome',
];
export function defineConfig() {
const backendApiEndpoints = ['/api', '/trpc', '/webapi', '/oidc'];
@@ -83,36 +96,41 @@ export function defineConfig() {
url.port = process.env.PORT || '3210';
}
// refs: https://github.com/lobehub/lobe-chat/pull/5866
// new handle segment rewrite: /${route}${originalPathname}
// / -> /zh-CN__0
// /discover -> /zh-CN__0/discover
// All SPA routes that use react-router-dom should be rewritten to just /${route}
const spaRoutes = [
'/chat',
'/agent',
'/group',
'/community',
'/resource',
'/page',
'/settings',
'/image',
'/labs',
'/changelog',
'/profile',
'/me',
'/desktop-onboarding',
'/onboarding',
'/share',
];
const isSpaRoute = spaRoutes.some((route) => url.pathname.startsWith(route));
let nextPathname: string;
if (isSpaRoute) {
nextPathname = `/${route}`;
} else {
nextPathname = `/${route}` + (url.pathname === '/' ? '' : url.pathname);
// Direct access to /spa/ pre-rendered pages — pass through
if (url.pathname.startsWith('/spa/')) {
return NextResponse.next();
}
const isNextjsRoute = nextjsOnlyRoutes.some((r) => url.pathname.startsWith(r));
// SPA routes: rewrite to /spa/[locale]/[...path] catch-all
if (!isNextjsRoute) {
const spaPath = `/spa/${locale}${url.pathname === '/' ? '' : url.pathname}`;
logDefault('SPA route, rewriting to: %s', spaPath);
url.pathname = spaPath;
const response = NextResponse.rewrite(url);
// If locale explicitly provided via query (?hl=), persist it in cookie
if (explicitlyLocale) {
const existingLocale = request.cookies.get(LOBE_LOCALE_COOKIE)?.value as
| Locales
| undefined;
if (!existingLocale) {
response.cookies.set(LOBE_LOCALE_COOKIE, explicitlyLocale, {
maxAge: 60 * 60 * 24 * 90,
path: '/',
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
});
}
}
return response;
}
// Next.js App Router routes: rewrite with variants prefix
const nextPathname = `/${route}` + (url.pathname === '/' ? '' : url.pathname);
const nextURL = appEnv.MIDDLEWARE_REWRITE_THROUGH_LOCAL
? urlJoin(url.origin, nextPathname)
: nextPathname;
@@ -121,8 +139,8 @@ export function defineConfig() {
logDefault('URL rewrite: %O', {
isLocalRewrite: appEnv.MIDDLEWARE_REWRITE_THROUGH_LOCAL,
nextPathname: nextPathname,
nextURL: nextURL,
nextPathname,
nextURL,
originalPathname: url.pathname,
});
@@ -184,7 +202,6 @@ export function defineConfig() {
'/market-auth-callback',
// public share pages
'/share(.*)',
]);
const betterAuthMiddleware = async (req: NextRequest) => {
@@ -192,6 +209,14 @@ export function defineConfig() {
const response = defaultMiddleware(req);
// SPA routes are all public (HTML contains no sensitive data, auth is handled client-side)
const reqPath = new URL(req.url).pathname;
const isSpaRoute =
reqPath.startsWith('/spa/') ||
(!nextjsOnlyRoutes.some((r) => reqPath.startsWith(r)) &&
!backendApiEndpoints.some((r) => reqPath.startsWith(r)));
if (isSpaRoute) return response;
// when enable auth protection, only public route is not protected, others are all protected
const isProtected = !isPublicRoute(req);
+1 -1
View File
@@ -8,7 +8,7 @@ import { marketSDK, marketUserInfo, serverDatabase } from '@/libs/trpc/lambda/mi
import { type TrustedClientUserInfo } from '@/libs/trusted-client';
import { generateTrustedClientToken } from '@/libs/trusted-client';
const MARKET_BASE_URL = process.env.NEXT_PUBLIC_MARKET_BASE_URL || 'https://market.lobehub.com';
const MARKET_BASE_URL = process.env.MARKET_BASE_URL || 'https://market.lobehub.com';
interface MarketUserInfo {
accountId: number;
@@ -8,7 +8,7 @@ import { marketSDK, marketUserInfo, serverDatabase } from '@/libs/trpc/lambda/mi
import { type TrustedClientUserInfo } from '@/libs/trusted-client';
import { generateTrustedClientToken } from '@/libs/trusted-client';
const MARKET_BASE_URL = process.env.NEXT_PUBLIC_MARKET_BASE_URL || 'https://market.lobehub.com';
const MARKET_BASE_URL = process.env.MARKET_BASE_URL || 'https://market.lobehub.com';
interface MarketUserInfo {
accountId: number;
+1 -1
View File
@@ -94,7 +94,7 @@ export class DiscoverService {
log(
'DiscoverService initialized with market baseURL: %s, hasAuth: %s, userId: %s',
process.env.NEXT_PUBLIC_MARKET_BASE_URL,
process.env.MARKET_BASE_URL,
!!(accessToken || userInfo),
userInfo?.userId,
);
+1 -1
View File
@@ -8,7 +8,7 @@ import { generateTrustedClientToken, getTrustedClientTokenForSession } from '@/l
const log = debug('lobe-server:market-service');
const MARKET_BASE_URL = process.env.NEXT_PUBLIC_MARKET_BASE_URL || 'https://market.lobehub.com';
const MARKET_BASE_URL = process.env.MARKET_BASE_URL || 'https://market.lobehub.com';
// ============================== Helper Functions ==============================
+17
View File
@@ -0,0 +1,17 @@
/**
* Safely serialize a JS value into a string that can be embedded inside
* an HTML `<script>` tag as a JSON expression.
*
* Escapes `</script>`, `<!--`, and `<![CDATA[` patterns so the output
* cannot break out of the script context.
*/
export function serializeForHtml(value: unknown): string {
const json = JSON.stringify(value);
// https://html.spec.whatwg.org/multipage/scripting.html#restrictions-for-contents-of-script-elements
return json
.replaceAll('<', '\\u003c')
.replaceAll('>', '\\u003e')
.replaceAll('&', '\\u0026')
.replaceAll("'", '\\u0027');
}
+4 -2
View File
@@ -1,6 +1,8 @@
import { PythonInterpreter } from '@lobechat/python-interpreter';
import { type CodeInterpreterResponse } from '@lobechat/types';
import { pythonEnv } from '@/envs/python';
class PythonService {
async runPython(
code: string,
@@ -9,8 +11,8 @@ class PythonService {
): Promise<CodeInterpreterResponse | undefined> {
if (typeof Worker === 'undefined') return;
const interpreter = await new PythonInterpreter!({
pyodideIndexUrl: process.env.NEXT_PUBLIC_PYODIDE_INDEX_URL!,
pypiIndexUrl: process.env.NEXT_PUBLIC_PYPI_INDEX_URL!,
pyodideIndexUrl: pythonEnv.NEXT_PUBLIC_PYODIDE_INDEX_URL!,
pypiIndexUrl: pythonEnv.NEXT_PUBLIC_PYODIDE_PIP_INDEX_URL!,
});
await interpreter.init();
await interpreter.installPackages(packages.filter((p) => p !== ''));
+4
View File
@@ -15,9 +15,13 @@ declare module 'styled-components' {
declare global {
interface Window {
__SERVER_CONFIG__: import('./spaServerConfig').SPAServerConfig | undefined;
lobeEnv?: {
darwinMajorVersion?: number;
isMacTahoe?: boolean;
};
}
/** Vite define: current bundle is mobile variant */
const __MOBILE__: boolean;
}
+28
View File
@@ -0,0 +1,28 @@
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;
}
@@ -0,0 +1,56 @@
import type {
LoadI18nNamespaceModuleParams,
LoadI18nNamespaceModuleWithFallbackParams,
} from './loadI18nNamespaceModule';
// Use import.meta.glob so Vite can statically analyze and avoid CJS/dynamic import issues
const defaultLoaders = import.meta.glob<{ default: Record<string, string> }>(
'/src/locales/default/*.ts',
);
const localeLoaders = import.meta.glob<{ default: Record<string, string> }>('/locales/*/*.json');
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 key = getDefaultKey(ns);
const load = defaultLoaders[key];
if (!load) throw new Error(`Missing default namespace: ${ns}`);
return load() as Promise<{ default: Record<string, string> }>;
}
const normalizedLng = normalizeLocale(lng);
const localeKey = getLocaleKey(normalizedLng, ns);
const loadLocale = localeLoaders[localeKey];
if (loadLocale) {
return loadLocale() as Promise<{ default: Record<string, string> }>;
}
const loadDefault = defaultLoaders[getDefaultKey(ns)];
if (!loadDefault) throw new Error(`Missing default namespace: ${ns}`);
return loadDefault() as Promise<{ default: Record<string, string> }>;
};
export type {
LoadI18nNamespaceModuleParams,
LoadI18nNamespaceModuleWithFallbackParams,
} from './loadI18nNamespaceModule';
export const loadI18nNamespaceModuleWithFallback = async (
params: LoadI18nNamespaceModuleWithFallbackParams,
): Promise<{ default: Record<string, string> }> => {
const { onFallback, ...rest } = params;
try {
return await loadI18nNamespaceModule(rest);
} catch (error) {
onFallback?.({ error, lng: rest.lng, ns: rest.ns });
const loadDefault = defaultLoaders[getDefaultKey(rest.ns)];
if (!loadDefault) throw error;
return loadDefault() as Promise<{ default: Record<string, string> }>;
}
};
+22
View File
@@ -0,0 +1,22 @@
import { normalizeLocale } from '@/locales/resources';
// Use antd ESM locale (es/locale) - CJS locale (locale/*.js) uses module.exports and breaks in Vite
const antdLocaleLoaders = import.meta.glob('/node_modules/antd/es/locale/*.js');
export const getAntdLocale = async (lang?: string) => {
let normalLang: any = normalizeLocale(lang);
// due to antd only have ar-EG locale, we need to convert ar to ar-EG
// refs: https://ant.design/docs/react/i18n
if (normalLang === 'ar') normalLang = 'ar-EG';
const localePath = `/node_modules/antd/es/locale/${normalLang.replace('-', '_')}.js`;
const loadLocale = antdLocaleLoaders[localePath];
if (!loadLocale) {
throw new Error(`Unsupported antd locale: ${normalLang}`);
}
const { default: locale } = await loadLocale();
return locale;
};
+3
View File
@@ -0,0 +1,3 @@
import 'vite/client';
export {};
+14
View File
@@ -0,0 +1,14 @@
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"dev:next": {
"cache": false,
"persistent": true
},
"dev:spa": {
"cache": false,
"persistent": true
}
},
"ui": "tui"
}
+64
View File
@@ -0,0 +1,64 @@
import { resolve } from 'node:path';
import react from '@vitejs/plugin-react';
import { defineConfig, type Plugin } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';
const isMobile = process.env.MOBILE === 'true';
const isDev = process.env.NODE_ENV !== 'production';
const root = resolve(__dirname);
const viteModuleRedirects: [string, string][] = [
['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'],
['src/components/mdx/Image.tsx', 'src/components/mdx/Image.vite.tsx'],
['src/layout/AuthProvider/index.tsx', 'src/layout/AuthProvider/index.vite.tsx'],
['src/components/Analytics/LobeAnalyticsProviderWrapper.tsx', 'src/components/Analytics/LobeAnalyticsProviderWrapper.vite.tsx'],
['src/libs/next/navigation.ts', 'src/libs/next/navigation.vite.ts'],
].map(([from, to]) => [resolve(root, from), resolve(root, to)]);
function viteModuleRedirect(): Plugin {
return {
enforce: 'pre',
name: 'vite-module-redirect',
async resolveId(source, importer, options) {
if (source.includes('.vite')) return null;
const resolved = await this.resolve(source, importer, { ...options, skipSelf: true });
if (!resolved) return null;
const cleanId = resolved.id.split('?')[0];
for (const [from, to] of viteModuleRedirects) {
if (cleanId === from) return to;
}
return null;
},
};
}
export default defineConfig({
base: isDev ? '/' : '/spa/',
build: {
outDir: isMobile ? 'dist/mobile' : 'dist/desktop',
rollupOptions: {
input: resolve(__dirname, 'index.html'),
},
},
define: {
'__MOBILE__': JSON.stringify(isMobile),
'process.env.NEXT_PUBLIC_IS_DESKTOP_APP': JSON.stringify('0'),
},
plugins: [viteModuleRedirect(), tsconfigPaths(), react({ jsxImportSource: '@emotion/react' })],
server: {
port: 3011,
proxy: {
'/api': 'http://localhost:3010',
'/oidc': 'http://localhost:3010',
'/trpc': 'http://localhost:3010',
'/webapi': 'http://localhost:3010',
},
},
});