mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 03:30:19 +00:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cdd8ec403e | |||
| 8fbb592351 | |||
| e20a5ab2ec | |||
| a55a0be86a | |||
| 7839976866 | |||
| b32c08c261 | |||
| 12297ad3a5 | |||
| 6479b395eb | |||
| 3054e92584 | |||
| ad70ad24fe | |||
| 82a98e8042 | |||
| 27d085c374 | |||
| d1bee6488a | |||
| 3f41cf94d6 | |||
| 9a5410ebea | |||
| da2ea9101d | |||
| 9996f29a79 | |||
| 9f5ea16a7f | |||
| 12450861ba | |||
| 68696fc288 | |||
| cd83e068f7 | |||
| f2b5539246 | |||
| 389feb84cb | |||
| 85e4d55bd5 | |||
| 90521e0b99 | |||
| c4e7aae2ee | |||
| b8610dc49d |
+4
-1
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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';
|
||||
|
||||
@@ -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)}
|
||||
|
||||
+2
-3
@@ -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,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';
|
||||
|
||||
@@ -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 page(catch-all 排除此路径)
|
||||
- **Desktop Electron**:本次仅迁移 web 端,desktop 构建流程暂不动,后续单独 PR 适配
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: 环境变量整治(前置,不改架构)
|
||||
|
||||
**目标**:统一散落的 `NEXT_PUBLIC_*` 引用,修复已知 bug,为后续迁移扫清障碍。
|
||||
|
||||
### 1.1 修复 Pyodide 变量名不一致 bug
|
||||
|
||||
- `src/envs/python.ts:8` schema 定义 `NEXT_PUBLIC_PYODIDE_PIP_INDEX_URL`
|
||||
- `src/services/python.ts:13` 实际读取 `NEXT_PUBLIC_PYPI_INDEX_URL`
|
||||
- **操作**:统一为 `NEXT_PUBLIC_PYODIDE_PIP_INDEX_URL`(与 schema 一致),`src/services/python.ts` 改为读取 `pythonEnv.NEXT_PUBLIC_PYODIDE_PIP_INDEX_URL`
|
||||
|
||||
### 1.2 收敛散落的直接 `process.env.NEXT_PUBLIC_*` 引用
|
||||
|
||||
将散落引用改为经 `src/envs/` 读取,便于后续统一替换:
|
||||
|
||||
| 文件 | 当前 | 改为 |
|
||||
| --------------------------------------------------------------- | ------------------------------------------- | ------------------------------------------------- |
|
||||
| `src/services/python.ts:12-13` | `process.env.NEXT_PUBLIC_PYODIDE_INDEX_URL` | `pythonEnv.NEXT_PUBLIC_PYODIDE_INDEX_URL` |
|
||||
| `src/layout/AuthProvider/MarketAuth/MarketAuthProvider.tsx:169` | `process.env.NEXT_PUBLIC_MARKET_BASE_URL` | `appEnv.MARKET_BASE_URL`(新增到 appEnv) |
|
||||
| `src/components/Analytics/Desktop.tsx:9-14` | `process.env.NEXT_PUBLIC_DESKTOP_*` | 新增 `desktopAnalyticsEnv`,或合入 `analyticsEnv` |
|
||||
| `packages/const/src/version.ts:7` | `process.env.NEXT_PUBLIC_IS_DESKTOP_APP` | 保持(构建时常量,后续改 `VITE_*`) |
|
||||
| `packages/builtin-tool-group-management/src/const.ts:1` | 同上 | 同上 |
|
||||
| `packages/builtin-tool-gtd/src/const.ts:1` | 同上 | 同上 |
|
||||
|
||||
### 1.3 服务端 `NEXT_PUBLIC_MARKET_BASE_URL` 去前缀
|
||||
|
||||
5 个服务端文件直接读 `process.env.NEXT_PUBLIC_MARKET_BASE_URL`,改为 `MARKET_BASE_URL`:
|
||||
|
||||
- `src/server/services/market/index.ts:11`
|
||||
- `src/server/routers/lambda/market/agent.ts:11`
|
||||
- `src/server/routers/lambda/market/agentGroup.ts:11`
|
||||
- `src/app/(backend)/market/oidc/[[...segments]]/route.ts:7`
|
||||
- `src/server/services/discover/index.ts:97`
|
||||
|
||||
**验证**:`bun run type-check` 通过;现有功能不受影响。
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Vite 工程搭建
|
||||
|
||||
**目标**:建立 Vite SPA 工程入口,能在本地启动并看到基础页面壳。
|
||||
|
||||
### 2.1 工程结构(直接在 src 中修改,不创建 web-spa app)
|
||||
|
||||
在项目根目录新增 Vite 相关文件,复用现有 `src/`:
|
||||
|
||||
```
|
||||
lobe-chat/
|
||||
├── vite.config.ts # Vite 配置(两次 build:desktop + mobile)
|
||||
├── index.html # SPA 入口 HTML 模板
|
||||
├── src/
|
||||
│ ├── entry.desktop.tsx # Desktop SPA 入口
|
||||
│ └── entry.mobile.tsx # Mobile SPA 入口
|
||||
└── ...(现有 src/ 结构不变)
|
||||
```
|
||||
|
||||
### 2.2 Vite 配置要点(两次独立 build)
|
||||
|
||||
Desktop 和 Mobile 分别执行一次 `vite build`,通过 `define` 注入 `__MOBILE__` 常量,产出两个独立 bundle:
|
||||
|
||||
```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` | 自定义 throw(SPA 内由 ErrorBoundary 捕获) |
|
||||
|
||||
**关键文件**:`src/libs/next/navigation.ts`
|
||||
|
||||
> **注意**:`(auth)` 页面仍运行在 Next.js 环境中,不能使用 `src/libs/next/` wrapper(替换后指向 react-router-dom)。需将 `(auth)` 下引用 `@/libs/next/navigation` 的地方改为直接 `import { useRouter, usePathname, ... } from 'next/navigation'`,确保 auth 页面在 Next.js 中正常工作。
|
||||
|
||||
### 4.2 `Link.tsx`
|
||||
|
||||
替换为 `react-router-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 Component,SPA 无法使用。新建纯客户端版本:
|
||||
|
||||
```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 正确初始化 serverConfig;user session 正常拉取。
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Next.js Catch-All Route 实现
|
||||
|
||||
**目标**:Next.js 后端提供 catch-all route,读取 Vite 产出的 HTML 模板并注入运行时数据。
|
||||
|
||||
### 6.1 Route Handler
|
||||
|
||||
参考实现:`catch-all.eg.ts`
|
||||
|
||||
```
|
||||
src/app/(spa)/[...path]/route.ts # catch-all,优先级低于 (backend)/*
|
||||
```
|
||||
|
||||
**核心逻辑(dev /prod 分离)**:
|
||||
|
||||
- **prod**:读取 Vite 构建产物的 HTML string template(`dist/desktop/index.html` / `dist/mobile/index.html`),进行字符串替换后返回
|
||||
- **dev**:代理 Vite dev server(`fetch(VITE_DEV_ORIGIN)`),获取 HTML 后 rewrite 资源 URL 指向 Vite dev server origin(处理 script src、link href、inline module scripts)。**特别注意 Worker 跨域问题**:dev 模式下 Next.js 与 Vite dev server 不同源,`new Worker()` 无法直接加载跨域脚本,需注入 workerPatch(将跨域 URL 包装为 blob URL),参考 `catch-all.eg.ts` 中的实现
|
||||
|
||||
```ts
|
||||
// 伪代码
|
||||
async function getTemplate(isMobile: boolean): Promise<string> {
|
||||
if (isDev) {
|
||||
// 代理 Vite dev server HTML,rewrite 资源 URL
|
||||
const res = await fetch(VITE_DEV_ORIGIN);
|
||||
const html = await res.text();
|
||||
return rewriteViteAssetUrls(html);
|
||||
}
|
||||
// prod:读取预构建的 string template
|
||||
return isMobile ? mobileHtmlTemplate : desktopHtmlTemplate;
|
||||
}
|
||||
```
|
||||
|
||||
完整流程:
|
||||
|
||||
1. 读取 UA → 选 desktop /mobile template
|
||||
2. 读取 cookie/headers → 解析 locale
|
||||
3. 调用 `getServerGlobalConfig()` → 构建 `SPAServerConfig`
|
||||
4. 安全序列化 + 正则替换 `window.__SERVER_CONFIG__` 占位
|
||||
5. `new Response(html, { headers })`
|
||||
|
||||
### 6.2 安全序列化
|
||||
|
||||
已有实现 `src/server/utils/serializeForHtml.ts`,直接复用。
|
||||
|
||||
### 6.3 缓存策略
|
||||
|
||||
```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 页面全量 public(HTML 本身无敏感数据)
|
||||
- 登录态检查由 SPA 内部 route guard 负责(SPAGlobalProvider 中的 AuthProvider/BetterAuthProvider)
|
||||
- `/api/*`、`/trpc/*`、`/oidc/*` 的鉴权保持不变
|
||||
- Auth 页面继续由 Next.js middleware 保护
|
||||
|
||||
**关键文件**:
|
||||
|
||||
- `src/app/[variants]/(auth)/` — 不动,保持 Next.js
|
||||
- `src/libs/next/proxy/define-config.ts` — 排除 auth 路径
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: 第三方依赖迁移
|
||||
|
||||
| 依赖 | 用途 | 文件 | 迁移 |
|
||||
| ----------------------------- | --------------------- | ------------------------------ | ----------------------------------------------------- |
|
||||
| `nuqs/adapters/next/app` | Auth 页面 query state | `(auth)/layout.tsx` | Phase 7 已移除 |
|
||||
| `@vercel/speed-insights/next` | Vercel 性能监控 | `[variants]/layout.tsx` | 改用 `@vercel/speed-insights` 的 vanilla 版本,或移除 |
|
||||
| `next-mdx-remote/rsc` | MDX 渲染 | `src/components/mdx/index.tsx` | 改用 `@mdx-js/rollup`(Vite plugin)或运行时 MDX 解析 |
|
||||
| `@next/third-parties/google` | GA4 | `Analytics/Google.tsx` | 用 `<script>` 直接注入 gtag |
|
||||
| `react-scan/monitoring/next` | React 性能调试 | `Analytics/ReactScan.tsx` | 改用 `react-scan` 的通用版本 |
|
||||
| `@serwist/next` | PWA/Service Worker | `sw.ts` + `define-config.ts` | 改用 `vite-plugin-pwa`(Workbox 封装) |
|
||||
| `@t3-oss/env-nextjs` | 环境变量校验 | `src/envs/*.ts` | 改用 `@t3-oss/env-core`(框架无关版) |
|
||||
|
||||
**关键文件**:
|
||||
|
||||
- `src/components/mdx/index.tsx`
|
||||
- `src/components/Analytics/*.tsx`
|
||||
- `src/envs/*.ts`(6 个文件)
|
||||
- `src/app/sw.ts`(改用 `vite-plugin-pwa` 集成)
|
||||
|
||||
---
|
||||
|
||||
## Phase 9: 构建集成与产物组织
|
||||
|
||||
### 9.1 Vite 构建产物
|
||||
|
||||
两次 build 分别产出:
|
||||
|
||||
```
|
||||
dist/
|
||||
├── desktop/
|
||||
│ ├── index.html # desktop 入口模板
|
||||
│ └── assets/ # JS/CSS(content hash)
|
||||
└── mobile/
|
||||
├── index.html # mobile 入口模板
|
||||
└── assets/
|
||||
```
|
||||
|
||||
### 9.2 构建脚本
|
||||
|
||||
```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 正确初始化 serverConfig;user session 正常拉取 |
|
||||
| 6 | `next dev` 访问 `/agent` 返回注入后的 HTML,SPA 正常渲染 |
|
||||
| 7 | Auth 页面在 Next.js App Router 中正常工作;catch-all 正确排除 auth 路径 |
|
||||
| 8 | Analytics 脚本加载、MDX 渲染、PWA 安装正常 |
|
||||
| 9 | Docker 镜像构建成功;线上访问 SPA + API 均正常 |
|
||||
| 10 | 旧代码已删;`pnpm dev:spa` 为默认开发命令;desktop 构建正常 |
|
||||
|
||||
### 端到端验证
|
||||
|
||||
- 本地:`pnpm dev:spa` 纯前端开发(不启动 Next),验证页面渲染、路由、热更新
|
||||
- 联调:`pnpm dev:spa` + `next dev`,验证 API 调用、tRPC、Auth 流程
|
||||
- Docker:构建镜像 → 运行 → 验证全量功能(含 locale 切换、mobile UA 切换、OAuth 流程)
|
||||
- Desktop:`pnpm desktop:build:renderer` → 验证 Electron 构建不受影响
|
||||
|
||||
---
|
||||
|
||||
## 风险与注意事项
|
||||
|
||||
1. **`process.env` 在 Vite 中不可用**:Vite 不注入 `process.env`,所有客户端代码中的 `process.env.*` 需改为 `import.meta.env.*` 或 `window.__SERVER_CONFIG__`。可通过 Vite define 提供兼容层,但建议逐步替换。
|
||||
2. **Emotion SSR**:当前 Next.js 有 `compiler.emotion` 支持。Vite 侧需配置 `@emotion/babel-plugin`(通过 `@vitejs/plugin-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
@@ -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 meta;locale 由 index.html 前置 script 检测;turborepo dev 流程可用。
|
||||
|
||||
**Architecture:** middleware locale 检测逻辑保持不变(`?hl=` → cookie → `Accept-Language`)。SPA catch-all 路由从 `(spa)/[[...path]]/route.ts` 迁移至 `(spa)/[locale]/[[...path]]/route.ts`,标记 `force-static`,通过 `generateStaticParams` 为 18 种语系预渲染。每个语系 HTML 内嵌 locale 对应的 SEO meta(title/description/OG)。客户端 locale 由 `index.html` 前置 script 处理(读 cookie / `?hl=` / `navigator.language`),SPAGlobalProvider 从 DOM 读取。theme 字段直接删除(app 层已处理)。
|
||||
|
||||
**Tech Stack:** Next.js route handler, Vite, TypeScript, Turborepo
|
||||
|
||||
---
|
||||
|
||||
## Task 1: 验证 Turborepo Dev 可用
|
||||
|
||||
**Files:**
|
||||
- 已有: `turbo.json`
|
||||
- 已有: `package.json` scripts (`dev`, `dev:next`, `dev:spa`)
|
||||
|
||||
**Step 1: 运行 turbo dev 验证并行启动**
|
||||
|
||||
Run: `bun run dev`
|
||||
Expected: Turborepo 并行启动 `dev:next`(port 3010)和 `dev:spa`(port 3011),两个进程均正常运行。
|
||||
|
||||
**Step 2: 验证代理连通**
|
||||
|
||||
访问 `http://localhost:3011`,确认 Vite SPA 页面正常渲染,API 请求代理至 Next.js(3010)。
|
||||
|
||||
---
|
||||
|
||||
## Task 2: index.html 注入 locale 检测前置 script
|
||||
|
||||
**Files:**
|
||||
- Modify: `index.html`
|
||||
|
||||
**Step 1: 在 index.html 中添加 locale 检测 script**
|
||||
|
||||
在 `<div id="root"></div>` 之前插入前置 script,复用 proxy `define-config.ts` 中的 locale 检测优先级:
|
||||
|
||||
1. `?hl=` search param(最高优先级,同时持久化至 cookie)
|
||||
2. `LOBE_LOCALE` cookie
|
||||
3. `navigator.language`(等同服务端 `Accept-Language`)
|
||||
4. fallback `en-US`
|
||||
|
||||
若值为 `auto` 则降级至 `navigator.language`。
|
||||
|
||||
完整 `index.html`:
|
||||
|
||||
```html
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<!--SEO_META-->
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
(function () {
|
||||
var hl = new URLSearchParams(location.search).get('hl');
|
||||
var m = document.cookie.match(/(?:^|;\s*)LOBE_LOCALE=([^;]*)/);
|
||||
var cookie = m ? decodeURIComponent(m[1]) : '';
|
||||
var locale = hl || cookie || navigator.language || 'en-US';
|
||||
if (locale === 'auto') locale = navigator.language || 'en-US';
|
||||
if (hl && !cookie) {
|
||||
document.cookie = 'LOBE_LOCALE=' + encodeURIComponent(hl) + ';path=/;max-age=7776000;SameSite=Lax';
|
||||
}
|
||||
document.documentElement.lang = locale;
|
||||
var rtl = ['ar', 'arc', 'dv', 'fa', 'ha', 'he', 'khw', 'ks', 'ku', 'ps', 'ur', 'yi'];
|
||||
document.documentElement.dir = rtl.indexOf(locale.split('-')[0].toLowerCase()) >= 0 ? 'rtl' : 'ltr';
|
||||
})();
|
||||
</script>
|
||||
<div id="root"></div>
|
||||
<script>
|
||||
window.__SERVER_CONFIG__ = undefined; /* SERVER_CONFIG */
|
||||
</script>
|
||||
<!--ANALYTICS_SCRIPTS-->
|
||||
<script type="module" src="/src/entry.desktop.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
> 注:此 script 完全复用 proxy `define-config.ts` 的 locale 检测逻辑(`?hl=` → cookie → browser language),包括 `?hl=` 持久化至 cookie(90 天 = 7776000 秒)。middleware 中的 locale 逻辑保持不变。`<!--LOCALE-->` / `<!--DIR-->` 占位符移除;`<!--SEO_META-->` 保留,由 Task 5 的 `force-static` route 注入各语系 SEO meta。
|
||||
|
||||
**Step 2: 验证 dev 模式**
|
||||
|
||||
Run: `bun run dev:spa`
|
||||
打开浏览器,检查 `document.documentElement.lang` 是否正确从 cookie 或 `navigator.language` 取值。
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```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 prop;import Locale 改为 `./Locale`;注释 DevPanel |
|
||||
| `src/layout/SPAGlobalProvider/Locale.tsx` | 新增:SPA 专用 Locale,`import.meta.glob` 加载 dayjs/antd locale,无 SSR 逻辑 |
|
||||
| `src/components/HighlightNotification/index.tsx` | `next/link` → `<a>`(外链场景) |
|
||||
| `src/components/mdx/Image.vite.tsx` | 新增:去掉 `plaiceholder`/`sharp`,直接渲染 `<Image>` |
|
||||
| `src/layout/AuthProvider/index.vite.tsx` | 新增:跳过 `authEnv` server env,直接用 BetterAuth |
|
||||
| `src/components/Analytics/LobeAnalyticsProviderWrapper.vite.tsx` | 新增:从 `window.__SERVER_CONFIG__` 读取 analytics 配置 |
|
||||
| `src/libs/next/navigation.ts` | 还原为 `next/navigation` 再导出 |
|
||||
| `src/libs/next/navigation.vite.ts` | 新增:react-router-dom 实现 |
|
||||
| `vite.config.ts` | `base`: dev `/` → prod `/spa/`;`viteModuleRedirect()` 插件含 8 条重定向规则 |
|
||||
| `src/utils/locale.vite.ts` | 已有:`import.meta.glob` 加载 antd locale |
|
||||
| `src/utils/i18n/loadI18nNamespaceModule.vite.ts` | 已有:`import.meta.glob` 加载 i18n namespace |
|
||||
| `src/libs/getUILocaleAndResources.vite.ts` | 新增:`import.meta.glob` 版本 |
|
||||
| `scripts/generateSpaTemplates.mts` | 新增:vite build 后生成内联 HTML string 的 `.ts` |
|
||||
| `package.json` | `build` = `build:spa` + `build:next`;`build:spa` 追加模板生成 |
|
||||
| `turbo.json` | `dev` → `dev:next` 任务定义修复 |
|
||||
@@ -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';
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
'use client';
|
||||
|
||||
export { default } from '@/components/Error';
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
import Loading from '@/components/Loading/BrandTextLoading';
|
||||
|
||||
export default () => <Loading debugId="Variants" />;
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from '@/components/404';
|
||||
@@ -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 />;
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
import Loading from '@/components/Loading/BrandTextLoading';
|
||||
|
||||
export default () => <Loading debugId="App Root" />;
|
||||
@@ -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 = '';
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 />);
|
||||
@@ -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,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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,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,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
@@ -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
@@ -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
@@ -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
@@ -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 = () => {
|
||||
|
||||
@@ -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,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(() => {
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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,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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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 ==============================
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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 !== ''));
|
||||
|
||||
Vendored
+4
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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> }>;
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
import 'vite/client';
|
||||
|
||||
export {};
|
||||
+14
@@ -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"
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user