1230 lines
42 KiB
TypeScript
1230 lines
42 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
import { invoke } from '@tauri-apps/api/core';
|
|
import { listen } from '@tauri-apps/api/event';
|
|
import { AlertCircle, Check, Copy, ExternalLink, Eye, EyeOff, Info } from 'lucide-react';
|
|
import { type CoreStatus, useCoreRuntime } from '../coreRuntime';
|
|
import openaiIcon from '../assets/icons/openai-light.svg';
|
|
import claudeIcon from '../assets/icons/claude.svg';
|
|
import geminiIcon from '../assets/icons/gemini.svg';
|
|
import { clientApiProfiles } from '../services/clientAccess';
|
|
import packageMetadata from '../../package.json';
|
|
import { useI18n } from '../i18n';
|
|
import { useAppUpdate } from '../appUpdate';
|
|
|
|
type CorePlatform = {
|
|
os: string;
|
|
arch: string;
|
|
assetOs: string;
|
|
assetArch: string;
|
|
archiveKind: 'tar.gz' | 'zip';
|
|
};
|
|
|
|
type CoreLatest = {
|
|
version: string;
|
|
assetName: string;
|
|
};
|
|
|
|
type BundledCoreInfo = {
|
|
version: string;
|
|
assetName: string;
|
|
sizeBytes: number;
|
|
};
|
|
|
|
type CoreInstallResult = {
|
|
version: string;
|
|
assetName: string;
|
|
installDir: string;
|
|
binaryPath: string | null;
|
|
};
|
|
|
|
type CoreInstallTask = {
|
|
running: boolean;
|
|
cancellable: boolean;
|
|
phase: string;
|
|
downloaded: number;
|
|
total: number | null;
|
|
percent: number | null;
|
|
message: string | null;
|
|
result: CoreInstallResult | null;
|
|
};
|
|
|
|
type MessageType = 'info' | 'success' | 'error';
|
|
type CoreProcessCommand = 'start_core_process' | 'stop_core_process' | 'restart_core_process';
|
|
|
|
type GuiSettings = {
|
|
port: number;
|
|
allowLan: boolean;
|
|
runOnStartup: boolean;
|
|
};
|
|
|
|
type CoreConfigSummary = {
|
|
apiKeys: Array<{ apiKey: string }>;
|
|
};
|
|
|
|
const APP_RELEASE_URL = 'https://github.com/router-for-me/EasyCLIProxyAPI/releases/latest';
|
|
|
|
let latestAutoCheckStarted = false;
|
|
let cachedLatest: CoreLatest | null = null;
|
|
let cachedLatestError = '';
|
|
let latestCheckPromise: Promise<CoreLatest> | null = null;
|
|
|
|
function displayAppVersion(version: string) {
|
|
const resolvedVersion = version.trim() || packageMetadata.version;
|
|
return resolvedVersion.startsWith('v') ? resolvedVersion : `v${resolvedVersion}`;
|
|
}
|
|
|
|
function requestLatestCore() {
|
|
if (latestCheckPromise) {
|
|
return latestCheckPromise;
|
|
}
|
|
|
|
latestCheckPromise = invoke<CoreLatest>('check_latest_core')
|
|
.then((result) => {
|
|
cachedLatest = result;
|
|
cachedLatestError = '';
|
|
return result;
|
|
})
|
|
.catch((error) => {
|
|
cachedLatest = null;
|
|
cachedLatestError = String(error);
|
|
throw error;
|
|
})
|
|
.finally(() => {
|
|
latestCheckPromise = null;
|
|
});
|
|
|
|
return latestCheckPromise;
|
|
}
|
|
|
|
export type KernelView = 'home' | 'versions';
|
|
|
|
export function KernelPage({ view = 'home' }: { view?: KernelView }) {
|
|
const { t } = useI18n();
|
|
const {
|
|
info: appUpdate,
|
|
error: appUpdateError,
|
|
checking: checkingAppUpdate,
|
|
task: appUpdateTask,
|
|
check: checkAppUpdate,
|
|
requestInstall: requestAppUpdate,
|
|
} = useAppUpdate();
|
|
const {
|
|
status: coreStatus,
|
|
statusError,
|
|
refreshStatus,
|
|
publishStatus,
|
|
} = useCoreRuntime();
|
|
const [platform, setPlatform] = useState<CorePlatform | null>(null);
|
|
const [platformError, setPlatformError] = useState('');
|
|
const [latest, setLatest] = useState<CoreLatest | null>(cachedLatest);
|
|
const [latestError, setLatestError] = useState(cachedLatestError);
|
|
const [bundledCore, setBundledCore] = useState<BundledCoreInfo | null>(null);
|
|
const [bundledCoreError, setBundledCoreError] = useState('');
|
|
const [checkingLatest, setCheckingLatest] = useState(Boolean(latestCheckPromise));
|
|
const [allowLanAccess, setAllowLanAccess] = useState(false);
|
|
const [customPort, setCustomPort] = useState('8317');
|
|
const [settingsLoaded, setSettingsLoaded] = useState(false);
|
|
const [settingsError, setSettingsError] = useState('');
|
|
const [installing, setInstalling] = useState(false);
|
|
const [processBusy, setProcessBusy] = useState(false);
|
|
const [networkBusy, setNetworkBusy] = useState(false);
|
|
const [processNotice, setProcessNotice] = useState<{
|
|
message: string;
|
|
tone: MessageType;
|
|
} | null>(null);
|
|
const [message, setMessage] = useState('');
|
|
const [messageType, setMessageType] = useState<MessageType>('info');
|
|
const [progress, setProgress] = useState<CoreInstallTask | null>(null);
|
|
const [installDialogOpen, setInstallDialogOpen] = useState(false);
|
|
const [cancellingInstall, setCancellingInstall] = useState(false);
|
|
const [copiedApiField, setCopiedApiField] = useState('');
|
|
const [homeApiKey, setHomeApiKey] = useState<string | null | undefined>(undefined);
|
|
const [homeApiKeyError, setHomeApiKeyError] = useState(false);
|
|
const [showHomeApiKey, setShowHomeApiKey] = useState(false);
|
|
const [lanIpv4, setLanIpv4] = useState<string | null>(null);
|
|
const [lanIpChecked, setLanIpChecked] = useState(false);
|
|
const installDialogRef = useRef<HTMLDivElement>(null);
|
|
const savedPortRef = useRef(8317);
|
|
const savedAllowLanRef = useRef(false);
|
|
const settingsSaveRequestRef = useRef(0);
|
|
const processNoticeTimerRef = useRef<number | null>(null);
|
|
const copiedApiTimerRef = useRef<number | null>(null);
|
|
|
|
const showProcessNotice = (message: string, tone: MessageType) => {
|
|
if (processNoticeTimerRef.current !== null) {
|
|
window.clearTimeout(processNoticeTimerRef.current);
|
|
}
|
|
|
|
setProcessNotice({ message, tone });
|
|
processNoticeTimerRef.current = window.setTimeout(() => {
|
|
setProcessNotice(null);
|
|
processNoticeTimerRef.current = null;
|
|
}, 3600);
|
|
};
|
|
|
|
const applyInstallTask = (task: CoreInstallTask, showFinishedDialog = true) => {
|
|
if (!task.running && !task.message && !task.result) {
|
|
setProgress(null);
|
|
setInstalling(false);
|
|
setCancellingInstall(false);
|
|
return;
|
|
}
|
|
|
|
setInstalling(task.running);
|
|
if (!task.running) {
|
|
setCancellingInstall(false);
|
|
}
|
|
|
|
if (task.running || showFinishedDialog) {
|
|
setProgress(task);
|
|
setInstallDialogOpen(true);
|
|
} else {
|
|
setProgress(null);
|
|
}
|
|
|
|
if (task.result) {
|
|
setMessage(task.message || t('kernel.install.completed', { version: task.result.version }));
|
|
setMessageType('success');
|
|
void refreshStatus();
|
|
return;
|
|
}
|
|
|
|
if (task.message) {
|
|
setMessage(task.message);
|
|
setMessageType(task.phase === '安装失败' ? 'error' : 'info');
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
let disposed = false;
|
|
let unlisten: (() => void) | null = null;
|
|
|
|
listen<CoreInstallTask>('core-install-progress', (event) => {
|
|
applyInstallTask(event.payload);
|
|
})
|
|
.then((unlistenProgress) => {
|
|
if (disposed) {
|
|
unlistenProgress();
|
|
} else {
|
|
unlisten = unlistenProgress;
|
|
}
|
|
})
|
|
.catch((error) => {
|
|
if (!disposed) {
|
|
setMessage(t('kernel.error.progressListener', { error: String(error) }));
|
|
setMessageType('error');
|
|
}
|
|
});
|
|
|
|
loadPlatform();
|
|
loadBundledCore();
|
|
loadInstallTask();
|
|
loadGuiSettings();
|
|
if (view === 'home') {
|
|
void loadHomeApiKey();
|
|
}
|
|
|
|
if (!latestAutoCheckStarted) {
|
|
latestAutoCheckStarted = true;
|
|
void checkLatest();
|
|
} else if (latestCheckPromise) {
|
|
void checkLatest();
|
|
}
|
|
|
|
return () => {
|
|
disposed = true;
|
|
unlisten?.();
|
|
if (processNoticeTimerRef.current !== null) {
|
|
window.clearTimeout(processNoticeTimerRef.current);
|
|
}
|
|
if (copiedApiTimerRef.current !== null) {
|
|
window.clearTimeout(copiedApiTimerRef.current);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!installDialogOpen) {
|
|
return;
|
|
}
|
|
|
|
const previousOverflow = document.body.style.overflow;
|
|
document.body.style.overflow = 'hidden';
|
|
installDialogRef.current?.focus();
|
|
|
|
const preventEscapeClose = (event: KeyboardEvent) => {
|
|
if (event.key === 'Escape') {
|
|
event.preventDefault();
|
|
}
|
|
};
|
|
|
|
document.addEventListener('keydown', preventEscapeClose);
|
|
|
|
return () => {
|
|
document.body.style.overflow = previousOverflow;
|
|
document.removeEventListener('keydown', preventEscapeClose);
|
|
};
|
|
}, [installDialogOpen]);
|
|
|
|
useEffect(() => {
|
|
let disposed = false;
|
|
if (!allowLanAccess) {
|
|
setLanIpv4(null);
|
|
setLanIpChecked(false);
|
|
return;
|
|
}
|
|
|
|
setLanIpChecked(false);
|
|
invoke<string | null>('get_lan_ipv4')
|
|
.then((address) => {
|
|
if (!disposed) {
|
|
setLanIpv4(address || null);
|
|
}
|
|
})
|
|
.catch(() => {
|
|
if (!disposed) {
|
|
setLanIpv4(null);
|
|
}
|
|
})
|
|
.finally(() => {
|
|
if (!disposed) {
|
|
setLanIpChecked(true);
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
disposed = true;
|
|
};
|
|
}, [allowLanAccess]);
|
|
|
|
const runCoreProcessCommand = async (
|
|
command: CoreProcessCommand,
|
|
messages?: { success?: string; failure?: string },
|
|
) => {
|
|
const actionLabel =
|
|
command === 'start_core_process'
|
|
? t('kernel.action.start')
|
|
: command === 'stop_core_process'
|
|
? t('kernel.action.stop')
|
|
: t('kernel.action.restart');
|
|
setProcessBusy(true);
|
|
|
|
try {
|
|
const result = await invoke<CoreStatus>(command);
|
|
publishStatus(result);
|
|
showProcessNotice(messages?.success ?? t('kernel.notice.actionSuccess', { action: actionLabel }), 'success');
|
|
return true;
|
|
} catch (error) {
|
|
const errorMessage = String(error);
|
|
await refreshStatus();
|
|
showProcessNotice(
|
|
messages?.failure
|
|
? `${messages.failure}: ${errorMessage}`
|
|
: t('kernel.notice.actionFailed', { action: actionLabel, error: errorMessage }),
|
|
'error',
|
|
);
|
|
return false;
|
|
} finally {
|
|
setProcessBusy(false);
|
|
}
|
|
};
|
|
|
|
const loadGuiSettings = async () => {
|
|
try {
|
|
const settings = await invoke<GuiSettings>('get_gui_settings');
|
|
setAllowLanAccess(settings.allowLan);
|
|
setCustomPort(String(settings.port));
|
|
savedPortRef.current = settings.port;
|
|
savedAllowLanRef.current = settings.allowLan;
|
|
setSettingsError('');
|
|
} catch (error) {
|
|
setSettingsError(String(error));
|
|
} finally {
|
|
setSettingsLoaded(true);
|
|
}
|
|
};
|
|
|
|
const loadHomeApiKey = async () => {
|
|
try {
|
|
const settings = await invoke<CoreConfigSummary>('get_core_config_settings');
|
|
setHomeApiKey(settings.apiKeys[0]?.apiKey ?? null);
|
|
setHomeApiKeyError(false);
|
|
} catch {
|
|
setHomeApiKey(undefined);
|
|
setHomeApiKeyError(true);
|
|
}
|
|
};
|
|
|
|
const saveNetworkSettings = async (
|
|
allowLan: boolean,
|
|
port: number,
|
|
restartAfterSave = false,
|
|
) => {
|
|
const requestId = ++settingsSaveRequestRef.current;
|
|
|
|
try {
|
|
const settings = await invoke<GuiSettings>('save_gui_settings', {
|
|
settings: { allowLan, port },
|
|
});
|
|
if (requestId !== settingsSaveRequestRef.current) {
|
|
return;
|
|
}
|
|
setAllowLanAccess(settings.allowLan);
|
|
setCustomPort(String(settings.port));
|
|
savedPortRef.current = settings.port;
|
|
savedAllowLanRef.current = settings.allowLan;
|
|
setSettingsError('');
|
|
|
|
if (restartAfterSave) {
|
|
if (coreStatus?.running) {
|
|
await runCoreProcessCommand('restart_core_process', {
|
|
success: t('kernel.notice.networkRestarted'),
|
|
failure: t('kernel.notice.networkRestartFailed'),
|
|
});
|
|
} else {
|
|
showProcessNotice(t('kernel.notice.networkNextStart'), 'info');
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (requestId !== settingsSaveRequestRef.current) {
|
|
return;
|
|
}
|
|
setAllowLanAccess(savedAllowLanRef.current);
|
|
setCustomPort(String(savedPortRef.current));
|
|
setSettingsError(String(error));
|
|
if (restartAfterSave) {
|
|
showProcessNotice(t('kernel.notice.networkSaveFailed', { error: String(error) }), 'error');
|
|
}
|
|
}
|
|
};
|
|
|
|
const updateAllowLanAccess = (allowLan: boolean) => {
|
|
const editedPort = Number(customPort);
|
|
const port =
|
|
Number.isInteger(editedPort) && editedPort >= 1 && editedPort <= 65535
|
|
? editedPort
|
|
: savedPortRef.current;
|
|
setAllowLanAccess(allowLan);
|
|
setNetworkBusy(true);
|
|
void saveNetworkSettings(allowLan, port, true).finally(() => setNetworkBusy(false));
|
|
};
|
|
|
|
const commitCustomPort = () => {
|
|
const port = Number(customPort);
|
|
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
setCustomPort(String(savedPortRef.current));
|
|
setSettingsError(t('kernel.error.port'));
|
|
return;
|
|
}
|
|
|
|
setNetworkBusy(true);
|
|
void saveNetworkSettings(allowLanAccess, port, port !== savedPortRef.current)
|
|
.finally(() => setNetworkBusy(false));
|
|
};
|
|
|
|
const loadPlatform = async () => {
|
|
try {
|
|
const result = await invoke<CorePlatform>('detect_core_platform');
|
|
setPlatform(result);
|
|
setPlatformError('');
|
|
} catch (error) {
|
|
setPlatform(null);
|
|
setPlatformError(String(error));
|
|
}
|
|
};
|
|
|
|
const loadBundledCore = async () => {
|
|
try {
|
|
const result = await invoke<BundledCoreInfo | null>('detect_bundled_core');
|
|
setBundledCore(result);
|
|
setBundledCoreError('');
|
|
} catch (error) {
|
|
setBundledCore(null);
|
|
setBundledCoreError(String(error));
|
|
}
|
|
};
|
|
|
|
const checkLatest = async () => {
|
|
setCheckingLatest(true);
|
|
setLatestError('');
|
|
setMessage('');
|
|
setMessageType('info');
|
|
|
|
try {
|
|
const result = await requestLatestCore();
|
|
setLatest(result);
|
|
} catch (error) {
|
|
setLatest(null);
|
|
setLatestError(String(error));
|
|
} finally {
|
|
setCheckingLatest(false);
|
|
}
|
|
};
|
|
|
|
const loadInstallTask = async () => {
|
|
try {
|
|
const task = await invoke<CoreInstallTask>('get_core_install_task');
|
|
applyInstallTask(task, false);
|
|
} catch (error) {
|
|
setMessage(t('kernel.error.installTask', { error: String(error) }));
|
|
setMessageType('error');
|
|
}
|
|
};
|
|
|
|
const installVersion = async (version: string) => {
|
|
setInstalling(true);
|
|
setMessage(t('kernel.install.installingVersion', { version }));
|
|
setMessageType('info');
|
|
setCancellingInstall(false);
|
|
setInstallDialogOpen(true);
|
|
setProgress({
|
|
running: true,
|
|
cancellable: true,
|
|
phase: '准备下载',
|
|
downloaded: 0,
|
|
total: null,
|
|
percent: null,
|
|
message: null,
|
|
result: null,
|
|
});
|
|
|
|
try {
|
|
const result = await invoke<CoreInstallResult>('install_core_version', { version });
|
|
setMessage(t('kernel.install.completed', { version: result.version }));
|
|
setMessageType('success');
|
|
setProgress({
|
|
running: false,
|
|
cancellable: false,
|
|
phase: '安装完成',
|
|
downloaded: 1,
|
|
total: 1,
|
|
percent: 100,
|
|
message: t('kernel.install.completed', { version: result.version }),
|
|
result,
|
|
});
|
|
await Promise.all([refreshStatus(), loadBundledCore()]);
|
|
} catch (error) {
|
|
const errorMessage = String(error);
|
|
setMessage(errorMessage);
|
|
setMessageType(errorMessage.includes('取消') ? 'info' : 'error');
|
|
setProgress((current) => ({
|
|
running: false,
|
|
cancellable: false,
|
|
phase: errorMessage.includes('取消') ? '已取消' : '安装失败',
|
|
downloaded: current?.downloaded ?? 0,
|
|
total: current?.total ?? null,
|
|
percent: current?.percent ?? null,
|
|
message: errorMessage,
|
|
result: null,
|
|
}));
|
|
} finally {
|
|
setInstalling(false);
|
|
}
|
|
};
|
|
|
|
const installBundledCore = async () => {
|
|
if (!bundledCore) return;
|
|
|
|
setInstalling(true);
|
|
setMessage(t('kernel.install.installingBundled', { version: bundledCore.version }));
|
|
setMessageType('info');
|
|
setCancellingInstall(false);
|
|
setInstallDialogOpen(true);
|
|
setProgress({
|
|
running: true,
|
|
cancellable: false,
|
|
phase: '准备内置内核',
|
|
downloaded: 0,
|
|
total: bundledCore.sizeBytes,
|
|
percent: 0,
|
|
message: null,
|
|
result: null,
|
|
});
|
|
try {
|
|
const result = await invoke<CoreInstallResult>('install_bundled_core');
|
|
setMessage(t('kernel.install.bundledCompleted', { version: result.version }));
|
|
setMessageType('success');
|
|
await Promise.all([refreshStatus(), loadBundledCore()]);
|
|
} catch (error) {
|
|
const errorMessage = String(error);
|
|
setMessage(errorMessage);
|
|
setMessageType('error');
|
|
setProgress((current) => ({
|
|
running: false,
|
|
cancellable: false,
|
|
phase: '安装失败',
|
|
downloaded: current?.downloaded ?? 0,
|
|
total: current?.total ?? null,
|
|
percent: current?.percent ?? null,
|
|
message: errorMessage,
|
|
result: null,
|
|
}));
|
|
} finally {
|
|
setInstalling(false);
|
|
}
|
|
};
|
|
|
|
const cancelInstall = async () => {
|
|
if (cancellingInstall || !progress?.running || !progress.cancellable) {
|
|
return;
|
|
}
|
|
|
|
setCancellingInstall(true);
|
|
setMessage(t('kernel.install.cancelling'));
|
|
setMessageType('info');
|
|
|
|
try {
|
|
await invoke('cancel_core_install');
|
|
} catch (error) {
|
|
setCancellingInstall(false);
|
|
setMessage(String(error));
|
|
setMessageType('error');
|
|
}
|
|
};
|
|
|
|
const closeInstallDialog = () => {
|
|
if (installing || progress?.running) {
|
|
return;
|
|
}
|
|
|
|
setInstallDialogOpen(false);
|
|
setProgress(null);
|
|
setCancellingInstall(false);
|
|
};
|
|
|
|
const copyApiValue = async (value: string, field: string, message: string) => {
|
|
try {
|
|
await navigator.clipboard.writeText(value);
|
|
setCopiedApiField(field);
|
|
showProcessNotice(message, 'success');
|
|
if (copiedApiTimerRef.current !== null) {
|
|
window.clearTimeout(copiedApiTimerRef.current);
|
|
}
|
|
copiedApiTimerRef.current = window.setTimeout(() => {
|
|
setCopiedApiField('');
|
|
copiedApiTimerRef.current = null;
|
|
}, 1800);
|
|
} catch {
|
|
showProcessNotice(t('kernel.notice.copyFailed'), 'error');
|
|
}
|
|
};
|
|
|
|
const openAppRelease = async () => {
|
|
try {
|
|
await invoke('open_external_url', { url: appUpdate?.releaseUrl || APP_RELEASE_URL });
|
|
} catch (error) {
|
|
showProcessNotice(t('kernel.error.openUpdate', { error: String(error) }), 'error');
|
|
}
|
|
};
|
|
|
|
const latestVersion = latest?.version ?? '';
|
|
const currentVersion = coreStatus?.currentVersion ?? '';
|
|
const coreInstalled = Boolean(coreStatus?.installed);
|
|
const coreRunning = Boolean(coreStatus?.running);
|
|
const busy = checkingLatest || installing || processBusy;
|
|
const installDisabled = busy || Boolean(coreStatus?.running);
|
|
const offlineInstallDisabled = installing || processBusy || coreRunning;
|
|
const computedPercent =
|
|
progress?.percent ??
|
|
(progress?.total && progress.total > 0 ? (progress.downloaded / progress.total) * 100 : null);
|
|
const progressKnown = computedPercent !== null;
|
|
const progressPercent = clampPercent(computedPercent ?? 0);
|
|
const progressText = progress
|
|
? progress.phase === '安装完成'
|
|
? t('kernel.progress.completed')
|
|
: progress.phase === '解压中'
|
|
? t('kernel.progress.extracting')
|
|
: progress.total
|
|
? `${formatBytes(progress.downloaded)} / ${formatBytes(progress.total)}`
|
|
: progress.downloaded > 0
|
|
? formatBytes(progress.downloaded)
|
|
: t('kernel.progress.waiting')
|
|
: '';
|
|
const statusTone = statusError ? 'error' : coreRunning ? 'success' : 'neutral';
|
|
const statusLabel = coreStatus
|
|
? coreRunning
|
|
? t('kernel.status.running')
|
|
: coreInstalled
|
|
? t('kernel.status.stopped')
|
|
: t('kernel.status.notInstalled')
|
|
: statusError
|
|
? t('common.detectionFailed')
|
|
: t('common.detecting');
|
|
const currentAppVersion = displayAppVersion(appUpdate?.currentVersion || packageMetadata.version);
|
|
const latestLabel = checkingLatest
|
|
? t('kernel.update.checking')
|
|
: latestVersion || (latestError ? t('kernel.update.failed') : t('kernel.update.notChecked'));
|
|
const updateStateLabel = checkingLatest
|
|
? t('kernel.update.statusChecking')
|
|
: latestError
|
|
? t('kernel.update.failed')
|
|
: !latestVersion
|
|
? t('kernel.update.notYetChecked')
|
|
: currentVersion === latestVersion
|
|
? t('kernel.update.latest')
|
|
: t('kernel.update.available');
|
|
const platformOsLabel = platform?.os || (platformError ? t('common.detectionFailed') : t('common.detecting'));
|
|
const platformArchLabel = platform?.arch || (platformError ? t('common.detectionFailed') : t('common.detecting'));
|
|
const installTaskRunning = Boolean(installing || progress?.running);
|
|
const offlineInstallRequired = Boolean(coreStatus && !coreInstalled && latestError);
|
|
const versionStatusLabel = installTaskRunning
|
|
? cancellingInstall
|
|
? t('kernel.install.cancelling')
|
|
: progress?.phase ? localizeInstallPhase(progress.phase, t) : t('kernel.install.inProgress')
|
|
: offlineInstallRequired
|
|
? t('kernel.install.githubFailed')
|
|
: message || updateStateLabel;
|
|
const versionStatusTone: MessageType = installTaskRunning
|
|
? 'info'
|
|
: offlineInstallRequired
|
|
? 'error'
|
|
: message
|
|
? messageType
|
|
: latestError
|
|
? 'error'
|
|
: currentVersion && currentVersion === latestVersion
|
|
? 'success'
|
|
: 'info';
|
|
const installDialogTone: MessageType = progress?.result
|
|
? 'success'
|
|
: progress?.phase === '安装失败'
|
|
? 'error'
|
|
: 'info';
|
|
const installDialogTitle = installTaskRunning
|
|
? cancellingInstall
|
|
? t('kernel.install.titleCancelling')
|
|
: t('kernel.install.titleInstalling')
|
|
: progress?.result
|
|
? t('kernel.install.titleCompleted')
|
|
: progress?.phase === '已取消'
|
|
? t('kernel.install.titleCancelled')
|
|
: t('kernel.install.titleFailed');
|
|
const installDialogMessage = cancellingInstall
|
|
? t('kernel.install.waitingStop')
|
|
: progress?.message || (installTaskRunning ? message || t('kernel.install.taskRunning') : '');
|
|
const installDialogAction = installTaskRunning
|
|
? cancellingInstall
|
|
? t('kernel.install.cancellingShort')
|
|
: progress?.cancellable
|
|
? t('kernel.install.cancel')
|
|
: t('common.processing')
|
|
: t('common.close');
|
|
const installDialogActionDisabled =
|
|
installTaskRunning && (cancellingInstall || !progress?.cancellable);
|
|
const apiPort = Number(customPort);
|
|
const apiProfiles = clientApiProfiles(
|
|
Number.isInteger(apiPort) && apiPort >= 1 && apiPort <= 65535
|
|
? apiPort
|
|
: savedPortRef.current,
|
|
allowLanAccess ? lanIpv4 : null,
|
|
);
|
|
const apiProfileIcons = {
|
|
openai: openaiIcon,
|
|
claude: claudeIcon,
|
|
gemini: geminiIcon,
|
|
} as const;
|
|
|
|
return (
|
|
<section className={`page kernel-page ${view === 'home' ? 'home-page' : 'version-management-page'}`}>
|
|
<div className={view === 'home' ? 'kernel-layout home-layout' : 'kernel-layout version-management-layout'}>
|
|
{view === 'home' ? (
|
|
<div className="panel control-panel">
|
|
<div className="panel-heading">
|
|
<div>
|
|
<h2>{t('kernel.control.title')}</h2>
|
|
</div>
|
|
<span className={`state-pill ${statusTone}`} title={statusError || undefined}>
|
|
{processBusy || networkBusy ? t('common.processing') : statusLabel}
|
|
</span>
|
|
</div>
|
|
|
|
<dl className="panel-detail-grid">
|
|
<div className="panel-detail-row">
|
|
<dt>{t('kernel.control.installStatus')}</dt>
|
|
<dd>{coreStatus ? (coreInstalled ? t('kernel.control.installed') : t('kernel.status.notInstalled')) : t('common.detecting')}</dd>
|
|
</div>
|
|
<div className="panel-detail-row">
|
|
<dt>{t('kernel.control.runStatus')}</dt>
|
|
<dd>{coreStatus ? (coreRunning ? t('kernel.status.running') : t('kernel.control.notRunning')) : t('common.detecting')}</dd>
|
|
</div>
|
|
<div className="panel-detail-row">
|
|
<dt>{t('kernel.control.pid')}</dt>
|
|
<dd>{coreStatus?.processId || t('kernel.control.noPid')}</dd>
|
|
</div>
|
|
<div className="panel-detail-row">
|
|
<dt>{t('kernel.control.allowLan')}</dt>
|
|
<dd className="detail-control-cell">
|
|
<span className="switch-control" title={t('kernel.control.lanRestartHint')}>
|
|
<input
|
|
type="checkbox"
|
|
aria-label={t('kernel.control.allowLanAria')}
|
|
disabled={!settingsLoaded || networkBusy || processBusy}
|
|
checked={allowLanAccess}
|
|
onChange={(event) => updateAllowLanAccess(event.currentTarget.checked)}
|
|
/>
|
|
<span className="switch-track" />
|
|
</span>
|
|
</dd>
|
|
</div>
|
|
<div className="panel-detail-row">
|
|
<dt>
|
|
<label htmlFor="custom-port">{t('kernel.control.port')}</label>
|
|
</dt>
|
|
<dd className="detail-control-cell">
|
|
<input
|
|
id="custom-port"
|
|
className={`compact-text-input ${settingsError ? 'error' : ''}`}
|
|
type="text"
|
|
inputMode="numeric"
|
|
pattern="[0-9]*"
|
|
maxLength={5}
|
|
placeholder={t('kernel.control.portPlaceholder')}
|
|
title={settingsError || t('kernel.control.portRange')}
|
|
aria-invalid={Boolean(settingsError)}
|
|
disabled={!settingsLoaded || networkBusy || processBusy}
|
|
value={customPort}
|
|
onChange={(event) =>
|
|
setCustomPort(event.currentTarget.value.replace(/\D/g, '').slice(0, 5))
|
|
}
|
|
onBlur={commitCustomPort}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter') {
|
|
event.currentTarget.blur();
|
|
}
|
|
}}
|
|
/>
|
|
</dd>
|
|
</div>
|
|
</dl>
|
|
|
|
<div className="button-row panel-action-row control-action-row">
|
|
<button
|
|
type="button"
|
|
className={coreRunning ? 'danger-button' : 'primary-button'}
|
|
disabled={!coreInstalled || installing || processBusy || networkBusy}
|
|
onClick={() =>
|
|
void runCoreProcessCommand(
|
|
coreRunning ? 'stop_core_process' : 'start_core_process',
|
|
{ success: coreRunning ? t('kernel.notice.stopped') : t('kernel.notice.started') },
|
|
)
|
|
}
|
|
>
|
|
{processBusy ? t('common.processing') : coreRunning ? t('kernel.action.stop') : t('kernel.action.start')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="secondary-button"
|
|
disabled={!coreInstalled || !coreRunning || installing || processBusy || networkBusy}
|
|
onClick={() =>
|
|
void runCoreProcessCommand('restart_core_process', { success: t('kernel.notice.restarted') })
|
|
}
|
|
>
|
|
{t('kernel.action.restart')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="secondary-button"
|
|
disabled={processBusy || networkBusy}
|
|
onClick={() => void refreshStatus()}
|
|
>
|
|
{t('kernel.control.refresh')}
|
|
</button>
|
|
</div>
|
|
|
|
</div>
|
|
) : null}
|
|
|
|
{view === 'versions' ? (
|
|
<div className="panel software-update-panel">
|
|
<div className="panel-heading">
|
|
<div>
|
|
<h2>{t('appUpdate.title')}</h2>
|
|
<p className={appUpdateError ? 'error' : appUpdate?.updateAvailable ? 'success' : ''}>
|
|
{appUpdateError
|
|
|| (appUpdate?.updateAvailable
|
|
? t('appUpdate.available', { version: displayAppVersion(appUpdate.latestVersion) })
|
|
: appUpdate
|
|
? t('appUpdate.upToDate')
|
|
: t('appUpdate.phase.checking'))}
|
|
</p>
|
|
</div>
|
|
<span className={`state-pill ${appUpdate?.updateAvailable ? 'update' : appUpdateError ? 'error' : 'success'}`}>
|
|
{appUpdate?.autoUpdateSupported ? t('appUpdate.portableReady') : t('appUpdate.manualOnly')}
|
|
</span>
|
|
</div>
|
|
|
|
<dl className="panel-detail-grid software-update-details">
|
|
<div className="panel-detail-row">
|
|
<dt>{t('appUpdate.current')}</dt>
|
|
<dd>{currentAppVersion}</dd>
|
|
</div>
|
|
<div className="panel-detail-row">
|
|
<dt>{t('appUpdate.latest')}</dt>
|
|
<dd>{appUpdate ? displayAppVersion(appUpdate.latestVersion) : t('common.detecting')}</dd>
|
|
</div>
|
|
<div className="panel-detail-row">
|
|
<dt>{t('appUpdate.status')}</dt>
|
|
<dd className={appUpdateError ? 'error' : appUpdate?.updateAvailable ? 'success' : ''}>
|
|
{appUpdateTask.running
|
|
? t(`appUpdate.phase.${appUpdateTask.phase}` as Parameters<typeof t>[0])
|
|
: appUpdateError
|
|
? t('kernel.update.failed')
|
|
: appUpdate?.updateAvailable
|
|
? t('appUpdate.available', { version: displayAppVersion(appUpdate.latestVersion) })
|
|
: appUpdate
|
|
? t('appUpdate.upToDate')
|
|
: t('appUpdate.phase.checking')}
|
|
</dd>
|
|
</div>
|
|
</dl>
|
|
|
|
<div className="button-row panel-action-row software-update-actions">
|
|
<button
|
|
type="button"
|
|
className="secondary-button"
|
|
disabled={checkingAppUpdate || appUpdateTask.running}
|
|
onClick={() => void checkAppUpdate()}
|
|
>
|
|
{checkingAppUpdate ? t('appUpdate.checking') : t('appUpdate.check')}
|
|
</button>
|
|
{appUpdate?.updateAvailable && appUpdate.autoUpdateSupported ? (
|
|
<button
|
|
type="button"
|
|
className="primary-button"
|
|
disabled={appUpdateTask.running}
|
|
onClick={requestAppUpdate}
|
|
>
|
|
{t('appUpdate.installNow')}
|
|
</button>
|
|
) : (
|
|
<button type="button" className="secondary-button" onClick={() => void openAppRelease()}>
|
|
{t('appUpdate.openRelease')} <ExternalLink size={14} aria-hidden="true" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{view === 'versions' ? (
|
|
<div className="panel version-panel">
|
|
<div className="panel-heading">
|
|
<div className="version-heading-inline">
|
|
<h2>{t('kernel.versions.title')}</h2>
|
|
<span className="version-offline-hint">
|
|
{t('kernel.versions.offlineHint')}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<dl className="panel-detail-grid">
|
|
<div className="panel-detail-row">
|
|
<dt>{t('kernel.versions.current')}</dt>
|
|
<dd>{currentVersion || t('kernel.status.notInstalled')}</dd>
|
|
</div>
|
|
<div className="panel-detail-row">
|
|
<dt>{t('kernel.versions.latest')}</dt>
|
|
<dd>{latestLabel}</dd>
|
|
</div>
|
|
<div className="panel-detail-row">
|
|
<dt>{t('kernel.versions.bundled')}</dt>
|
|
<dd title={bundledCoreError || bundledCore?.assetName}>
|
|
{bundledCore?.version ?? (bundledCoreError ? t('common.detectionFailed') : t('kernel.versions.notIncluded'))}
|
|
</dd>
|
|
</div>
|
|
<div className="panel-detail-row">
|
|
<dt>{t('kernel.versions.platform')}</dt>
|
|
<dd title={platformError || undefined}>{platformOsLabel} / {platformArchLabel}</dd>
|
|
</div>
|
|
<div className="panel-detail-row">
|
|
<dt>{t('kernel.versions.updateStatus')}</dt>
|
|
<dd className={versionStatusTone} title={versionStatusLabel}>
|
|
{versionStatusLabel}
|
|
</dd>
|
|
</div>
|
|
</dl>
|
|
|
|
<div className="button-row panel-action-row version-action-row">
|
|
<button
|
|
type="button"
|
|
className="secondary-button"
|
|
disabled={busy}
|
|
onClick={checkLatest}
|
|
>
|
|
{checkingLatest ? t('kernel.update.checking') : t('kernel.versions.check')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="secondary-button"
|
|
title={latestVersion ? t('kernel.versions.installVersion', { version: latestVersion }) : t('kernel.versions.installLatest')}
|
|
disabled={!latestVersion || installDisabled}
|
|
onClick={() => installVersion(latestVersion)}
|
|
>
|
|
{t('kernel.versions.installLatest')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="secondary-button"
|
|
title={t('kernel.versions.reinstallTitle')}
|
|
disabled={!currentVersion || installDisabled}
|
|
onClick={() => installVersion(currentVersion)}
|
|
>
|
|
{t('kernel.versions.reinstall')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="primary-button"
|
|
title={(bundledCore?.assetName ?? bundledCoreError) || t('kernel.versions.noBundled')}
|
|
disabled={!bundledCore || offlineInstallDisabled}
|
|
onClick={() => void installBundledCore()}
|
|
>
|
|
{t('kernel.versions.offlineInstall')}
|
|
</button>
|
|
</div>
|
|
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
{view === 'home' ? (
|
|
<section className="panel client-api-panel">
|
|
<div className="panel-heading client-api-heading">
|
|
<div>
|
|
<h2>API URL</h2>
|
|
{typeof homeApiKey === 'string' ? (
|
|
<div className="client-api-key-actions">
|
|
<button
|
|
type="button"
|
|
className="client-api-default-key"
|
|
aria-pressed={showHomeApiKey}
|
|
title={showHomeApiKey ? t('config.keys.hide') : t('config.keys.show')}
|
|
onClick={() => setShowHomeApiKey((visible) => !visible)}
|
|
>
|
|
<span>{t('kernel.access.firstKey')}</span>
|
|
<code>{showHomeApiKey ? homeApiKey : '******'}</code>
|
|
{showHomeApiKey ? (
|
|
<EyeOff size={14} aria-hidden="true" />
|
|
) : (
|
|
<Eye size={14} aria-hidden="true" />
|
|
)}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="icon-button quiet client-api-key-copy"
|
|
title={t('config.keys.copy')}
|
|
aria-label={t('config.keys.copy')}
|
|
onClick={() =>
|
|
void copyApiValue(
|
|
homeApiKey,
|
|
'home:api-key',
|
|
t('config.notice.keyCopied'),
|
|
)
|
|
}
|
|
>
|
|
{copiedApiField === 'home:api-key' ? (
|
|
<Check size={14} aria-hidden="true" />
|
|
) : (
|
|
<Copy size={14} aria-hidden="true" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
) : homeApiKey === null ? (
|
|
<p className="client-api-no-key">{t('kernel.access.noConfiguredKey')}</p>
|
|
) : (
|
|
<p className={homeApiKeyError ? 'error' : undefined}>
|
|
{homeApiKeyError ? t('common.unavailable') : t('common.loading')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<span className={`state-pill ${coreRunning ? 'success' : ''}`}>
|
|
{coreRunning ? t('kernel.access.connectable') : t('kernel.access.waiting')}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="client-api-grid">
|
|
{apiProfiles.map((profile) => (
|
|
<article className={`client-api-card ${profile.id}`} key={profile.id}>
|
|
<div className="client-api-card-heading">
|
|
<span className="client-api-logo">
|
|
<img src={apiProfileIcons[profile.id]} alt="" />
|
|
</span>
|
|
<div>
|
|
<strong>{profile.name}</strong>
|
|
<span>{profile.description}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="client-api-values">
|
|
<div className="client-api-value-row">
|
|
<span>{t('kernel.access.localUrl')}</span>
|
|
<code title={profile.baseUrl}>{profile.baseUrl}</code>
|
|
<button
|
|
type="button"
|
|
className="icon-button quiet"
|
|
onClick={() =>
|
|
void copyApiValue(
|
|
profile.baseUrl,
|
|
`${profile.id}:base`,
|
|
t('kernel.access.localCopied', { name: profile.name }),
|
|
)
|
|
}
|
|
title={t('kernel.access.copyLocal', { name: profile.name })}
|
|
aria-label={t('kernel.access.copyLocal', { name: profile.name })}
|
|
>
|
|
{copiedApiField === `${profile.id}:base` ? (
|
|
<Check size={15} aria-hidden="true" />
|
|
) : (
|
|
<Copy size={15} aria-hidden="true" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
{allowLanAccess ? (
|
|
<div className="client-api-value-row">
|
|
<span>{t('kernel.access.lanUrl')}</span>
|
|
<code title={profile.lanUrl || undefined}>
|
|
{!lanIpChecked
|
|
? t('kernel.access.detectingIp')
|
|
: profile.lanUrl || t('kernel.access.noIp')}
|
|
</code>
|
|
{profile.lanUrl ? (
|
|
<button
|
|
type="button"
|
|
className="icon-button quiet"
|
|
onClick={() =>
|
|
void copyApiValue(
|
|
profile.lanUrl!,
|
|
`${profile.id}:lan`,
|
|
t('kernel.access.lanCopied', { name: profile.name }),
|
|
)
|
|
}
|
|
title={t('kernel.access.copyLan', { name: profile.name })}
|
|
aria-label={t('kernel.access.copyLan', { name: profile.name })}
|
|
>
|
|
{copiedApiField === `${profile.id}:lan` ? (
|
|
<Check size={15} aria-hidden="true" />
|
|
) : (
|
|
<Copy size={15} aria-hidden="true" />
|
|
)}
|
|
</button>
|
|
) : (
|
|
<span className="client-api-copy-placeholder" aria-hidden="true" />
|
|
)}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</article>
|
|
))}
|
|
</div>
|
|
</section>
|
|
) : null}
|
|
|
|
{view === 'versions' && installDialogOpen && progress ? (
|
|
<div className="install-dialog-backdrop">
|
|
<div
|
|
ref={installDialogRef}
|
|
className={`install-dialog ${installDialogTone}`}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="install-dialog-title"
|
|
aria-describedby="install-dialog-message"
|
|
aria-busy={installTaskRunning}
|
|
tabIndex={-1}
|
|
>
|
|
<div className="install-dialog-heading">
|
|
<span>{t('kernel.dialog.install')}</span>
|
|
<h2 id="install-dialog-title">{installDialogTitle}</h2>
|
|
</div>
|
|
|
|
<div className="install-dialog-phase">
|
|
<span>{t('kernel.dialog.phase')}</span>
|
|
<strong>{cancellingInstall ? t('kernel.install.cancellingShort') : localizeInstallPhase(progress.phase, t)}</strong>
|
|
</div>
|
|
|
|
<div
|
|
className={`install-progress-track ${
|
|
progressKnown ? '' : installTaskRunning ? 'unknown is-running' : 'unknown'
|
|
}`}
|
|
>
|
|
<span
|
|
className="install-progress-fill"
|
|
style={progressKnown ? { width: `${progressPercent}%` } : undefined}
|
|
/>
|
|
</div>
|
|
|
|
<div className="install-progress-meta">
|
|
<strong>{progressKnown ? `${progressPercent.toFixed(1)}%` : t('kernel.dialog.unknownProgress')}</strong>
|
|
<span>{progressText}</span>
|
|
</div>
|
|
|
|
<div
|
|
id="install-dialog-message"
|
|
className={`install-dialog-message ${installDialogTone}`}
|
|
aria-live="polite"
|
|
>
|
|
{installDialogMessage || '\u00a0'}
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
className={installTaskRunning ? 'danger-button' : 'primary-button'}
|
|
disabled={installDialogActionDisabled}
|
|
onClick={installTaskRunning ? cancelInstall : closeInstallDialog}
|
|
>
|
|
{installDialogAction}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{processNotice ? (
|
|
<div
|
|
className={`config-toast ${processNotice.tone}`}
|
|
role="status"
|
|
title={processNotice.message}
|
|
>
|
|
{processNotice.tone === 'success' ? (
|
|
<Check size={17} aria-hidden="true" />
|
|
) : processNotice.tone === 'error' ? (
|
|
<AlertCircle size={17} aria-hidden="true" />
|
|
) : (
|
|
<Info size={17} aria-hidden="true" />
|
|
)}
|
|
<span>{processNotice.message}</span>
|
|
</div>
|
|
) : null}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function localizeInstallPhase(
|
|
phase: string,
|
|
t: ReturnType<typeof useI18n>['t'],
|
|
) {
|
|
const keys = {
|
|
'准备下载': 'kernel.phase.preparingDownload',
|
|
'下载中': 'kernel.phase.downloading',
|
|
'解压中': 'kernel.phase.extracting',
|
|
'准备内置内核': 'kernel.phase.preparingBundled',
|
|
'安装完成': 'kernel.phase.completed',
|
|
'安装失败': 'kernel.phase.failed',
|
|
'已取消': 'kernel.phase.cancelled',
|
|
} as const;
|
|
const key = keys[phase as keyof typeof keys];
|
|
return key ? t(key) : phase;
|
|
}
|
|
|
|
function clampPercent(percent: number) {
|
|
return Math.min(100, Math.max(0, percent));
|
|
}
|
|
|
|
function formatBytes(bytes: number) {
|
|
if (bytes < 1024) {
|
|
return `${bytes} B`;
|
|
}
|
|
|
|
if (bytes < 1024 * 1024) {
|
|
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
}
|
|
|
|
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
}
|