feat: add server version check for desktop app (#11710)

*  feat: add server version check for desktop app

- Add /api/version endpoint consumption in globalService
- Add serverVersion and isServerVersionOutdated states to global store
- Add useCheckServerVersion hook to detect outdated server
- Show ServerVersionOutdatedAlert when server version is incompatible
- Display server version tag in settings when different from client
- Support version diff threshold (5 versions) for compatibility check

* 🔧 chore: only show server version alert for self-hosted instances

Check storageMode from electron store - only show alert when
using 'selfHost' mode, not 'cloud' mode.

* 🔧 chore: remove deprecated 'local' storage mode option

* 🐛 fix: only treat 404 as outdated server, throw on other errors

Previously any non-OK response was treated as "server doesn't support
the API", causing transient failures (500s, network issues) to
incorrectly show the outdated alert. Now only 404 returns null to
indicate a missing API, while other errors throw to allow SWR retry.

*  feat: add server version check and update alerts

- Implemented server version check functionality to notify users when their client version requires a newer server version.
- Added localized messages for server version outdated alerts in English and Chinese.
- Enhanced the global state management to track server version and its status.
- Updated UI components to display server version information and warnings appropriately.
- Introduced a new alert component to inform users about the need to upgrade their server for optimal performance.

Signed-off-by: Innei <tukon479@gmail.com>

---------

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2026-01-22 23:20:07 +08:00
committed by GitHub
parent bf244f9ae1
commit 0cf27230ea
14 changed files with 269 additions and 15 deletions

View File

@@ -50,7 +50,8 @@ export default class RemoteServerConfigCtr extends ControllerModule {
* Local mode has been removed; fall back to cloud.
*/
private normalizeConfig = (config: DataSyncConfig): DataSyncConfig => {
if (config.storageMode !== 'local') return config;
// Use type assertion to handle legacy 'local' value from stored data
if ((config.storageMode as string) !== 'local') return config;
const nextConfig: DataSyncConfig = {
...config,

View File

@@ -60,7 +60,7 @@ describe('RemoteServerConfigCtr', () => {
ipcMainHandleMock.mockClear();
mockStoreManager.get.mockReturnValue({
active: false,
storageMode: 'local',
storageMode: 'cloud',
});
controller = new RemoteServerConfigCtr(mockApp);
});
@@ -85,7 +85,7 @@ describe('RemoteServerConfigCtr', () => {
it('should update configuration', async () => {
const prevConfig: DataSyncConfig = {
active: false,
storageMode: 'local',
storageMode: 'cloud',
};
mockStoreManager.get.mockReturnValue(prevConfig);
@@ -195,7 +195,7 @@ describe('RemoteServerConfigCtr', () => {
refreshToken: Buffer.from('stored-refresh-token').toString('base64'),
};
}
return { active: false, storageMode: 'local' };
return { active: false, storageMode: 'cloud' };
});
// Create new controller to test loading from store
@@ -210,7 +210,7 @@ describe('RemoteServerConfigCtr', () => {
if (key === 'encryptedTokens') {
return null;
}
return { active: false, storageMode: 'local' };
return { active: false, storageMode: 'cloud' };
});
const newController = new RemoteServerConfigCtr(mockApp);
@@ -243,7 +243,7 @@ describe('RemoteServerConfigCtr', () => {
refreshToken: 'invalid-encrypted-token',
};
}
return { active: false, storageMode: 'local' };
return { active: false, storageMode: 'cloud' };
});
const newController = new RemoteServerConfigCtr(mockApp);
@@ -273,7 +273,7 @@ describe('RemoteServerConfigCtr', () => {
if (key === 'encryptedTokens') {
return null;
}
return { active: false, storageMode: 'local' };
return { active: false, storageMode: 'cloud' };
});
const newController = new RemoteServerConfigCtr(mockApp);
@@ -417,7 +417,7 @@ describe('RemoteServerConfigCtr', () => {
it('should return error when remote server is not active', async () => {
mockStoreManager.get.mockImplementation((key) => {
if (key === 'dataSyncConfig') {
return { active: false, storageMode: 'local' };
return { active: false, storageMode: 'cloud' };
}
return null;
});
@@ -648,7 +648,7 @@ describe('RemoteServerConfigCtr', () => {
refreshToken: 'stored-refresh',
};
}
return { active: false, storageMode: 'local' };
return { active: false, storageMode: 'cloud' };
});
const newController = new RemoteServerConfigCtr(mockApp);

View File

@@ -332,6 +332,11 @@
"run": "Run",
"save": "Save",
"send": "Send",
"serverVersionOutdated.desc": "Your client version (v{{version}}) requires a newer server version.",
"serverVersionOutdated.dismiss": "Continue Anyway",
"serverVersionOutdated.title": "Server Version Outdated",
"serverVersionOutdated.upgrade": "Upgrade Guide",
"serverVersionOutdated.warning": "Some features may not work properly or behave unexpectedly. Please update your server for the best experience.",
"setting": "Settings",
"share": "Share",
"stop": "Stop",
@@ -380,6 +385,7 @@
"upgradeVersion.action": "Upgrade",
"upgradeVersion.hasNew": "Update available",
"upgradeVersion.newVersion": "Update available: {{version}}",
"upgradeVersion.serverVersion": "Server: {{version}}",
"userPanel.anonymousNickName": "Anonymous User",
"userPanel.billing": "Billing Management",
"userPanel.cloud": "Launch {{name}}",

View File

@@ -332,6 +332,11 @@
"run": "运行",
"save": "保存",
"send": "发送",
"serverVersionOutdated.desc": "当前客户端版本v{{version}})需要更新的服务端版本。",
"serverVersionOutdated.dismiss": "继续使用",
"serverVersionOutdated.title": "服务端版本过旧",
"serverVersionOutdated.upgrade": "升级指南",
"serverVersionOutdated.warning": "部分功能可能无法正常使用或出现非预期行为。建议更新服务端以获得最佳体验。",
"setting": "设置",
"share": "分享",
"stop": "停止",
@@ -380,6 +385,7 @@
"upgradeVersion.action": "升级",
"upgradeVersion.hasNew": "有可用更新",
"upgradeVersion.newVersion": "可用更新版本:{{version}}",
"upgradeVersion.serverVersion": "服务端:{{version}}",
"userPanel.anonymousNickName": "匿名用户",
"userPanel.billing": "账单管理",
"userPanel.cloud": "体验 {{name}}",

View File

@@ -1,4 +1,4 @@
export type StorageMode = 'local' | 'cloud' | 'selfHost';
export type StorageMode = 'cloud' | 'selfHost';
export enum StorageModeEnum {
Cloud = 'cloud',
SelfHost = 'selfHost',

View File

@@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next';
import { ProductLogo } from '@/components/Branding';
import { CHANGELOG_URL, MANUAL_UPGRADE_URL, OFFICIAL_SITE } from '@/const/url';
import { CURRENT_VERSION } from '@/const/version';
import { CURRENT_VERSION, isDesktop } from '@/const/version';
import { useNewVersion } from '@/features/User/UserPanel/useNewVersion';
import { useGlobalStore } from '@/store/global';
@@ -18,9 +18,17 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
const Version = memo<{ mobile?: boolean }>(({ mobile }) => {
const hasNewVersion = useNewVersion();
const [latestVersion] = useGlobalStore((s) => [s.latestVersion]);
const [latestVersion, serverVersion, useCheckServerVersion] = useGlobalStore((s) => [
s.latestVersion,
s.serverVersion,
s.useCheckServerVersion,
]);
const { t } = useTranslation('common');
useCheckServerVersion(isDesktop);
const showServerVersion = serverVersion && serverVersion !== CURRENT_VERSION;
return (
<Flexbox
align={mobile ? 'stretch' : 'center'}
@@ -46,6 +54,9 @@ const Version = memo<{ mobile?: boolean }>(({ mobile }) => {
<div style={{ fontSize: 18, fontWeight: 'bolder' }}>{BRANDING_NAME}</div>
<Flexbox gap={6} horizontal={!mobile}>
<Tag>v{CURRENT_VERSION}</Tag>
{showServerVersion && (
<Tag>{t('upgradeVersion.serverVersion', { version: `v${serverVersion}` })}</Tag>
)}
{hasNewVersion && (
<Tag color={'info'}>
{t('upgradeVersion.newVersion', { version: `v${latestVersion}` })}

View File

@@ -62,14 +62,14 @@ describe('useUserAvatar', () => {
expect(result.current).toBe(mockAvatar);
});
it('should return original avatar when no remote server URL in desktop environment', () => {
it('should return original avatar when no remote server URL in desktop environment (selfHost mode)', () => {
mockIsDesktop = true;
const mockAvatar = '/api/avatar.png';
act(() => {
useUserStore.setState({ user: { avatar: mockAvatar } as any });
useElectronStore.setState({
dataSyncConfig: { remoteServerUrl: undefined, storageMode: 'local' },
dataSyncConfig: { remoteServerUrl: undefined, storageMode: 'selfHost' },
});
});

View File

@@ -0,0 +1,131 @@
'use client';
import { Button, Flexbox, Icon } from '@lobehub/ui';
import { createStyles } from 'antd-style';
import { TriangleAlert, X } from 'lucide-react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { MANUAL_UPGRADE_URL } from '@/const/url';
import { CURRENT_VERSION } from '@/const/version';
import { useElectronStore } from '@/store/electron';
import { electronSyncSelectors } from '@/store/electron/selectors';
import { useGlobalStore } from '@/store/global';
const useStyles = createStyles(({ css, token }) => ({
closeButton: css`
cursor: pointer;
position: absolute;
inset-block-start: 20px;
inset-inline-end: 20px;
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: ${token.borderRadius}px;
color: ${token.colorTextSecondary};
transition: all 0.2s;
&:hover {
color: ${token.colorText};
background: ${token.colorFillSecondary};
}
`,
container: css`
position: fixed;
z-index: 9999;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: ${token.colorBgMask};
`,
content: css`
position: relative;
overflow: hidden;
max-width: 480px;
padding: 24px;
border: 1px solid ${token.yellowBorder};
border-radius: ${token.borderRadiusLG}px;
background: ${token.colorBgContainer};
box-shadow: ${token.boxShadowSecondary};
`,
desc: css`
line-height: 1.6;
color: ${token.colorTextSecondary};
`,
title: css`
font-size: 16px;
font-weight: bold;
color: ${token.colorWarningText};
`,
titleIcon: css`
flex-shrink: 0;
color: ${token.colorWarning};
`,
warning: css`
padding: 12px;
border-radius: ${token.borderRadius}px;
color: ${token.colorWarningText};
background: ${token.yellowBg};
`,
}));
const ServerVersionOutdatedAlert = () => {
const { styles } = useStyles();
const { t } = useTranslation('common');
const [dismissed, setDismissed] = useState(false);
const isServerVersionOutdated = useGlobalStore((s) => s.isServerVersionOutdated);
const storageMode = useElectronStore(electronSyncSelectors.storageMode);
// Only show alert when using self-hosted server, not cloud
if (storageMode !== 'selfHost') return null;
if (!isServerVersionOutdated || dismissed) return null;
return (
<div className={styles.container}>
<div className={styles.content}>
<div className={styles.closeButton} onClick={() => setDismissed(true)}>
<Icon icon={X} />
</div>
<Flexbox gap={16}>
<Flexbox align="center" gap={8} horizontal>
<Icon className={styles.titleIcon} icon={TriangleAlert} />
<div className={styles.title}>{t('serverVersionOutdated.title')}</div>
</Flexbox>
<div className={styles.desc}>
{t('serverVersionOutdated.desc', { version: CURRENT_VERSION })}
</div>
<div className={styles.warning}>{t('serverVersionOutdated.warning')}</div>
<Flexbox gap={8} horizontal justify="flex-end" style={{ marginTop: 8 }}>
<a href={MANUAL_UPGRADE_URL} rel="noreferrer" target="_blank">
<Button size="small" type="primary">
{t('serverVersionOutdated.upgrade')}
</Button>
</a>
<Button onClick={() => setDismissed(true)} size="small">
{t('serverVersionOutdated.dismiss')}
</Button>
</Flexbox>
</Flexbox>
</div>
</div>
);
};
export default ServerVersionOutdatedAlert;

View File

@@ -5,6 +5,7 @@ import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { createStoreUpdater } from 'zustand-utils';
import { isDesktop } from '@/const/version';
import { enableNextAuth } from '@/envs/auth';
import { useIsMobile } from '@/hooks/useIsMobile';
import { useAgentStore } from '@/store/agent';
@@ -32,7 +33,10 @@ const StoreInitialization = memo(() => {
const { serverConfig } = useServerConfigStore();
const useInitSystemStatus = useGlobalStore((s) => s.useInitSystemStatus);
const [useInitSystemStatus, useCheckServerVersion] = useGlobalStore((s) => [
s.useInitSystemStatus,
s.useCheckServerVersion,
]);
const useInitBuiltinAgent = useAgentStore((s) => s.useInitBuiltinAgent);
const useInitAiProviderKeyVaults = useAiInfraStore((s) => s.useFetchAiProviderRuntimeState);
@@ -41,6 +45,9 @@ const StoreInitialization = memo(() => {
// init the system preference
useInitSystemStatus();
// check server version in desktop app
useCheckServerVersion(isDesktop);
// fetch server config
const useFetchServerConfig = useServerConfigStore((s) => s.useInitServerConfig);
useFetchServerConfig();

View File

@@ -7,6 +7,7 @@ import { ReferralProvider } from '@/business/client/ReferralProvider';
import { LobeAnalyticsProviderWrapper } from '@/components/Analytics/LobeAnalyticsProviderWrapper';
import { DragUploadProvider } from '@/components/DragUploadZone/DragUploadProvider';
import { getServerFeatureFlagsValue } from '@/config/featureFlags';
import { isDesktop } from '@/const/version';
import { appEnv } from '@/envs/app';
import DevPanel from '@/features/DevPanel';
import { getServerGlobalConfig } from '@/server/globalConfig';
@@ -20,6 +21,7 @@ import ImportSettings from './ImportSettings';
import Locale from './Locale';
import NextThemeProvider from './NextThemeProvider';
import QueryProvider from './Query';
import ServerVersionOutdatedAlert from './ServerVersionOutdatedAlert';
import StoreInitialization from './StoreInitialization';
import StyleRegistry from './StyleRegistry';
@@ -66,6 +68,8 @@ const GlobalLayout = async ({
>
<QueryProvider>
<StoreInitialization />
{isDesktop && <ServerVersionOutdatedAlert />}
<FaviconProvider>
<GroupWizardProvider>
<DragUploadProvider>

View File

@@ -350,6 +350,13 @@ export default {
'run': 'Run',
'save': 'Save',
'send': 'Send',
'serverVersionOutdated.desc':
'Your client version (v{{version}}) requires a newer server version.',
'serverVersionOutdated.dismiss': 'Continue Anyway',
'serverVersionOutdated.title': 'Server Version Outdated',
'serverVersionOutdated.upgrade': 'Upgrade Guide',
'serverVersionOutdated.warning':
'Some features may not work properly or behave unexpectedly. Please update your server for the best experience.',
'setting': 'Settings',
'share': 'Share',
'stop': 'Stop',
@@ -401,6 +408,7 @@ export default {
'upgradeVersion.action': 'Upgrade',
'upgradeVersion.hasNew': 'Update available',
'upgradeVersion.newVersion': 'Update available: {{version}}',
'upgradeVersion.serverVersion': 'Server: {{version}}',
'userPanel.anonymousNickName': 'Anonymous User',
'userPanel.billing': 'Billing Management',
'userPanel.cloud': 'Launch {{name}}',

View File

@@ -1,11 +1,13 @@
import type { PartialDeep } from 'type-fest';
import type { VersionResponseData } from '@/app/(backend)/api/version/route';
import { BusinessGlobalService } from '@/business/client/services/BusinessGlobalService';
import { lambdaClient } from '@/libs/trpc/client';
import { type LobeAgentConfig } from '@/types/agent';
import { type GlobalRuntimeConfig } from '@/types/serverConfig';
const VERSION_URL = 'https://registry.npmmirror.com/@lobehub/chat/latest';
const SERVER_VERSION_URL = '/api/version';
class GlobalService extends BusinessGlobalService {
/**
@@ -18,6 +20,29 @@ class GlobalService extends BusinessGlobalService {
return data['version'];
};
/**
* get server version from /api/version
* @returns version string if available, null only if server returns 404 (API doesn't exist on old server)
* @throws Error for other failures (network errors, 500s, etc.) to allow SWR retry
*/
getServerVersion = async (): Promise<string | null> => {
const res = await fetch(SERVER_VERSION_URL);
// Only treat 404 as "server doesn't support version API"
// Other errors (500, network issues) should throw to allow retry
if (res.status === 404) {
return null;
}
if (!res.ok) {
throw new Error(`Failed to fetch server version: ${res.status}`);
}
const data: VersionResponseData = await res.json();
return data.version;
};
getGlobalConfig = async (): Promise<GlobalRuntimeConfig> => {
return lambdaClient.config.getGlobalConfig.query();
};

View File

@@ -23,6 +23,7 @@ export interface GlobalGeneralAction {
updateResourceManagerColumnWidth: (column: 'name' | 'date' | 'size', width: number) => void;
updateSystemStatus: (status: Partial<SystemStatus>, action?: any) => void;
useCheckLatestVersion: (enabledCheck?: boolean) => SWRResponse<string>;
useCheckServerVersion: (enabledCheck?: boolean) => SWRResponse<string | null>;
useInitSystemStatus: () => SWRResponse;
}
@@ -160,6 +161,51 @@ export const generalActionSlice: StateCreator<
},
),
useCheckServerVersion: (enabledCheck = true) =>
useOnlyFetchOnceSWR(
enabledCheck ? 'checkServerVersion' : null,
async () => globalService.getServerVersion(),
{
onSuccess: (data: string | null) => {
if (data === null) {
set({ isServerVersionOutdated: true }, false);
return;
}
set({ serverVersion: data }, false);
if (!valid(CURRENT_VERSION) || !valid(data)) return;
const clientVersion = parse(CURRENT_VERSION);
const serverVersion = parse(data);
if (!clientVersion || !serverVersion) return;
const DIFF_THRESHOLD = 5;
// 版本差异计算规则
// ┌─────────────────┬────────┬─────────┐
// │ 客户端 → 服务端 │ 差异值 │ 结果 │
// ├─────────────────┼────────┼─────────┤
// │ 1.0.5 → 1.0.0 │ 5 │ ⚠️ 过旧 │
// ├─────────────────┼────────┼─────────┤
// │ 1.1.0 → 1.0.5 │ 5 │ ⚠️ 过旧 │
// ├─────────────────┼────────┼─────────┤
// │ 2.0.0 → 1.9.9 │ 91 │ ⚠️ 过旧 │
// ├─────────────────┼────────┼─────────┤
// │ 1.0.4 → 1.0.0 │ 4 │ ✅ 正常 │
// └─────────────────┴────────┴─────────┘
const versionDiff =
(clientVersion.major - serverVersion.major) * 100 +
(clientVersion.minor - serverVersion.minor) * 10 +
(clientVersion.patch - serverVersion.patch);
if (versionDiff >= DIFF_THRESHOLD) {
set({ isServerVersionOutdated: true }, false);
}
},
},
),
useInitSystemStatus: () =>
useOnlyFetchOnceSWR<SystemStatus>(
'initSystemStatus',

View File

@@ -180,9 +180,18 @@ export interface GlobalState {
*/
initClientDBStage: DatabaseLoadingState;
isMobile?: boolean;
/**
* 服务端版本过旧,不支持 /api/version 接口
* 需要提示用户更新服务端
*/
isServerVersionOutdated?: boolean;
isStatusInit?: boolean;
latestVersion?: string;
navigate?: NavigateFunction;
/**
* 服务端版本号,用于检测客户端与服务端版本是否一致
*/
serverVersion?: string;
sidebarKey: SidebarTabKey;
status: SystemStatus;
statusStorage: AsyncLocalStorage<SystemStatus>;