feat(desktop): add device gateway status indicator in titlebar (#13260)

* support desktop gateway

* support device mode

*  feat(desktop): add device gateway status indicator in titlebar

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

*  test(desktop): update getDeviceInfo test to include name and description fields

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ✏️ chore(i18n): update gateway status copy to reference Gateway instead of cloud

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ✏️ chore(i18n): translate Gateway to 网关 in zh-CN

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ✏️ chore(i18n): simplify description placeholder to Optional

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ✏️ chore(desktop): use fixed title 'Connect to Gateway' in device popover

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Arvin Xu
2026-03-26 01:14:08 +08:00
committed by GitHub
parent 2c7a3f934d
commit 169f11b63b
14 changed files with 399 additions and 0 deletions

View File

@@ -28,7 +28,9 @@ export const defaultProxySettings: NetworkProxySettings = {
export const STORE_DEFAULTS: ElectronMainStore = {
dataSyncConfig: { storageMode: 'cloud' },
encryptedTokens: {},
gatewayDeviceDescription: '',
gatewayDeviceId: '',
gatewayDeviceName: '',
gatewayUrl: 'https://device-gateway.lobehub.com',
locale: 'auto',
networkProxy: defaultProxySettings,

View File

@@ -70,13 +70,27 @@ export default class GatewayConnectionCtr extends ControllerModule {
@IpcMethod()
async getDeviceInfo(): Promise<{
description: string;
deviceId: string;
hostname: string;
name: string;
platform: string;
}> {
return this.service.getDeviceInfo();
}
@IpcMethod()
async setDeviceName(params: { name: string }): Promise<{ success: boolean }> {
this.service.setDeviceName(params.name);
return { success: true };
}
@IpcMethod()
async setDeviceDescription(params: { description: string }): Promise<{ success: boolean }> {
this.service.setDeviceDescription(params.description);
return { success: true };
}
// ─── Auto Connect ───
private async tryAutoConnect() {

View File

@@ -553,8 +553,10 @@ describe('GatewayConnectionCtr', () => {
const info = await ctr.getDeviceInfo();
expect(info).toEqual({
description: '',
deviceId: 'my-device',
hostname: 'mock-hostname',
name: 'mock-hostname',
platform: process.platform,
});
});

View File

@@ -84,12 +84,32 @@ export default class GatewayConnectionService extends ServiceModule {
getDeviceInfo() {
return {
description: this.getDeviceDescription(),
deviceId: this.getDeviceId(),
hostname: os.hostname(),
name: this.getDeviceName(),
platform: process.platform,
};
}
// ─── Device Name & Description ───
getDeviceName(): string {
return (this.app.storeManager.get('gatewayDeviceName') as string) || os.hostname();
}
setDeviceName(name: string) {
this.app.storeManager.set('gatewayDeviceName', name);
}
getDeviceDescription(): string {
return (this.app.storeManager.get('gatewayDeviceDescription') as string) || '';
}
setDeviceDescription(description: string) {
this.app.storeManager.set('gatewayDeviceDescription', description);
}
// ─── Connection Logic ───
async connect(): Promise<{ error?: string; success: boolean }> {

View File

@@ -12,7 +12,9 @@ export interface ElectronMainStore {
lastRefreshAt?: number;
refreshToken?: string;
};
gatewayDeviceDescription: string;
gatewayDeviceId: string;
gatewayDeviceName: string;
gatewayUrl: string;
locale: string;
networkProxy: NetworkProxySettings;

View File

@@ -1,4 +1,13 @@
{
"gateway.description": "Description",
"gateway.descriptionPlaceholder": "Optional",
"gateway.deviceName": "Device Name",
"gateway.deviceNamePlaceholder": "Enter device name",
"gateway.enableConnection": "Connect to Gateway",
"gateway.statusConnected": "Connected to Gateway",
"gateway.statusConnecting": "Connecting to Gateway...",
"gateway.statusDisconnected": "Not connected to Gateway",
"gateway.title": "Device Gateway",
"navigation.chat": "Chat",
"navigation.discover": "Discover",
"navigation.discoverAssistants": "Discover Assistants",

View File

@@ -1,4 +1,13 @@
{
"gateway.description": "描述",
"gateway.descriptionPlaceholder": "可选",
"gateway.deviceName": "设备名称",
"gateway.deviceNamePlaceholder": "输入设备名称",
"gateway.enableConnection": "连接到网关",
"gateway.statusConnected": "已连接到网关",
"gateway.statusConnecting": "正在连接到网关...",
"gateway.statusDisconnected": "未连接到网关",
"gateway.title": "设备网关",
"navigation.chat": "对话",
"navigation.discover": "发现",
"navigation.discoverAssistants": "发现助理",

View File

@@ -0,0 +1,180 @@
import { useWatchBroadcast } from '@lobechat/electron-client-ipc';
import { ActionIcon, Flexbox } from '@lobehub/ui';
import { Input, Popover, Switch } from 'antd';
import { createStyles, cssVar } from 'antd-style';
import { HardDrive } from 'lucide-react';
import { memo, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useElectronStore } from '@/store/electron';
import { electronSyncSelectors } from '@/store/electron/selectors';
const useStyles = createStyles(({ css, token }) => ({
fieldLabel: css`
font-size: 12px;
color: ${cssVar.colorTextDescription};
`,
greenDot: css`
position: absolute;
inset-block-end: 0;
inset-inline-end: 0;
width: 8px;
height: 8px;
border: 1.5px solid ${cssVar.colorBgContainer};
border-radius: 50%;
background: #52c41a;
`,
input: css`
border: none;
background: ${token.colorFillTertiary};
&:hover,
&:focus {
background: ${token.colorFillSecondary};
}
`,
popoverContent: css`
width: 280px;
padding-block: 4px;
padding-inline: 0;
`,
statusTitle: css`
font-size: 13px;
font-weight: 500;
color: ${cssVar.colorText};
`,
}));
const DeviceGateway = memo(() => {
const { t } = useTranslation('electron');
const { styles } = useStyles();
const [
gatewayStatus,
connectGateway,
disconnectGateway,
setGatewayConnectionStatus,
useFetchGatewayStatus,
useFetchGatewayDeviceInfo,
updateDeviceName,
updateDeviceDescription,
gatewayDeviceInfo,
] = useElectronStore((s) => [
s.gatewayConnectionStatus,
s.connectGateway,
s.disconnectGateway,
s.setGatewayConnectionStatus,
s.useFetchGatewayStatus,
s.useFetchGatewayDeviceInfo,
s.updateDeviceName,
s.updateDeviceDescription,
s.gatewayDeviceInfo,
]);
useFetchGatewayStatus();
useFetchGatewayDeviceInfo();
useWatchBroadcast('gatewayConnectionStatusChanged', ({ status }) => {
setGatewayConnectionStatus(status);
});
const isConnected = gatewayStatus === 'connected';
const isConnecting = gatewayStatus === 'connecting' || gatewayStatus === 'reconnecting';
const [localName, setLocalName] = useState<string | undefined>();
const [localDescription, setLocalDescription] = useState<string | undefined>();
const handleSwitchChange = useCallback(
async (checked: boolean) => {
if (checked) {
await connectGateway();
} else {
await disconnectGateway();
}
},
[connectGateway, disconnectGateway],
);
const handleNameBlur = useCallback(() => {
if (localName !== undefined && localName !== gatewayDeviceInfo?.name) {
updateDeviceName(localName);
}
setLocalName(undefined);
}, [localName, gatewayDeviceInfo?.name, updateDeviceName]);
const handleDescriptionBlur = useCallback(() => {
if (localDescription !== undefined && localDescription !== gatewayDeviceInfo?.description) {
updateDeviceDescription(localDescription);
}
setLocalDescription(undefined);
}, [localDescription, gatewayDeviceInfo?.description, updateDeviceDescription]);
const popoverContent = (
<Flexbox className={styles.popoverContent} gap={16}>
<Flexbox horizontal align="center" justify="space-between">
<span className={styles.statusTitle}>{t('gateway.enableConnection')}</span>
<Switch
checked={isConnected || isConnecting}
loading={isConnecting}
size="small"
onChange={handleSwitchChange}
/>
</Flexbox>
<Flexbox gap={4}>
<span className={styles.fieldLabel}>{t('gateway.deviceName')}</span>
<Input
className={styles.input}
placeholder={t('gateway.deviceNamePlaceholder')}
size="small"
value={localName ?? gatewayDeviceInfo?.name ?? ''}
variant="filled"
onBlur={handleNameBlur}
onChange={(e) => setLocalName(e.target.value)}
onPressEnter={handleNameBlur}
/>
</Flexbox>
<Flexbox gap={4}>
<span className={styles.fieldLabel}>{t('gateway.description')}</span>
<Input.TextArea
autoSize={{ maxRows: 3, minRows: 2 }}
className={styles.input}
placeholder={t('gateway.descriptionPlaceholder')}
size="small"
value={localDescription ?? gatewayDeviceInfo?.description ?? ''}
variant="filled"
onBlur={handleDescriptionBlur}
onChange={(e) => setLocalDescription(e.target.value)}
/>
</Flexbox>
</Flexbox>
);
return (
<Popover arrow={false} content={popoverContent} placement="bottomRight" trigger="click">
<div style={{ position: 'relative' }}>
<ActionIcon
icon={HardDrive}
loading={isConnecting}
size="small"
title={t('gateway.title')}
tooltipProps={{ placement: 'bottomRight' }}
/>
{isConnected && <div className={styles.greenDot} />}
</div>
</Popover>
);
});
const DeviceGatewayWithAuth = memo(() => {
const isSyncActive = useElectronStore(electronSyncSelectors.isSyncActive);
if (!isSyncActive) return null;
return <DeviceGateway />;
});
export default DeviceGatewayWithAuth;

View File

@@ -7,6 +7,7 @@ import { electronStylish } from '@/styles/electron';
import { getPlatform } from '@/utils/platform';
import Connection from '../connection/Connection';
import DeviceGateway from '../connection/DeviceGateway';
import { useTabNavigation } from '../navigation/useTabNavigation';
import { useWatchThemeUpdate } from '../system/useWatchThemeUpdate';
import { UpdateNotification } from '../updater/UpdateNotification';
@@ -39,6 +40,7 @@ const TitleBar = memo(() => {
<Flexbox horizontal align={'center'} gap={4}>
<Flexbox horizontal className={electronStylish.nodrag} gap={8}>
<UpdateNotification />
<DeviceGateway />
<Connection />
</Flexbox>
{showCustomWinControl && (

View File

@@ -68,6 +68,15 @@ export default {
'proxy.validation.serverRequired': 'Server address is required when proxy is enabled',
'proxy.validation.typeRequired': 'Proxy type is required when proxy is enabled',
'proxy.validation.usernameRequired': 'Username is required when authentication is enabled',
'gateway.description': 'Description',
'gateway.descriptionPlaceholder': 'Optional',
'gateway.deviceName': 'Device Name',
'gateway.deviceNamePlaceholder': 'Enter device name',
'gateway.enableConnection': 'Connect to Gateway',
'gateway.statusConnected': 'Connected to Gateway',
'gateway.statusConnecting': 'Connecting to Gateway...',
'gateway.statusDisconnected': 'Not connected to Gateway',
'gateway.title': 'Device Gateway',
'remoteServer.authError': 'Authorization failed: {{error}}',
'remoteServer.authPending': 'Please complete the authorization in your browser',
'remoteServer.configDesc': 'Connect to the remote LobeHub server to enable data synchronization',

View File

@@ -0,0 +1,29 @@
import { ensureElectronIpc } from '@/utils/electron/ipc';
class GatewayConnectionService {
connect = async () => {
return ensureElectronIpc().gatewayConnection.connect();
};
disconnect = async () => {
return ensureElectronIpc().gatewayConnection.disconnect();
};
getConnectionStatus = async () => {
return ensureElectronIpc().gatewayConnection.getConnectionStatus();
};
getDeviceInfo = async () => {
return ensureElectronIpc().gatewayConnection.getDeviceInfo();
};
setDeviceDescription = async (description: string) => {
return ensureElectronIpc().gatewayConnection.setDeviceDescription({ description });
};
setDeviceName = async (name: string) => {
return ensureElectronIpc().gatewayConnection.setDeviceName({ name });
};
}
export const gatewayConnectionService = new GatewayConnectionService();

View File

@@ -0,0 +1,111 @@
import type { GatewayConnectionStatus } from '@lobechat/electron-client-ipc';
import { type SWRResponse } from 'swr';
import useSWR from 'swr';
import { mutate } from '@/libs/swr';
import { gatewayConnectionService } from '@/services/electron/gatewayConnection';
import { type StoreSetter } from '@/store/types';
import { type ElectronStore } from '../store';
const GATEWAY_DEVICE_INFO_KEY = 'electron:getGatewayDeviceInfo';
type Setter = StoreSetter<ElectronStore>;
export const gatewaySlice = (set: Setter, get: () => ElectronStore, _api?: unknown) =>
new ElectronGatewayActionImpl(set, get, _api);
export interface GatewayDeviceInfo {
description: string;
deviceId: string;
hostname: string;
name: string;
platform: string;
}
export class ElectronGatewayActionImpl {
readonly #get: () => ElectronStore;
readonly #set: Setter;
constructor(set: Setter, get: () => ElectronStore, _api?: unknown) {
void _api;
this.#set = set;
this.#get = get;
}
connectGateway = async (): Promise<void> => {
this.#set({ gatewayConnectionStatus: 'connecting' });
try {
const result = await gatewayConnectionService.connect();
if (!result.success) {
this.#set({ gatewayConnectionStatus: 'disconnected' });
}
} catch (error) {
console.error('Gateway connect failed:', error);
this.#set({ gatewayConnectionStatus: 'disconnected' });
}
};
disconnectGateway = async (): Promise<void> => {
try {
await gatewayConnectionService.disconnect();
this.#set({ gatewayConnectionStatus: 'disconnected' });
} catch (error) {
console.error('Gateway disconnect failed:', error);
}
};
refreshGatewayDeviceInfo = async (): Promise<void> => {
await mutate(GATEWAY_DEVICE_INFO_KEY);
};
setGatewayConnectionStatus = (status: GatewayConnectionStatus): void => {
this.#set({ gatewayConnectionStatus: status }, false, 'setGatewayConnectionStatus');
};
updateDeviceDescription = async (description: string): Promise<void> => {
try {
await gatewayConnectionService.setDeviceDescription(description);
await this.#get().refreshGatewayDeviceInfo();
} catch (error) {
console.error('Update device description failed:', error);
}
};
updateDeviceName = async (name: string): Promise<void> => {
try {
await gatewayConnectionService.setDeviceName(name);
await this.#get().refreshGatewayDeviceInfo();
} catch (error) {
console.error('Update device name failed:', error);
}
};
useFetchGatewayDeviceInfo = (): SWRResponse<GatewayDeviceInfo> => {
return useSWR<GatewayDeviceInfo>(
GATEWAY_DEVICE_INFO_KEY,
async () => gatewayConnectionService.getDeviceInfo() as Promise<GatewayDeviceInfo>,
{
onSuccess: (data) => {
this.#set({ gatewayDeviceInfo: data }, false, 'setGatewayDeviceInfo');
},
},
);
};
useFetchGatewayStatus = (): SWRResponse<{ status: GatewayConnectionStatus }> => {
return useSWR<{ status: GatewayConnectionStatus }>(
'electron:getGatewayConnectionStatus',
async () => gatewayConnectionService.getConnectionStatus(),
{
onSuccess: (data) => {
this.#set({ gatewayConnectionStatus: data.status }, false, 'setGatewayConnectionStatus');
},
},
);
};
}
export type ElectronGatewayAction = Pick<
ElectronGatewayActionImpl,
keyof ElectronGatewayActionImpl
>;

View File

@@ -1,9 +1,11 @@
import {
type DataSyncConfig,
type ElectronAppState,
type GatewayConnectionStatus,
type NetworkProxySettings,
} from '@lobechat/electron-client-ipc';
import { type GatewayDeviceInfo } from './actions/gateway';
import { type NavigationHistoryState } from './actions/navigationHistory';
import { navigationHistoryInitialState } from './actions/navigationHistory';
import { type RecentPagesState } from './actions/recentPages';
@@ -26,6 +28,8 @@ export interface ElectronState extends NavigationHistoryState, RecentPagesState,
appState: ElectronAppState;
dataSyncConfig: DataSyncConfig;
desktopHotkeys: Record<string, string>;
gatewayConnectionStatus: GatewayConnectionStatus;
gatewayDeviceInfo?: GatewayDeviceInfo;
isAppStateInit?: boolean;
isConnectingServer?: boolean;
isConnectionDrawerOpen?: boolean;
@@ -43,6 +47,7 @@ export const initialState: ElectronState = {
appState: {},
dataSyncConfig: { storageMode: 'cloud' },
desktopHotkeys: {},
gatewayConnectionStatus: 'disconnected',
isAppStateInit: false,
isConnectingServer: false,
isConnectionDrawerOpen: false,

View File

@@ -7,6 +7,8 @@ import { expose } from '../middleware/expose';
import { flattenActions } from '../utils/flattenActions';
import { type ElectronAppAction } from './actions/app';
import { createElectronAppSlice } from './actions/app';
import { type ElectronGatewayAction } from './actions/gateway';
import { gatewaySlice } from './actions/gateway';
import { type NavigationHistoryAction } from './actions/navigationHistory';
import { createNavigationHistorySlice } from './actions/navigationHistory';
import { type RecentPagesAction } from './actions/recentPages';
@@ -27,6 +29,7 @@ export interface ElectronStore
ElectronState,
ElectronRemoteServerAction,
ElectronAppAction,
ElectronGatewayAction,
ElectronSettingsAction,
NavigationHistoryAction,
RecentPagesAction,
@@ -36,6 +39,7 @@ export interface ElectronStore
type ElectronStoreAction = ElectronRemoteServerAction &
ElectronAppAction &
ElectronGatewayAction &
ElectronSettingsAction &
NavigationHistoryAction &
RecentPagesAction &
@@ -48,6 +52,7 @@ const createStore: StateCreator<ElectronStore, [['zustand/devtools', never]]> =
...flattenActions<ElectronStoreAction>([
remoteSyncSlice(...parameters),
createElectronAppSlice(...parameters),
gatewaySlice(...parameters),
settingsSlice(...parameters),
createNavigationHistorySlice(...parameters),
createRecentPagesSlice(...parameters),