-
-
连接信息
-
当前前端占位状态
+
+
+
+
+
+ {loading
+ ? '读取中'
+ : settings === null
+ ? '不可用'
+ : settings.pluginsEnabled
+ ? '已启用'
+ : '已停用'}
+
-
+
+ 启用插件系统
+
+
+
-
-
-
- 配置源
-
- 未接入
+
+
+
+
+
路由策略
+
+
+ {loading
+ ? '读取中'
+ : settings === null
+ ? '不可用'
+ : routingStrategyLabel(settings.routingStrategy)}
+
-
-
- 保存状态
-
- 待实现
+
+ {ROUTING_OPTIONS.map((option) => (
+
+ ))}
-
+
+
+ {addDialogOpen ? (
+ {
+ if (event.currentTarget === event.target) closeAddDialog();
+ }}>
+
+
+ ) : null}
+
+ {deleteIndex !== null ? (
+ {
+ if (event.currentTarget === event.target && busyAction !== 'delete-key') {
+ setDeleteIndex(null);
+ }
+ }}>
+
+
+
{maskApiKey(selectedDeleteKey)}
+
+
+
+
+
+
+ ) : null}
+
+ {notice ? (
+
+ {notice.tone === 'success' ? (
+
+ ) : (
+
+ )}
+
{notice.message}
+
+ ) : null}
);
}
+
+function maskApiKey(apiKey: string) {
+ const value = apiKey.trim();
+ if (!value) {
+ return '';
+ }
+ const visible = value.length < 4 ? 1 : 2;
+ return `${value.slice(0, visible)}${'*'.repeat(Math.max(6, 10 - visible * 2))}${value.slice(-visible)}`;
+}
+
+function routingStrategyLabel(strategy?: string) {
+ if (!strategy) {
+ return '读取中';
+ }
+ return ROUTING_OPTIONS.find((option) => option.value === strategy)?.label ?? strategy;
+}
diff --git a/src/pages/Kernel.tsx b/src/pages/Kernel.tsx
index 1c87b03..31e0380 100644
--- a/src/pages/Kernel.tsx
+++ b/src/pages/Kernel.tsx
@@ -1,6 +1,12 @@
import { useEffect, useRef, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
+import { AlertCircle, Check, Copy, 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, DEFAULT_CLIENT_API_KEY } from '../services/clientAccess';
type CorePlatform = {
os: string;
@@ -10,17 +16,6 @@ type CorePlatform = {
archiveKind: 'tar.gz' | 'zip';
};
-type CoreStatus = {
- installed: boolean;
- running: boolean;
- managed: boolean;
- processId: number | null;
- currentVersion: string | null;
- installDir: string;
- binaryPath: string | null;
- message: string;
-};
-
type CoreLatest = {
version: string;
assetName: string;
@@ -45,6 +40,13 @@ type CoreInstallTask = {
};
type MessageType = 'info' | 'success' | 'error';
+type CoreProcessCommand = 'start_core_process' | 'stop_core_process' | 'restart_core_process';
+
+type GuiSettings = {
+ port: number;
+ allowLan: boolean;
+ runOnStartup: boolean;
+};
let latestAutoCheckStarted = false;
let cachedLatest: CoreLatest | null = null;
@@ -75,6 +77,7 @@ function requestLatestCore() {
}
export function KernelPage() {
+ const { publishStatus } = useCoreRuntime();
const [coreStatus, setCoreStatus] = useState(null);
const [statusError, setStatusError] = useState('');
const [platform, setPlatform] = useState(null);
@@ -83,17 +86,42 @@ export function KernelPage() {
const [latestError, setLatestError] = useState(cachedLatestError);
const [checkingLatest, setCheckingLatest] = useState(Boolean(latestCheckPromise));
const [allowLanAccess, setAllowLanAccess] = useState(false);
- const [customPort, setCustomPort] = useState('');
+ 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 [processMessage, setProcessMessage] = useState('');
- const [processMessageType, setProcessMessageType] = useState('info');
+ const [networkBusy, setNetworkBusy] = useState(false);
+ const [processNotice, setProcessNotice] = useState<{
+ message: string;
+ tone: MessageType;
+ } | null>(null);
const [message, setMessage] = useState('');
const [messageType, setMessageType] = useState('info');
const [progress, setProgress] = useState(null);
const [installDialogOpen, setInstallDialogOpen] = useState(false);
const [cancellingInstall, setCancellingInstall] = useState(false);
+ const [copiedApiField, setCopiedApiField] = useState('');
+ const [lanIpv4, setLanIpv4] = useState(null);
+ const [lanIpChecked, setLanIpChecked] = useState(false);
const installDialogRef = useRef(null);
+ const savedPortRef = useRef(8317);
+ const savedAllowLanRef = useRef(false);
+ const settingsSaveRequestRef = useRef(0);
+ const processNoticeTimerRef = useRef(null);
+ const copiedApiTimerRef = useRef(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) {
@@ -152,6 +180,7 @@ export function KernelPage() {
loadCoreStatus();
loadPlatform();
loadInstallTask();
+ loadGuiSettings();
if (!latestAutoCheckStarted) {
latestAutoCheckStarted = true;
@@ -163,6 +192,12 @@ export function KernelPage() {
return () => {
disposed = true;
unlisten?.();
+ if (processNoticeTimerRef.current !== null) {
+ window.clearTimeout(processNoticeTimerRef.current);
+ }
+ if (copiedApiTimerRef.current !== null) {
+ window.clearTimeout(copiedApiTimerRef.current);
+ }
};
}, []);
@@ -189,17 +224,165 @@ export function KernelPage() {
};
}, [installDialogOpen]);
+ useEffect(() => {
+ let disposed = false;
+ if (!allowLanAccess) {
+ setLanIpv4(null);
+ setLanIpChecked(false);
+ return;
+ }
+
+ setLanIpChecked(false);
+ invoke('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 loadCoreStatus = async () => {
try {
const result = await invoke('get_core_status');
setCoreStatus(result);
+ publishStatus(result);
setStatusError('');
} catch (error) {
setCoreStatus(null);
+ publishStatus(null);
setStatusError(String(error));
}
};
+ const runCoreProcessCommand = async (
+ command: CoreProcessCommand,
+ messages?: { success?: string; failure?: string },
+ ) => {
+ const actionLabel =
+ command === 'start_core_process'
+ ? '启动'
+ : command === 'stop_core_process'
+ ? '关闭'
+ : '重启';
+ setProcessBusy(true);
+
+ try {
+ const result = await invoke(command);
+ setCoreStatus(result);
+ publishStatus(result);
+ setStatusError('');
+ showProcessNotice(messages?.success ?? `内核${actionLabel}成功`, 'success');
+ return true;
+ } catch (error) {
+ const errorMessage = String(error);
+ await loadCoreStatus();
+ setStatusError(errorMessage);
+ showProcessNotice(
+ `${messages?.failure ?? `内核${actionLabel}失败`}:${errorMessage}`,
+ 'error',
+ );
+ return false;
+ } finally {
+ setProcessBusy(false);
+ }
+ };
+
+ const loadGuiSettings = async () => {
+ try {
+ const settings = await invoke('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 saveNetworkSettings = async (
+ allowLan: boolean,
+ port: number,
+ restartAfterSave = false,
+ ) => {
+ const requestId = ++settingsSaveRequestRef.current;
+
+ try {
+ const settings = await invoke('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: '网络设置已保存,内核已重启',
+ failure: '网络设置已保存,但内核重启失败',
+ });
+ } else {
+ showProcessNotice('网络设置已保存,下次启动内核时生效', 'info');
+ }
+ }
+ } catch (error) {
+ if (requestId !== settingsSaveRequestRef.current) {
+ return;
+ }
+ setAllowLanAccess(savedAllowLanRef.current);
+ setCustomPort(String(savedPortRef.current));
+ setSettingsError(String(error));
+ if (restartAfterSave) {
+ showProcessNotice(`保存网络设置失败:${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('端口必须在 1 到 65535 之间');
+ return;
+ }
+
+ setNetworkBusy(true);
+ void saveNetworkSettings(allowLanAccess, port, port !== savedPortRef.current)
+ .finally(() => setNetworkBusy(false));
+ };
+
const loadPlatform = async () => {
try {
const result = await invoke('detect_core_platform');
@@ -317,23 +500,20 @@ export function KernelPage() {
setCancellingInstall(false);
};
- const runCoreProcessCommand = async (command: string, doing: string, done: string) => {
- setProcessBusy(true);
- setProcessMessage(doing);
- setProcessMessageType('info');
-
+ const copyApiValue = async (value: string, field: string, message: string) => {
try {
- const result = await invoke(command);
- setCoreStatus(result);
- setStatusError('');
- setProcessMessage(done);
- setProcessMessageType('success');
- } catch (error) {
- setProcessMessage(String(error));
- setProcessMessageType('error');
- await loadCoreStatus();
- } finally {
- setProcessBusy(false);
+ 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('复制接入信息失败', 'error');
}
};
@@ -369,7 +549,6 @@ export function KernelPage() {
: statusError
? '检测失败'
: '检测中';
- const statusMessage = coreStatus?.message || statusError || '正在检测 CPA 内核';
const latestLabel = checkingLatest
? '检查中'
: latestVersion || (latestError ? '检查失败' : '未检查');
@@ -386,12 +565,6 @@ export function KernelPage() {
const platformOsLabel = platform?.os || (platformError ? '检测失败' : '检测中');
const platformArchLabel = platform?.arch || (platformError ? '检测失败' : '检测中');
const installTaskRunning = Boolean(installing || progress?.running);
- const controlSubtitle = processMessage || statusMessage;
- const controlSubtitleTone: MessageType = processMessage
- ? processMessageType
- : statusError
- ? 'error'
- : 'info';
const versionStatusLabel = installTaskRunning
? cancellingInstall
? '正在取消下载'
@@ -432,6 +605,18 @@ export function KernelPage() {
: '关闭';
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 (
@@ -462,11 +647,10 @@ export function KernelPage() {
运行控制
-
- {controlSubtitle}
-
-
{statusLabel}
+
+ {processBusy || networkBusy ? '处理中' : statusLabel}
+
@@ -483,14 +667,15 @@ export function KernelPage() {
- {coreStatus?.processId || '无'}
-
局域网
+ 允许局域网
-
+
setAllowLanAccess(event.currentTarget.checked)}
+ onChange={(event) => updateAllowLanAccess(event.currentTarget.checked)}
/>
@@ -503,16 +688,25 @@ export function KernelPage() {
setCustomPort(event.currentTarget.value.replace(/\D/g, '').slice(0, 5))
}
+ onBlur={commitCustomPort}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter') {
+ event.currentTarget.blur();
+ }
+ }}
/>
@@ -521,34 +715,23 @@ export function KernelPage() {
-
) : null}
+
+ {processNotice ? (
+
+ {processNotice.tone === 'success' ? (
+
+ ) : processNotice.tone === 'error' ? (
+
+ ) : (
+
+ )}
+
{processNotice.message}
+
+ ) : null}
);
}
diff --git a/src/pages/ManagementPages.tsx b/src/pages/ManagementPages.tsx
new file mode 100644
index 0000000..429efb4
--- /dev/null
+++ b/src/pages/ManagementPages.tsx
@@ -0,0 +1,482 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { invoke } from '@tauri-apps/api/core';
+import {
+ Check,
+ Copy,
+ ExternalLink,
+ LoaderCircle,
+ LogIn,
+} from 'lucide-react';
+import antigravityIcon from '../assets/icons/antigravity.svg';
+import claudeIcon from '../assets/icons/claude.svg';
+import codexIcon from '../assets/icons/codex.svg';
+import grokIcon from '../assets/icons/grok.svg';
+import kimiIcon from '../assets/icons/kimi-light.svg';
+
+type OAuthProviderId = 'codex' | 'claude' | 'antigravity' | 'kimi' | 'xai';
+type OAuthFlowStatus = 'idle' | 'waiting' | 'success' | 'error';
+
+type OAuthProviderState = {
+ url?: string;
+ state?: string;
+ status: OAuthFlowStatus;
+ error?: string;
+ polling?: boolean;
+ callbackUrl?: string;
+ callbackSubmitting?: boolean;
+ callbackStatus?: 'success' | 'error';
+ callbackError?: string;
+};
+
+type OAuthStartResult = {
+ url: string;
+ state?: string | null;
+ opened: boolean;
+ openError?: string | null;
+};
+
+type OAuthStatusResult = {
+ status: string;
+ error?: string | null;
+};
+
+const oauthProviders = [
+ { id: 'codex' as const, name: 'Codex OAuth', icon: codexIcon },
+ { id: 'claude' as const, name: 'Claude OAuth', icon: claudeIcon },
+ { id: 'antigravity' as const, name: 'Antigravity OAuth', icon: antigravityIcon },
+ { id: 'kimi' as const, name: 'Kimi OAuth', icon: kimiIcon },
+ { id: 'xai' as const, name: 'xAI OAuth', icon: grokIcon },
+];
+
+const OAUTH_CALLBACK_SUPPORTED = new Set
([
+ 'codex',
+ 'claude',
+ 'antigravity',
+ 'xai',
+]);
+const XAI_CALLBACK_URL = 'http://127.0.0.1:56121/callback';
+const OAUTH_SUCCESS_RESET_MS = 5000;
+const OAUTH_POLL_INTERVAL_MS = 3000;
+
+export function OAuthLoginPage() {
+ const [states, setStates] = useState>>({});
+ const [notice, setNotice] = useState<{
+ message: string;
+ tone: 'success' | 'error' | 'info';
+ } | null>(null);
+ const pollingTimers = useRef>>({});
+ const pollingRequests = useRef>>({});
+ const successResetTimers = useRef>>({});
+ const noticeTimerRef = useRef(null);
+
+ const showNotice = useCallback((message: string, tone: 'success' | 'error' | 'info') => {
+ if (noticeTimerRef.current !== null) window.clearTimeout(noticeTimerRef.current);
+ setNotice({ message, tone });
+ noticeTimerRef.current = window.setTimeout(() => {
+ setNotice(null);
+ noticeTimerRef.current = null;
+ }, 3600);
+ }, []);
+
+ const updateProviderState = useCallback(
+ (provider: OAuthProviderId, next: Partial) => {
+ setStates((current) => ({
+ ...current,
+ [provider]: {
+ status: 'idle',
+ ...(current[provider] ?? {}),
+ ...next,
+ },
+ }));
+ },
+ [],
+ );
+
+ const clearPollingTimer = useCallback((provider: OAuthProviderId) => {
+ const timer = pollingTimers.current[provider];
+ if (timer !== undefined) window.clearInterval(timer);
+ delete pollingTimers.current[provider];
+ delete pollingRequests.current[provider];
+ }, []);
+
+ const clearSuccessResetTimer = useCallback((provider: OAuthProviderId) => {
+ const timer = successResetTimers.current[provider];
+ if (timer !== undefined) window.clearTimeout(timer);
+ delete successResetTimers.current[provider];
+ }, []);
+
+ const clearProviderTimers = useCallback(
+ (provider: OAuthProviderId) => {
+ clearPollingTimer(provider);
+ clearSuccessResetTimer(provider);
+ },
+ [clearPollingTimer, clearSuccessResetTimer],
+ );
+
+ const resetProviderAttempt = useCallback(
+ (provider: OAuthProviderId) => {
+ clearProviderTimers(provider);
+ setStates((current) => ({ ...current, [provider]: { status: 'idle' } }));
+ },
+ [clearProviderTimers],
+ );
+
+ const completeProviderAuth = useCallback(
+ (provider: OAuthProviderId) => {
+ clearProviderTimers(provider);
+ updateProviderState(provider, {
+ url: undefined,
+ state: undefined,
+ status: 'success',
+ error: undefined,
+ polling: false,
+ callbackUrl: '',
+ callbackSubmitting: false,
+ callbackStatus: undefined,
+ callbackError: undefined,
+ });
+ successResetTimers.current[provider] = window.setTimeout(() => {
+ resetProviderAttempt(provider);
+ }, OAUTH_SUCCESS_RESET_MS);
+ },
+ [clearProviderTimers, resetProviderAttempt, updateProviderState],
+ );
+
+ const startPolling = useCallback(
+ (provider: OAuthProviderId, state: string) => {
+ clearPollingTimer(provider);
+ const checkStatus = async () => {
+ if (pollingRequests.current[provider]) return;
+ pollingRequests.current[provider] = true;
+ try {
+ const result = await invoke('get_oauth_status', { state });
+ const status = (result.status || '').toLowerCase();
+ if (status === 'ok') {
+ completeProviderAuth(provider);
+ showNotice(`${providerLabel(provider)} 登录成功`, 'success');
+ } else if (status === 'error') {
+ updateProviderState(provider, {
+ status: 'error',
+ error: result.error || '认证失败',
+ polling: false,
+ });
+ clearPollingTimer(provider);
+ showNotice(
+ `${providerLabel(provider)} 登录失败${result.error ? `:${result.error}` : ''}`,
+ 'error',
+ );
+ }
+ } catch (error) {
+ updateProviderState(provider, {
+ status: 'error',
+ error: String(error),
+ polling: false,
+ });
+ clearPollingTimer(provider);
+ showNotice(String(error), 'error');
+ } finally {
+ delete pollingRequests.current[provider];
+ }
+ };
+ pollingTimers.current[provider] = window.setInterval(
+ () => void checkStatus(),
+ OAUTH_POLL_INTERVAL_MS,
+ );
+ },
+ [clearPollingTimer, completeProviderAuth, showNotice, updateProviderState],
+ );
+
+ useEffect(() => {
+ return () => {
+ Object.values(pollingTimers.current).forEach((timer) => {
+ if (timer !== undefined) window.clearInterval(timer);
+ });
+ Object.values(successResetTimers.current).forEach((timer) => {
+ if (timer !== undefined) window.clearTimeout(timer);
+ });
+ if (noticeTimerRef.current !== null) window.clearTimeout(noticeTimerRef.current);
+ };
+ }, []);
+
+ const startLogin = async (provider: OAuthProviderId) => {
+ clearProviderTimers(provider);
+ updateProviderState(provider, {
+ url: undefined,
+ state: undefined,
+ status: 'waiting',
+ polling: true,
+ error: undefined,
+ callbackUrl: '',
+ callbackStatus: undefined,
+ callbackError: undefined,
+ });
+
+ try {
+ const result = await invoke('start_oauth_login', { provider });
+ if (!result.state) {
+ updateProviderState(provider, {
+ url: result.url,
+ state: undefined,
+ status: 'error',
+ error: '内核未返回 OAuth state',
+ polling: false,
+ });
+ showNotice('内核未返回 OAuth state,无法轮询登录状态', 'error');
+ return;
+ }
+
+ updateProviderState(provider, {
+ url: result.url,
+ state: result.state,
+ status: 'waiting',
+ polling: true,
+ });
+ startPolling(provider, result.state);
+
+ if (!result.opened) {
+ showNotice(
+ result.openError
+ ? `已获取登录链接,但自动打开浏览器失败:${result.openError}`
+ : '已获取登录链接,但自动打开浏览器失败,请手动打开',
+ 'info',
+ );
+ }
+ } catch (error) {
+ updateProviderState(provider, {
+ status: 'error',
+ error: String(error),
+ polling: false,
+ });
+ showNotice(String(error), 'error');
+ }
+ };
+
+ const openAuthUrl = async (url?: string) => {
+ if (!url) return;
+ try {
+ await invoke('open_external_url', { url });
+ } catch (error) {
+ showNotice(String(error), 'error');
+ }
+ };
+
+ const copyAuthUrl = async (url?: string) => {
+ if (!url) return;
+ try {
+ await navigator.clipboard.writeText(url);
+ showNotice('登录链接已复制', 'success');
+ } catch {
+ showNotice('复制登录链接失败', 'error');
+ }
+ };
+
+ const submitCallback = async (provider: OAuthProviderId) => {
+ const current = states[provider];
+ const callbackInput = (current?.callbackUrl || '').trim();
+ if (!callbackInput) {
+ showNotice(
+ provider === 'xai' ? '请粘贴 xAI 返回的 code 或回调 URL' : '请粘贴回调 URL',
+ 'error',
+ );
+ return;
+ }
+
+ const redirectUrl = resolveCallbackUrl(provider, callbackInput, current?.state);
+ if (!redirectUrl) {
+ showNotice(
+ provider === 'xai'
+ ? '无法从输入内容构造 xAI 回调,请检查 code 与 state'
+ : '回调内容无效',
+ 'error',
+ );
+ return;
+ }
+
+ updateProviderState(provider, {
+ callbackSubmitting: true,
+ callbackStatus: undefined,
+ callbackError: undefined,
+ });
+ try {
+ await invoke('submit_oauth_callback', { provider, redirectUrl });
+ updateProviderState(provider, {
+ callbackSubmitting: false,
+ callbackStatus: 'success',
+ });
+ showNotice('回调已提交,正在等待认证完成', 'success');
+ } catch (error) {
+ updateProviderState(provider, {
+ callbackSubmitting: false,
+ callbackStatus: 'error',
+ callbackError: String(error),
+ });
+ showNotice(String(error), 'error');
+ }
+ };
+
+ return (
+
+
+
+ {notice ? (
+
+ {notice.message}
+
+ ) : null}
+
+
+ {oauthProviders.map((provider) => {
+ const state = states[provider.id] ?? { status: 'idle' as const };
+ const canSubmitCallback = OAUTH_CALLBACK_SUPPORTED.has(provider.id) && Boolean(state.url);
+ const loginLabel = state.status === 'success'
+ ? '登录另一个账号'
+ : state.polling
+ ? '登录中…'
+ : '开始登录';
+
+ return (
+
+
+

+
+
{provider.name}
+
+ {oauthStatusLabel(state)}
+
+
+
+
+
+
点击开始登录后,将自动打开浏览器完成授权;如未自动跳转,可手动打开链接
+ {state.url ? (
+
+
授权链接
+
{state.url}
+
+ void copyAuthUrl(state.url)}>
+ 复制链接
+
+ void openAuthUrl(state.url)}>
+ 打开链接
+
+
+
+ ) : null}
+
+ {canSubmitCallback ? (
+
+
+ {
+ const value = event.currentTarget.value;
+ updateProviderState(provider.id, {
+ callbackUrl: value,
+ callbackStatus: undefined,
+ callbackError: undefined,
+ });
+ }}
+ placeholder={provider.id === 'xai' ? '粘贴 code 或完整回调 URL' : '粘贴完整回调 URL'}
+ />
+ void submitCallback(provider.id)}>
+ {state.callbackSubmitting ? : }
+ 提交回调
+
+
+ {state.callbackStatus === 'success' && state.status === 'waiting' ? (
+
回调已提交,等待认证完成
+ ) : null}
+ {state.callbackStatus === 'error' ? (
+
回调提交失败{state.callbackError ? `:${state.callbackError}` : ''}
+ ) : null}
+
+ ) : null}
+
+ {state.status === 'error' && state.error ? (
+
{state.error}
+ ) : null}
+
+
+
+ void startLogin(provider.id)}>
+ {state.polling ? : }
+ {loginLabel}
+
+ {state.url ? (
+ void openAuthUrl(state.url)}>
+ 打开浏览器
+
+ ) : null}
+
+
+ );
+ })}
+
+
+ );
+}
+
+function oauthStatusLabel(state: OAuthProviderState) {
+ if (state.status === 'success') return '已完成';
+ if (state.status === 'error') return '失败';
+ if (state.status === 'waiting' || state.polling) return '等待回调';
+ return '未登录';
+}
+
+function providerLabel(provider: OAuthProviderId) {
+ return oauthProviders.find((item) => item.id === provider)?.name ?? provider;
+}
+
+function isAbsoluteUrl(value: string) {
+ try {
+ new URL(value);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+function readQueryLikeCallbackInput(value: string) {
+ const trimmed = value.trim();
+ if (!trimmed) return null;
+ const queryStart = trimmed.indexOf('?');
+ const hashStart = trimmed.indexOf('#');
+ const rawParams = queryStart >= 0
+ ? trimmed.slice(queryStart + 1)
+ : hashStart >= 0
+ ? trimmed.slice(hashStart + 1)
+ : trimmed;
+ if (!/(^|[?])(code|state|error)=/i.test(rawParams)) return null;
+ return new URLSearchParams(rawParams.replace(/^[?#]/, ''));
+}
+
+function buildXaiCallbackUrl(input: string, state?: string) {
+ const trimmed = input.trim();
+ if (!trimmed) return null;
+ if (isAbsoluteUrl(trimmed)) return trimmed;
+ const params = readQueryLikeCallbackInput(trimmed);
+ if (params) {
+ const callbackState = params.get('state')?.trim() || state?.trim();
+ if (!callbackState) return null;
+ const callbackUrl = new URL(XAI_CALLBACK_URL);
+ callbackUrl.searchParams.set('state', callbackState);
+ for (const key of ['code', 'error', 'error_description']) {
+ const value = params.get(key)?.trim();
+ if (value) callbackUrl.searchParams.set(key, value);
+ }
+ return callbackUrl.toString();
+ }
+ const code = (trimmed.match(/\bcode\s*[:=]\s*([^\s&]+)/i)?.[1] ?? trimmed).trim();
+ const callbackState = state?.trim();
+ if (!code || !callbackState) return null;
+ const callbackUrl = new URL(XAI_CALLBACK_URL);
+ callbackUrl.searchParams.set('code', code);
+ callbackUrl.searchParams.set('state', callbackState);
+ return callbackUrl.toString();
+}
+
+function resolveCallbackUrl(provider: OAuthProviderId, input: string, state?: string) {
+ return provider === 'xai' ? buildXaiCallbackUrl(input, state) : input.trim();
+}
diff --git a/src/pages/QuotaPage.tsx b/src/pages/QuotaPage.tsx
new file mode 100644
index 0000000..6ddb647
--- /dev/null
+++ b/src/pages/QuotaPage.tsx
@@ -0,0 +1,203 @@
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { AlertCircle, LoaderCircle, RefreshCw } from 'lucide-react';
+import antigravityIcon from '../assets/icons/antigravity.svg';
+import claudeIcon from '../assets/icons/claude.svg';
+import codexIcon from '../assets/icons/codex.svg';
+import grokIcon from '../assets/icons/grok.svg';
+import kimiIcon from '../assets/icons/kimi-light.svg';
+import { managementApi, readBoolean, responseList } from '../services/managementApi';
+import {
+ consumeCodexResetCredit,
+ fileName,
+ formatQuotaTimestamp,
+ idleQuota,
+ loadQuota,
+ providerForFile,
+ quotaKey,
+ type AuthFile,
+ type QuotaProvider,
+ type QuotaState,
+} from '../services/quotaService';
+import {
+ captureQuotaCacheGeneration,
+ commitQuotaCacheIfCurrent,
+ pruneQuotaCache,
+ updateQuotaCache,
+ useQuotaCache,
+} from '../services/quotaCache';
+import { dedupeAuthFiles } from '../services/authFiles';
+
+const providerMeta: Record = {
+ claude: { label: 'Claude', icon: claudeIcon },
+ codex: { label: 'Codex', icon: codexIcon },
+ kimi: { label: 'Kimi', icon: kimiIcon },
+ xai: { label: 'xAI', icon: grokIcon },
+ antigravity: { label: 'Antigravity', icon: antigravityIcon },
+};
+
+const providerOrder: QuotaProvider[] = ['claude', 'antigravity', 'codex', 'xai', 'kimi'];
+const REFRESH_CONCURRENCY = 4;
+
+export function QuotaPage() {
+ const [files, setFiles] = useState([]);
+ const quotas = useQuotaCache();
+ const [loading, setLoading] = useState(true);
+ const [refreshing, setRefreshing] = useState(false);
+ const [error, setError] = useState('');
+
+ const loadFiles = useCallback(async () => {
+ setLoading(true);
+ setError('');
+ try {
+ const payload = await managementApi.get('/auth-files');
+ const nextFiles = dedupeAuthFiles(responseList(payload, 'files')).filter(
+ (file) => !readBoolean(file, 'disabled') && providerForFile(file),
+ );
+ setFiles(nextFiles);
+ const validQuotaKeys = new Set(nextFiles.map(quotaKey));
+ pruneQuotaCache(validQuotaKeys);
+ updateQuotaCache((current) => {
+ const next = { ...current };
+ nextFiles.forEach((file) => {
+ const key = quotaKey(file);
+ if (!next[key]) next[key] = idleQuota();
+ });
+ return next;
+ });
+ } catch (requestError) {
+ setError(String(requestError));
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ void loadFiles();
+ }, [loadFiles]);
+
+ const refreshOne = useCallback(async (file: AuthFile) => {
+ const key = quotaKey(file);
+ const cacheGeneration = captureQuotaCacheGeneration();
+ updateQuotaCache((current) => ({ ...current, [key]: { status: 'loading', rows: [] } }));
+ const result = await loadQuota(file);
+ commitQuotaCacheIfCurrent(cacheGeneration, () => {
+ updateQuotaCache((current) => ({ ...current, [key]: result }));
+ });
+ }, []);
+
+ const resetCodexQuota = useCallback(async (file: AuthFile, quota: QuotaState) => {
+ const confirmed = window.confirm([
+ `确认重置「${fileName(file)}」的 Codex 额度吗?`,
+ '',
+ '本操作将消耗 1 次主动重置机会。',
+ `当前可用:${quota.resetCredits ?? '未记录'} 次`,
+ `最早过期:${formatQuotaTimestamp(quota.resetCreditsEarliestExpiry)}`,
+ '',
+ '只有点击“确定”后才会执行;点击“取消”不会消耗重置机会。',
+ ].join('\n'));
+ if (!confirmed) return;
+ const key = quotaKey(file);
+ const cacheGeneration = captureQuotaCacheGeneration();
+ updateQuotaCache((current) => ({ ...current, [key]: { ...current[key], status: 'loading', rows: [] } }));
+ try {
+ const result = await consumeCodexResetCredit(file);
+ commitQuotaCacheIfCurrent(cacheGeneration, () => {
+ updateQuotaCache((current) => ({ ...current, [key]: result }));
+ });
+ } catch (requestError) {
+ commitQuotaCacheIfCurrent(cacheGeneration, () => {
+ updateQuotaCache((current) => ({
+ ...current,
+ [key]: {
+ status: 'error',
+ rows: [],
+ error: requestError instanceof Error ? requestError.message : String(requestError),
+ },
+ }));
+ });
+ }
+ }, []);
+
+ const refreshAll = useCallback(async () => {
+ setRefreshing(true);
+ setError('');
+ const cacheGeneration = captureQuotaCacheGeneration();
+ updateQuotaCache((current) => Object.fromEntries(files.map((file) => [quotaKey(file), { ...current[quotaKey(file)], status: 'loading', rows: [] }])));
+ try {
+ for (let index = 0; index < files.length; index += REFRESH_CONCURRENCY) {
+ const batch = files.slice(index, index + REFRESH_CONCURRENCY);
+ await Promise.all(batch.map(async (file) => {
+ const result = await loadQuota(file);
+ commitQuotaCacheIfCurrent(cacheGeneration, () => {
+ updateQuotaCache((current) => ({ ...current, [quotaKey(file)]: result }));
+ });
+ }));
+ }
+ } finally {
+ setRefreshing(false);
+ }
+ }, [files]);
+
+ const grouped = useMemo(() => {
+ const groups = new Map();
+ files.forEach((file) => {
+ const provider = providerForFile(file);
+ if (!provider) return;
+ const items = groups.get(provider) ?? [];
+ items.push({ file, quota: quotas[quotaKey(file)] ?? idleQuota() });
+ groups.set(provider, items);
+ });
+ return providerOrder.flatMap((provider) => {
+ const items = groups.get(provider);
+ return items ? [[provider, items] as const] : [];
+ });
+ }, [files, quotas]);
+
+ return (
+
+
+ {error ? {error}
: null}
+ {loading ? (
+ 读取认证文件中
+ ) : grouped.length === 0 ? (
+ 暂无可查询配额先在认证文件中添加支持配额查询的凭据。
+ ) : (
+
+ {grouped.map(([provider, items]) => (
+
+ 
{providerMeta[provider].label}
{items.length} 个凭据
+ {items.map(({ file, quota }) => void refreshOne(file)} onReset={provider === 'codex' ? () => void resetCodexQuota(file, quota) : undefined} />)}
+
+ ))}
+
+ )}
+
+ );
+}
+
+export function QuotaCard({ file, quota, onRefresh, onReset }: { file: AuthFile; quota: QuotaState; onRefresh: () => void; onReset?: () => void }) {
+ const provider = providerForFile(file);
+ const name = fileName(file);
+ const disabled = readBoolean(file, 'disabled');
+ return (
+
+ {name}{provider ? providerMeta[provider].label : '未知'}{quota.plan ? ` · ${quota.plan}` : ''}
{onReset && (quota.resetCredits ?? 0) > 0 ? 重置额度 : null}
+ {quota.status === 'idle' ? {disabled ? '认证文件已停用' : '尚未获取额度'}{disabled ? '已停用' : '获取额度'}
: null}
+ {quota.status === 'loading' ? 查询中
: null}
+ {quota.status === 'error' ? : null}
+ {quota.status === 'success' && provider === 'codex' ? 主动重置次数 {quota.resetCredits ?? '未记录'}最早过期 {formatQuotaTimestamp(quota.resetCreditsEarliestExpiry)}
: null}
+ {quota.status === 'success' ? {quota.rows.map((row, index) =>
{row.label}{row.remainingPercent === null ? '—' : `剩余 ${Math.round(row.remainingPercent)}%`}
{row.detail ?? ''}{row.reset ? `${row.detail ? ' · ' : ''}${row.reset}` : ''})}
: null}
+
+ );
+}
diff --git a/src/services/authFiles.ts b/src/services/authFiles.ts
new file mode 100644
index 0000000..0d271fd
--- /dev/null
+++ b/src/services/authFiles.ts
@@ -0,0 +1,70 @@
+import { readBoolean, readString } from './managementApi';
+
+export type AuthFileRecord = Record;
+
+export const authFileName = (file: AuthFileRecord) =>
+ readString(file, 'name') || '未命名认证文件';
+
+export const isRuntimeOnlyAuthFile = (file: AuthFileRecord) =>
+ readBoolean(file, 'runtime_only', 'runtimeOnly');
+
+const hasMeaningfulValue = (value: unknown) => {
+ if (value === null || value === undefined) return false;
+ if (typeof value === 'string') return value.trim().length > 0;
+ if (Array.isArray(value)) return value.length > 0;
+ return true;
+};
+
+const authFileTimestamp = (file: AuthFileRecord) => {
+ for (const value of [file.modtime, file.updated_at, file.last_refresh]) {
+ if (value === null || value === undefined || value === '') continue;
+ const numeric = typeof value === 'number' ? value : Number(value);
+ if (Number.isFinite(numeric)) return numeric < 1e12 ? numeric * 1000 : numeric;
+ const parsed = new Date(String(value)).getTime();
+ if (!Number.isNaN(parsed)) return parsed;
+ }
+ return 0;
+};
+
+const authFilePriority = (file: AuthFileRecord) => {
+ let score = 0;
+ if (readString(file, 'source').toLowerCase() === 'file') score += 32;
+ if (readString(file, 'path')) score += 16;
+ if (!isRuntimeOnlyAuthFile(file)) score += 8;
+ if (!readBoolean(file, 'disabled')) score += 4;
+ if (authFileTimestamp(file) > 0) score += 2;
+ return score;
+};
+
+const mergeDuplicateAuthFiles = (entries: AuthFileRecord[]) => {
+ const sorted = [...entries].sort((left, right) => {
+ const priority = authFilePriority(right) - authFilePriority(left);
+ if (priority !== 0) return priority;
+ const timestamp = authFileTimestamp(right) - authFileTimestamp(left);
+ if (timestamp !== 0) return timestamp;
+ return Object.values(right).filter(hasMeaningfulValue).length
+ - Object.values(left).filter(hasMeaningfulValue).length;
+ });
+ const merged = { ...sorted[0] };
+ sorted.slice(1).forEach((entry) => {
+ Object.entries(entry).forEach(([key, value]) => {
+ if (!hasMeaningfulValue(merged[key]) && hasMeaningfulValue(value)) merged[key] = value;
+ });
+ });
+ return merged;
+};
+
+export const dedupeAuthFiles = (files: AuthFileRecord[]) => {
+ const grouped = new Map();
+ files.forEach((file, index) => {
+ const key = authFileName(file) || `unnamed-${index}`;
+ const entries = grouped.get(key) ?? [];
+ entries.push(file);
+ grouped.set(key, entries);
+ });
+ return Array.from(grouped.values())
+ .map(mergeDuplicateAuthFiles)
+ .sort((left, right) =>
+ authFileName(left).localeCompare(authFileName(right), undefined, { sensitivity: 'base' }),
+ );
+};
diff --git a/src/services/clientAccess.ts b/src/services/clientAccess.ts
new file mode 100644
index 0000000..9ae3c61
--- /dev/null
+++ b/src/services/clientAccess.ts
@@ -0,0 +1,39 @@
+export const DEFAULT_CLIENT_API_KEY = '123456';
+
+export type ClientApiProfile = {
+ id: 'openai' | 'claude' | 'gemini';
+ name: string;
+ description: string;
+ baseUrl: string;
+ lanUrl: string | null;
+};
+
+export function clientApiProfiles(port: number, lanIpv4?: string | null): ClientApiProfile[] {
+ const safePort = Number.isInteger(port) && port >= 1 && port <= 65535 ? port : 8317;
+ const origin = `http://127.0.0.1:${safePort}`;
+ const lanOrigin = lanIpv4?.trim() ? `http://${lanIpv4.trim()}:${safePort}` : null;
+
+ return [
+ {
+ id: 'openai',
+ name: 'OpenAI',
+ description: '适用于 OpenAI SDK、OpenCode 等兼容客户端',
+ baseUrl: `${origin}/v1`,
+ lanUrl: lanOrigin ? `${lanOrigin}/v1` : null,
+ },
+ {
+ id: 'claude',
+ name: 'Claude',
+ description: '适用于 Claude SDK 与 Claude Code',
+ baseUrl: origin,
+ lanUrl: lanOrigin,
+ },
+ {
+ id: 'gemini',
+ name: 'Gemini',
+ description: '适用于 Gemini 原生兼容客户端',
+ baseUrl: origin,
+ lanUrl: lanOrigin,
+ },
+ ];
+}
diff --git a/src/services/managementApi.ts b/src/services/managementApi.ts
new file mode 100644
index 0000000..bb3f5bd
--- /dev/null
+++ b/src/services/managementApi.ts
@@ -0,0 +1,190 @@
+import { invoke } from '@tauri-apps/api/core';
+
+export type ManagementJson = Record | unknown[] | string | number | boolean | null;
+
+type ManagementRequestOptions = {
+ query?: Record;
+ body?: ManagementJson;
+};
+
+const normalizeQuery = (
+ query?: Record,
+): Record | undefined => {
+ if (!query) {
+ return undefined;
+ }
+ const normalized = Object.entries(query).reduce>((result, [key, value]) => {
+ if (value !== undefined) {
+ result[key] = String(value);
+ }
+ return result;
+ }, {});
+ return Object.keys(normalized).length > 0 ? normalized : undefined;
+};
+
+async function request(
+ method: string,
+ path: string,
+ options: ManagementRequestOptions = {},
+): Promise {
+ return invoke('management_request', {
+ request: {
+ method,
+ path,
+ query: normalizeQuery(options.query),
+ body: options.body,
+ },
+ });
+}
+
+export const managementApi = {
+ get: (path: string, query?: ManagementRequestOptions['query']) =>
+ request('GET', path, { query }),
+ post: (path: string, body?: ManagementJson) =>
+ request('POST', path, { body }),
+ put: (path: string, body?: ManagementJson) =>
+ request('PUT', path, { body }),
+ patch: (path: string, body?: ManagementJson) =>
+ request('PATCH', path, { body }),
+ delete: (
+ path: string,
+ options: ManagementRequestOptions = {},
+ ) => request('DELETE', path, options),
+ uploadAuthFile: async (file: File) => {
+ const data = Array.from(new Uint8Array(await file.arrayBuffer()));
+ return invoke('upload_auth_file', {
+ name: file.name,
+ data,
+ });
+ },
+ downloadAuthFile: (name: string) => invoke('download_auth_file', { name }),
+};
+
+export function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+export function readString(value: unknown, ...keys: string[]): string {
+ if (!isRecord(value)) {
+ return '';
+ }
+ for (const key of keys) {
+ const candidate = value[key];
+ if (candidate === undefined || candidate === null) {
+ continue;
+ }
+ const text = String(candidate).trim();
+ if (text) {
+ return text;
+ }
+ }
+ return '';
+}
+
+export function readBoolean(value: unknown, ...keys: string[]): boolean {
+ if (!isRecord(value)) {
+ return false;
+ }
+ for (const key of keys) {
+ if (typeof value[key] === 'boolean') {
+ return value[key] as boolean;
+ }
+ }
+ return false;
+}
+
+export function readNumber(value: unknown, ...keys: string[]): number | null {
+ if (!isRecord(value)) {
+ return null;
+ }
+ for (const key of keys) {
+ const candidate = value[key];
+ const parsed = typeof candidate === 'number' ? candidate : Number(candidate);
+ if (Number.isFinite(parsed)) {
+ return parsed;
+ }
+ }
+ return null;
+}
+
+export function responseList(payload: unknown, key: string): Record