feat: add gateway management features and settings

- Implemented new API routes for handling logs and settings related to the gateway.
- Added a new Gateway section in the General Settings panel to manage gateway status and auto-start options.
- Introduced a state management hook for gateway settings, including status, logs, and auto-start functionality.
- Updated configuration service to include gateway auto-start setting.
- Enhanced internationalization support for new gateway-related messages.
- Refactored existing settings store to accommodate new gateway settings.
- Cleaned up code and improved logging functionality.
This commit is contained in:
duanshuwen
2026-04-20 22:22:11 +08:00
parent f7f4ffaee9
commit dfef9c90a5
18 changed files with 1139 additions and 15 deletions

View File

@@ -431,6 +431,20 @@ export const messages: I18nMessages = {
description: 'Customize the look and feel of the application.',
themeSection: 'Theme Settings',
languageSection: 'Language',
gatewayTitle: 'Gateway',
gatewayDescription: 'View the current Gateway state and basic runtime controls.',
gatewayStatusLabel: 'Status',
gatewayPortLabel: 'Port',
gatewayConnected: 'Running',
gatewayDisconnected: 'Stopped',
gatewayReconnecting: 'Restarting',
gatewayRestart: 'Restart',
gatewayLogs: 'Logs',
gatewayHideLogs: 'Hide Logs',
gatewayLogsEmpty: 'No logs available yet.',
gatewayLogsLoading: 'Loading logs...',
gatewayAutoStartTitle: 'Auto-start Gateway',
gatewayAutoStartDescription: 'Start the Gateway automatically when the app launches',
updatesTitle: 'Updates',
currentVersion: 'Current Version',
checkForUpdates: 'Check for Updates',
@@ -893,6 +907,20 @@ export const messages: I18nMessages = {
description: '自定义应用的外观与使用体验。',
themeSection: '主题设置',
languageSection: '语言',
gatewayTitle: '网关',
gatewayDescription: '查看当前网关状态与基础运行控制。',
gatewayStatusLabel: '状态',
gatewayPortLabel: '端口',
gatewayConnected: '运行中',
gatewayDisconnected: '已停止',
gatewayReconnecting: '重启中',
gatewayRestart: '重启',
gatewayLogs: '日志',
gatewayHideLogs: '收起日志',
gatewayLogsEmpty: '暂无日志内容。',
gatewayLogsLoading: '正在加载日志...',
gatewayAutoStartTitle: '自动启动网关',
gatewayAutoStartDescription: '应用启动时自动启动网关',
updatesTitle: '版本更新',
currentVersion: '当前版本',
checkForUpdates: '检查更新',
@@ -1355,6 +1383,20 @@ export const messages: I18nMessages = {
description: 'アプリの見た目と操作感をカスタマイズします。',
themeSection: 'テーマ設定',
languageSection: '言語',
gatewayTitle: 'ゲートウェイ',
gatewayDescription: '現在のゲートウェイ状態と基本的なランタイム操作を確認します。',
gatewayStatusLabel: '状態',
gatewayPortLabel: 'ポート',
gatewayConnected: '稼働中',
gatewayDisconnected: '停止中',
gatewayReconnecting: '再起動中',
gatewayRestart: '再起動',
gatewayLogs: 'ログ',
gatewayHideLogs: 'ログを閉じる',
gatewayLogsEmpty: 'まだログはありません。',
gatewayLogsLoading: 'ログを読み込み中...',
gatewayAutoStartTitle: 'ゲートウェイを自動起動',
gatewayAutoStartDescription: 'アプリ起動時にゲートウェイを自動起動します',
updatesTitle: 'アップデート',
currentVersion: '現在のバージョン',
checkForUpdates: '更新を確認',

View File

@@ -1,7 +1,8 @@
import { Moon, Sun, Monitor, RefreshCw } from 'lucide-react';
import { FileText, Moon, Sun, Monitor, RefreshCw } from 'lucide-react';
import { useI18n } from '../../../i18n';
import { SUPPORTED_LANGUAGE_CODES } from '../../../i18n/constants';
import type { LanguageCode, ThemeMode } from '../../../types/runtime';
import type { GatewayConnectionStatus, GatewaySettingState } from '../useGatewaySettingState';
import type { SettingUpdateState } from '../useSettingUpdateState';
import SectionHeader from './SectionHeader';
import ToggleSwitch from './ToggleSwitch';
@@ -11,6 +12,7 @@ type GeneralSettingsPanelProps = {
language: LanguageCode;
onThemeChange: (theme: ThemeMode) => void | Promise<void>;
onLanguageChange: (language: LanguageCode) => void | Promise<void>;
gatewayState: GatewaySettingState;
updateState: SettingUpdateState;
};
@@ -49,14 +51,46 @@ function getUpdateStatusText(t: ReturnType<typeof useI18n>['t'], updateState: Se
}
}
function getGatewayStatusMeta(
t: ReturnType<typeof useI18n>['t'],
status: GatewayConnectionStatus,
): {
label: string;
className: string;
} {
switch (status) {
case 'connected':
return {
label: t('settings.general.gatewayConnected'),
className:
'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-300',
};
case 'reconnecting':
return {
label: t('settings.general.gatewayReconnecting'),
className:
'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-300',
};
default:
return {
label: t('settings.general.gatewayDisconnected'),
className:
'border-slate-200 bg-slate-100 text-slate-700 dark:border-slate-500/30 dark:bg-slate-500/10 dark:text-slate-300',
};
}
}
export default function GeneralSettingsPanel({
themeMode,
language,
onThemeChange,
onLanguageChange,
gatewayState,
updateState,
}: GeneralSettingsPanelProps) {
const { t } = useI18n();
const gatewayStatusMeta = getGatewayStatusMeta(t, gatewayState.status);
const gatewayPort = gatewayState.gateway.port ?? 18789;
return (
<section className="flex-1 h-full p-5 select-none">
@@ -126,6 +160,118 @@ export default function GeneralSettingsPanel({
</div>
</div>
<div className="mt-10">
<div className="mb-6 text-[24px] font-medium text-[#171717] dark:text-gray-100">
{t('settings.general.gatewayTitle')}
</div>
<div className="p-5 dark:border-gray-700 dark:bg-[#222226]">
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0">
<div className="mb-2 text-[14px] text-[#525866] dark:text-gray-400">
{t('settings.general.gatewayStatusLabel')}
</div>
<div className="flex flex-wrap items-center gap-3">
<span className="text-[14px] text-[#667085] dark:text-gray-400">
{t('settings.general.gatewayPortLabel')}: {gatewayPort}
</span>
</div>
<div
className={[
'mt-3 text-[14px]',
gatewayState.error ? 'text-red-500' : 'text-[#667085] dark:text-gray-400',
].join(' ')}
>
{gatewayState.error ?? t('settings.general.gatewayDescription')}
</div>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<div className="flex flex-wrap items-center gap-3">
<span
className={[
'inline-flex items-center rounded-full border px-3.5 py-2 text-[14px] font-medium',
gatewayStatusMeta.className,
].join(' ')}
>
{gatewayStatusMeta.label}
</span>
{gatewayState.loading ? (
<span className="text-[13px] text-[#7a8699] dark:text-gray-500">
{t('common.loading')}
</span>
) : null}
</div>
<button
type="button"
onClick={() => {
void gatewayState.restartGateway();
}}
disabled={gatewayState.loading}
className="inline-flex items-center gap-2 rounded-full border border-[#E5E8EE] bg-white px-3.5 py-2 text-[14px] font-medium text-[#171717] transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-60 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 dark:hover:bg-gray-600"
>
<RefreshCw
className={[
'h-4 w-4',
gatewayState.loading ? 'animate-spin' : '',
].join(' ')}
/>
{t('settings.general.gatewayRestart')}
</button>
<button
type="button"
onClick={() => {
void gatewayState.toggleLogs();
}}
disabled={gatewayState.loading && !gatewayState.showLogs}
className="inline-flex items-center gap-2 rounded-full border border-[#E5E8EE] bg-white px-3.5 py-2 text-[14px] font-medium text-[#171717] transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-60 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 dark:hover:bg-gray-600"
>
<FileText className="h-4 w-4" />
{gatewayState.showLogs
? t('settings.general.gatewayHideLogs')
: t('settings.general.gatewayLogs')}
</button>
</div>
</div>
<div className="flex items-center justify-between gap-4 border-t border-dashed border-[#E5E8EE] pt-5 dark:border-gray-700">
<div>
<div className="mb-1 text-[16px] text-[#171717] dark:text-gray-100">
{t('settings.general.gatewayAutoStartTitle')}
</div>
<div className="text-[14px] text-[#99A0AE] dark:text-gray-500">
{t('settings.general.gatewayAutoStartDescription')}
</div>
</div>
<ToggleSwitch
checked={gatewayState.gatewayAutoStart}
onChange={(nextValue) => {
if (gatewayState.loading) return;
void gatewayState.setGatewayAutoStart(nextValue);
}}
/>
</div>
{gatewayState.showLogs ? (
<div className="rounded-[18px] bg-[#0F172A] px-4 py-4 shadow-inner">
<div className="mb-3 text-[13px] font-medium text-white/70">
{t('settings.general.gatewayLogs')}
</div>
<pre className="max-h-72 overflow-auto whitespace-pre-wrap break-words text-[12px] leading-6 text-[#D6E4FF]">
{gatewayState.loading && !gatewayState.logContent
? t('settings.general.gatewayLogsLoading')
: gatewayState.logContent || t('settings.general.gatewayLogsEmpty')}
</pre>
</div>
) : null}
</div>
</div>
</div>
<div className="mt-10">
<div className="mb-6 text-[24px] font-medium text-[#171717] dark:text-gray-100">
{t('settings.general.updatesTitle')}

View File

@@ -4,6 +4,7 @@ import type { LanguageCode, ThemeMode } from '../../types/runtime';
import AccountSettingsPanel from './components/AccountSettingsPanel';
import GeneralSettingsPanel from './components/GeneralSettingsPanel';
import SettingMenu, { type SettingView } from './components/SettingMenu';
import { useGatewaySettingState } from './useGatewaySettingState';
import { useSettingUpdateState } from './useSettingUpdateState';
export default function SettingPage() {
@@ -11,6 +12,7 @@ export default function SettingPage() {
const themeMode = useSettingsStore((state) => state.themeMode);
const language = useSettingsStore((state) => state.language);
const updateState = useSettingUpdateState();
const gatewayState = useGatewaySettingState();
const handleThemeChange = async (nextTheme: ThemeMode) => {
await updateThemeMode(nextTheme);
@@ -33,6 +35,7 @@ export default function SettingPage() {
language={language}
onThemeChange={handleThemeChange}
onLanguageChange={handleLanguageChange}
gatewayState={gatewayState}
updateState={updateState}
/>
)}

View File

@@ -0,0 +1,276 @@
import { useEffect, useState } from 'react';
import { onGatewayEvent } from '../../lib/gateway-client';
import { hostApiFetch } from '../../lib/host-api';
import {
initSettingsStore,
updateGatewayAutoStart,
useSettingsStore,
} from '../../stores/settings';
export type GatewayConnectionStatus = 'connected' | 'disconnected' | 'reconnecting';
type GatewayStatusResponse = {
ok?: boolean;
status?: GatewayConnectionStatus;
initialized?: boolean;
mode?: string | null;
port?: number | string | null;
pid?: number | string | null;
};
type GatewayLogsResponse =
| {
content?: string;
}
| string;
export type GatewayStatusSnapshot = {
ok: boolean;
status: GatewayConnectionStatus;
initialized: boolean;
mode: string | null;
port: number | null;
pid: number | null;
};
export type GatewaySettingState = {
status: GatewayConnectionStatus;
loading: boolean;
showLogs: boolean;
gatewayAutoStart: boolean;
gateway: GatewayStatusSnapshot;
logContent: string;
statusLoading: boolean;
logLoading: boolean;
restarting: boolean;
autoStartUpdating: boolean;
error: string | null;
lastFetchedAt: string | null;
refreshStatus: () => Promise<void>;
restartGateway: () => Promise<void>;
toggleLogs: () => Promise<void>;
loadLogs: (tailLines?: number) => Promise<void>;
viewLogs: (tailLines?: number) => Promise<void>;
setGatewayAutoStart: (nextValue: boolean) => Promise<void>;
};
const DEFAULT_GATEWAY_STATUS: GatewayStatusSnapshot = {
ok: false,
status: 'disconnected',
initialized: false,
mode: null,
port: null,
pid: null,
};
function getErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
return String(error);
}
function normalizeStatus(status: unknown): GatewayConnectionStatus {
if (status === 'connected' || status === 'disconnected' || status === 'reconnecting') {
return status;
}
return 'disconnected';
}
function normalizeOptionalNumber(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
function normalizeGatewayStatus(response: GatewayStatusResponse | null | undefined): GatewayStatusSnapshot {
const status = normalizeStatus(response?.status);
const initialized = Boolean(response?.initialized);
return {
ok: typeof response?.ok === 'boolean' ? response.ok : initialized && status === 'connected',
status,
initialized,
mode: typeof response?.mode === 'string' && response.mode ? response.mode : null,
port: normalizeOptionalNumber(response?.port),
pid: normalizeOptionalNumber(response?.pid),
};
}
function normalizeLogContent(response: GatewayLogsResponse): string {
if (typeof response === 'string') {
return response;
}
return typeof response.content === 'string' ? response.content : '';
}
export function useGatewaySettingState(): GatewaySettingState {
const gatewayAutoStart = useSettingsStore((current) => current.gatewayAutoStart);
const [gateway, setGateway] = useState<GatewayStatusSnapshot>(DEFAULT_GATEWAY_STATUS);
const [logContent, setLogContent] = useState('');
const [showLogs, setShowLogs] = useState(false);
const [statusLoading, setStatusLoading] = useState(true);
const [logLoading, setLogLoading] = useState(false);
const [restarting, setRestarting] = useState(false);
const [autoStartUpdating, setAutoStartUpdating] = useState(false);
const [error, setError] = useState<string | null>(null);
const [lastFetchedAt, setLastFetchedAt] = useState<string | null>(null);
useEffect(() => {
let active = true;
async function init() {
setStatusLoading(true);
setError(null);
try {
await initSettingsStore();
const response = await hostApiFetch<GatewayStatusResponse>('/api/gateway/status');
if (!active) return;
setGateway(normalizeGatewayStatus(response));
setLastFetchedAt(new Date().toISOString());
} catch (initError) {
if (!active) return;
setError(getErrorMessage(initError));
} finally {
if (active) {
setStatusLoading(false);
}
}
}
void init();
const unsubscribe = onGatewayEvent((event) => {
if (!active || event.type !== 'gateway:status') {
return;
}
setGateway((current) => ({
...current,
ok: event.status === 'connected',
status: event.status,
initialized: event.status !== 'disconnected' || current.initialized,
}));
setError(null);
});
return () => {
active = false;
unsubscribe();
};
}, []);
const refreshStatus = async () => {
setStatusLoading(true);
setError(null);
try {
const response = await hostApiFetch<GatewayStatusResponse>('/api/gateway/status');
setGateway(normalizeGatewayStatus(response));
setLastFetchedAt(new Date().toISOString());
} catch (refreshError) {
setError(getErrorMessage(refreshError));
} finally {
setStatusLoading(false);
}
};
const restartGateway = async () => {
setRestarting(true);
setError(null);
setGateway((current) => ({
...current,
ok: false,
status: 'reconnecting',
}));
try {
await hostApiFetch<{ success?: boolean }>('/api/gateway/restart', {
method: 'POST',
});
await refreshStatus();
} catch (restartError) {
setError(getErrorMessage(restartError));
} finally {
setRestarting(false);
}
};
const loadLogs = async (tailLines = 100) => {
setLogLoading(true);
setError(null);
try {
const response = await hostApiFetch<GatewayLogsResponse>(`/api/logs?tailLines=${encodeURIComponent(String(tailLines))}`);
setLogContent(normalizeLogContent(response));
setLastFetchedAt(new Date().toISOString());
} catch (logsError) {
setLogContent('');
setError(getErrorMessage(logsError));
} finally {
setLogLoading(false);
}
};
const toggleLogs = async () => {
const nextValue = !showLogs;
setShowLogs(nextValue);
if (!nextValue) {
return;
}
await loadLogs();
};
const setGatewayAutoStart = async (nextValue: boolean) => {
setAutoStartUpdating(true);
setError(null);
try {
await updateGatewayAutoStart(nextValue);
setLastFetchedAt(new Date().toISOString());
} catch (updateError) {
setError(getErrorMessage(updateError));
} finally {
setAutoStartUpdating(false);
}
};
const loading =
statusLoading
|| logLoading
|| restarting
|| autoStartUpdating;
return {
status: gateway.status,
loading,
showLogs,
gatewayAutoStart,
gateway,
logContent,
statusLoading,
logLoading,
restarting,
autoStartUpdating,
error,
lastFetchedAt,
refreshStatus,
restartGateway,
toggleLogs,
loadLogs,
viewLogs: loadLogs,
setGatewayAutoStart,
};
}

View File

@@ -13,4 +13,6 @@ export {
updateFontSize as setFontSize,
updateMinimizeToTray as setMinimizeToTray,
updatePrimaryColor as setPrimaryColor,
updateGatewayAutoStart,
updateGatewayAutoStart as setGatewayAutoStart,
} from './settings';

View File

@@ -29,6 +29,7 @@ export interface SettingsState {
minimizeToTray: boolean;
providerId: string | null;
defaultModel: string | null;
gatewayAutoStart: boolean;
}
const STORAGE_PREFIX = 'zn-ai-react:';
@@ -73,6 +74,7 @@ function createInitialState(): SettingsState {
minimizeToTray: false,
providerId: null,
defaultModel: null,
gatewayAutoStart: true,
};
}
@@ -203,7 +205,7 @@ async function hydrate(): Promise<SettingsState> {
const systemTheme = detectSystemTheme();
const systemLanguage = detectSystemLanguage();
const [themeMode, language, fontSize, minimizeToTray, primaryColor, providerId, defaultModel] = await Promise.all([
const [themeMode, language, fontSize, minimizeToTray, primaryColor, providerId, defaultModel, gatewayAutoStart] = await Promise.all([
readThemeMode(),
readConfigValue<LanguageCode>(CONFIG_KEYS.LANGUAGE, systemLanguage),
readConfigValue<number>(CONFIG_KEYS.FONT_SIZE, 14),
@@ -211,6 +213,7 @@ async function hydrate(): Promise<SettingsState> {
readConfigValue<string>(CONFIG_KEYS.PRIMARY_COLOR, '#1677ff'),
readConfigValue<string | null>(CONFIG_KEYS.PROVIDER, null),
readConfigValue<string | null>(CONFIG_KEYS.DEFAULT_MODEL, null),
readConfigValue<boolean>(CONFIG_KEYS.GATEWAY_AUTO_START, true),
]);
const resolvedLanguage = resolveSupportedLanguage(language ?? systemLanguage);
@@ -230,6 +233,7 @@ async function hydrate(): Promise<SettingsState> {
minimizeToTray: Boolean(minimizeToTray),
providerId: providerId ?? null,
defaultModel: defaultModel ?? null,
gatewayAutoStart: Boolean(gatewayAutoStart),
});
applyLocale(resolvedLanguage);
@@ -317,6 +321,21 @@ async function setPrimaryColor(primaryColor: string, persist = true): Promise<Se
return state;
}
async function setGatewayAutoStart(gatewayAutoStart: boolean, persist = true): Promise<SettingsState> {
const next = Boolean(gatewayAutoStart);
if (state.gatewayAutoStart === next && state.initialized) return state;
patchState({
gatewayAutoStart: next,
});
if (persist) {
await writeConfigValue(CONFIG_KEYS.GATEWAY_AUTO_START, next);
}
return state;
}
function getSnapshot(): SettingsState {
return state;
}
@@ -335,6 +354,7 @@ export const settingsStore = {
setFontSize,
setMinimizeToTray,
setPrimaryColor,
setGatewayAutoStart,
hostApiFetch,
};
@@ -371,4 +391,8 @@ export async function updateMinimizeToTray(minimizeToTray: boolean, persist = tr
return setMinimizeToTray(minimizeToTray, persist);
}
export async function updateGatewayAutoStart(gatewayAutoStart: boolean, persist = true): Promise<SettingsState> {
return setGatewayAutoStart(gatewayAutoStart, persist);
}
export { i18n };

View File

@@ -30,6 +30,7 @@ export const CONFIG_KEYS = {
MINIMIZE_TO_TRAY: 'minimizeToTray',
PROVIDER: 'provider',
DEFAULT_MODEL: 'defaultModel',
GATEWAY_AUTO_START: 'gatewayAutoStart',
SELECTED_CHANNELS: 'selectedChannels',
IMAGE_CACHE: 'imageCache',
TASK_LIST: 'taskList',
@@ -47,6 +48,7 @@ export interface ConfigValueMap {
minimizeToTray: boolean;
provider: string | null;
defaultModel: string | null;
gatewayAutoStart: boolean;
selectedChannels: Array<{ id: string; channelName: string; channelUrl: string }>;
imageCache: Array<[string, unknown]>;
taskList: Task[];