feat(providers): add API, OAuth, quota, and DeepSeek support
No files matched your search
@@ -6,6 +6,7 @@
|
||||
"name": "cpa-gui",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"lucide-react": "^1.24.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
},
|
||||
@@ -246,6 +247,8 @@
|
||||
|
||||
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"lucide-react": ["lucide-react@1.24.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-YT6mBD8lGKkg4nM39enlm94/sfJIiW0YKUT60fBy4YK8tai31ylg1VhGNWxkpSKHo9UagfnZqwIff3HTDQwXeA=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="],
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"lucide-react": "^1.24.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
|
||||
@@ -407,21 +407,34 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "countme"
|
||||
version = "3.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636"
|
||||
|
||||
[[package]]
|
||||
name = "cpa-gui"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"futures-util",
|
||||
"libc",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_norway",
|
||||
"serde_yaml",
|
||||
"sha2",
|
||||
"tar",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"toml 0.8.2",
|
||||
"windows-sys 0.59.0",
|
||||
"yaml-edit",
|
||||
"yaml-serde-edit",
|
||||
"zip",
|
||||
]
|
||||
|
||||
@@ -1264,6 +1277,12 @@ version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
@@ -2394,7 +2413,7 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustc-hash 2.1.3",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"thiserror 2.0.18",
|
||||
@@ -2415,7 +2434,7 @@ dependencies = [
|
||||
"rand",
|
||||
"rand_pcg",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustc-hash 2.1.3",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
@@ -2650,6 +2669,24 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rowan"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "417a3a9f582e349834051b8a10c8d71ca88da4211e4093528e36b9845f6b5f21"
|
||||
dependencies = [
|
||||
"countme",
|
||||
"hashbrown 0.14.5",
|
||||
"rustc-hash 1.1.0",
|
||||
"text-size",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.3"
|
||||
@@ -2805,7 +2842,7 @@ dependencies = [
|
||||
"phf",
|
||||
"phf_codegen",
|
||||
"precomputed-hash",
|
||||
"rustc-hash",
|
||||
"rustc-hash 2.1.3",
|
||||
"servo_arc",
|
||||
"smallvec",
|
||||
]
|
||||
@@ -2886,6 +2923,19 @@ dependencies = [
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_norway"
|
||||
version = "0.9.42"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e408f29489b5fd500fab51ff1484fc859bb655f32c671f307dcd733b72e8168c"
|
||||
dependencies = [
|
||||
"indexmap 2.14.0",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
"unsafe-libyaml-norway",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_repr"
|
||||
version = "0.1.20"
|
||||
@@ -2959,6 +3009,19 @@ dependencies = [
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_yaml"
|
||||
version = "0.9.34+deprecated"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
|
||||
dependencies = [
|
||||
"indexmap 2.14.0",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
"unsafe-libyaml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serialize-to-javascript"
|
||||
version = "0.1.2"
|
||||
@@ -3486,6 +3549,12 @@ dependencies = [
|
||||
"new_debug_unreachable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "text-size"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f18aa187839b2bdb1ad2fa35ead8c4c2976b64e4363c386d45ac0f7ee85c9233"
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
@@ -3907,6 +3976,18 @@ version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||
|
||||
[[package]]
|
||||
name = "unsafe-libyaml"
|
||||
version = "0.2.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
||||
|
||||
[[package]]
|
||||
name = "unsafe-libyaml-norway"
|
||||
version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b39abd59bf32521c7f2301b52d05a6a2c975b6003521cbd0c6dc1582f0a22104"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
@@ -4705,6 +4786,28 @@ dependencies = [
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yaml-edit"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d25d3b32461fbd629221a7356eb48c86ad435356db0bfa93e26e8c1509c1836"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"rowan",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yaml-serde-edit"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c5bdfd93bdf8b688e9a20708d5eb55faed433eb6286e8f0d5c08ab6f1d32d793"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_norway",
|
||||
"thiserror 1.0.69",
|
||||
"yaml-edit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.3"
|
||||
|
||||
@@ -14,9 +14,25 @@ futures-util = "0.3"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_norway = "0.9"
|
||||
serde_yaml = "0.9"
|
||||
sha2 = "0.10"
|
||||
tar = "0.4"
|
||||
tauri = { version = "2", features = [] }
|
||||
tokio = { version = "1", features = ["macros", "sync", "time"] }
|
||||
tokio-util = "0.7"
|
||||
toml = "0.8"
|
||||
yaml-serde-edit = { version = "0.1", default-features = false }
|
||||
yaml-edit = "0.2"
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.59", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Security",
|
||||
"Win32_Storage_FileSystem",
|
||||
"Win32_System_JobObjects",
|
||||
] }
|
||||
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 295 KiB |
@@ -1,28 +1,80 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { FileKey, Gauge, LogIn, Network, ServerCog, Settings } from 'lucide-react';
|
||||
import appLogo from './assets/logo.jpg';
|
||||
import { CoreRuntimeProvider, useCoreRuntime } from './coreRuntime';
|
||||
import { ConfigPanelPage } from './pages/ConfigPanel';
|
||||
import { ApiAccessPage } from './pages/ApiAccessPage';
|
||||
import { AuthFileManagementPage } from './pages/AuthFileManagementPage';
|
||||
import { KernelPage } from './pages/Kernel';
|
||||
import { OAuthLoginPage } from './pages/ManagementPages';
|
||||
import { QuotaPage } from './pages/QuotaPage';
|
||||
|
||||
const pages = [
|
||||
{
|
||||
id: 'kernel',
|
||||
label: '内核',
|
||||
icon: ServerCog,
|
||||
component: KernelPage,
|
||||
},
|
||||
{
|
||||
id: 'config',
|
||||
label: '配置',
|
||||
icon: Settings,
|
||||
component: ConfigPanelPage,
|
||||
},
|
||||
{
|
||||
id: 'oauth',
|
||||
label: 'OAuth',
|
||||
icon: LogIn,
|
||||
component: OAuthLoginPage,
|
||||
},
|
||||
{
|
||||
id: 'api',
|
||||
label: 'API 接入',
|
||||
icon: Network,
|
||||
component: ApiAccessPage,
|
||||
},
|
||||
{
|
||||
id: 'auth-files',
|
||||
label: '认证文件',
|
||||
icon: FileKey,
|
||||
component: AuthFileManagementPage,
|
||||
},
|
||||
{
|
||||
id: 'quota',
|
||||
label: '配额',
|
||||
icon: Gauge,
|
||||
component: QuotaPage,
|
||||
},
|
||||
] as const;
|
||||
|
||||
type PageId = (typeof pages)[number]['id'];
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<CoreRuntimeProvider>
|
||||
<AppContent />
|
||||
</CoreRuntimeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AppContent() {
|
||||
const [active, setActive] = useState<PageId>('kernel');
|
||||
const { status } = useCoreRuntime();
|
||||
const coreRunning = Boolean(status?.running);
|
||||
const activePage = pages.find((page) => page.id === active) ?? pages[0];
|
||||
const ActivePage = activePage.component;
|
||||
|
||||
useEffect(() => {
|
||||
if (!coreRunning && active !== 'kernel') {
|
||||
setActive('kernel');
|
||||
}
|
||||
}, [active, coreRunning]);
|
||||
|
||||
const select = (pageId: PageId) => {
|
||||
if (pageId !== 'kernel' && !coreRunning) {
|
||||
return;
|
||||
}
|
||||
setActive(pageId);
|
||||
};
|
||||
|
||||
@@ -30,7 +82,7 @@ function App() {
|
||||
<div className="app-shell">
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-brand" title="CLI Proxy API GUI">
|
||||
<span className="brand-mark">CP</span>
|
||||
<img src={appLogo} alt="" className="brand-mark brand-logo" />
|
||||
<div>
|
||||
<strong>Easy CLIProxyAPI</strong>
|
||||
<span>Desktop Console</span>
|
||||
@@ -38,16 +90,25 @@ function App() {
|
||||
</div>
|
||||
|
||||
<nav className="nav-section" aria-label="主导航">
|
||||
{pages.map((page) => (
|
||||
<button
|
||||
key={page.id}
|
||||
type="button"
|
||||
className={page.id === active ? 'active' : ''}
|
||||
onClick={() => select(page.id)}
|
||||
>
|
||||
{page.label}
|
||||
</button>
|
||||
))}
|
||||
{pages.map((page) => {
|
||||
const Icon = page.icon;
|
||||
const locked = page.id !== 'kernel' && !coreRunning;
|
||||
return (
|
||||
<button
|
||||
key={page.id}
|
||||
type="button"
|
||||
className={[page.id === active ? 'active' : '', locked ? 'locked' : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
disabled={locked}
|
||||
title={locked ? '请先启动内核' : undefined}
|
||||
onClick={() => select(page.id)}
|
||||
>
|
||||
<Icon size={17} aria-hidden="true" />
|
||||
<span>{page.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
@@ -58,11 +119,27 @@ function App() {
|
||||
|
||||
<div className="workspace">
|
||||
<main className="content">
|
||||
<ActivePage />
|
||||
{activePage.id === 'kernel' || coreRunning ? (
|
||||
<ActivePage />
|
||||
) : (
|
||||
<CoreLockedPage />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CoreLockedPage() {
|
||||
return (
|
||||
<section className="page core-locked-page">
|
||||
<div className="empty-state core-locked-panel">
|
||||
<ServerCog size={26} aria-hidden="true" />
|
||||
<strong>请先启动内核</strong>
|
||||
<span>启动 CPA 内核后,才能使用配置、OAuth、API 接入等功能。</span>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from 'react';
|
||||
|
||||
type Props = { children: ReactNode };
|
||||
type State = { error: Error | null };
|
||||
|
||||
export class AppErrorBoundary extends Component<Props, State> {
|
||||
state: State = { error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.error('CPA GUI 渲染异常', error, info.componentStack);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.state.error) return this.props.children;
|
||||
|
||||
return (
|
||||
<main className="app-error-boundary">
|
||||
<section className="empty-state">
|
||||
<strong>页面渲染出现异常</strong>
|
||||
<span>{this.state.error.message || '未知错误'}</span>
|
||||
<button type="button" className="primary-button" onClick={() => window.location.reload()}>
|
||||
重新加载页面
|
||||
</button>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: visioncortex VTracer 0.6.4 -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="64" height="59">
|
||||
<path d="M0,0 L8,0 L14,4 L19,14 L27,40 L32,50 L36,54 L35,59 L30,59 L22,52 L11,35 L6,33 L-1,34 L-6,39 L-14,52 L-22,59 L-28,59 L-27,53 L-22,47 L-17,34 L-10,12 L-5,3 Z " fill="#3789F9" transform="translate(28,0)"/>
|
||||
<path d="M0,0 L8,0 L14,4 L19,14 L25,35 L21,34 L16,29 L11,26 L7,20 L7,18 L2,16 L-3,15 L-8,18 L-12,19 L-9,9 L-4,2 Z " fill="#6D80D8" transform="translate(28,0)"/>
|
||||
<path d="M0,0 L8,0 L14,4 L19,14 L20,19 L13,15 L10,12 L3,10 L-1,8 L-7,7 L-4,2 Z " fill="#D78240" transform="translate(28,0)"/>
|
||||
<path d="M0,0 L5,1 L10,4 L12,9 L1,8 L-5,13 L-10,21 L-13,26 L-16,26 L-9,5 L-4,2 Z M6,7 Z " fill="#3294CC" transform="translate(25,14)"/>
|
||||
<path d="M0,0 L5,2 L10,10 L12,18 L5,14 L1,10 L0,4 L-3,3 L0,2 Z " fill="#E45C49" transform="translate(36,1)"/>
|
||||
<path d="M0,0 L9,1 L12,3 L12,5 L7,6 L4,8 L-1,11 L-5,12 L-2,2 Z " fill="#90AE64" transform="translate(21,7)"/>
|
||||
<path d="M0,0 L5,1 L5,4 L-2,7 L-7,11 L-11,10 L-9,5 L-4,2 Z " fill="#53A89A" transform="translate(25,14)"/>
|
||||
<path d="M0,0 L5,0 L16,9 L17,13 L12,12 L8,9 L8,7 L4,5 L0,2 Z " fill="#B5677D" transform="translate(33,11)"/>
|
||||
<path d="M0,0 L6,0 L14,6 L19,11 L23,12 L22,15 L15,12 L10,8 L10,6 L4,5 Z " fill="#778998" transform="translate(27,12)"/>
|
||||
<path d="M0,0 L4,2 L-11,17 L-12,14 L-5,4 Z " fill="#3390DF" transform="translate(26,21)"/>
|
||||
<path d="M0,0 L2,1 L-4,5 L-9,9 L-13,13 L-14,10 L-13,7 L-6,4 L-3,1 Z " fill="#3FA1B7" transform="translate(27,18)"/>
|
||||
<path d="M0,0 L4,0 L9,5 L13,6 L12,9 L5,6 L0,2 Z " fill="#8277BB" transform="translate(37,18)"/>
|
||||
<path d="M0,0 L5,1 L7,6 L-2,5 Z M1,4 Z " fill="#4989CF" transform="translate(30,17)"/>
|
||||
<path d="M0,0 L5,1 L2,3 L-3,6 L-7,7 L-6,3 Z " fill="#71B774" transform="translate(23,12)"/>
|
||||
<path d="M0,0 L7,1 L9,7 L5,6 L0,1 Z " fill="#6687E9" transform="translate(44,28)"/>
|
||||
<path d="M0,0 L7,0 L5,1 L5,3 L8,4 L4,5 L-2,4 Z " fill="#C7AF38" transform="translate(23,3)"/>
|
||||
<path d="M0,0 L8,0 L8,3 L4,4 L-4,3 Z " fill="#EF842A" transform="translate(28,0)"/>
|
||||
<path d="M0,0 L7,4 L7,6 L10,6 L11,10 L4,6 L0,2 Z " fill="#CD5D67" transform="translate(37,9)"/>
|
||||
<path d="M0,0 L5,2 L9,8 L8,11 L2,3 L0,2 Z " fill="#F35241" transform="translate(36,1)"/>
|
||||
<path d="M0,0 L8,2 L9,6 L4,5 L0,2 Z " fill="#A667A2" transform="translate(41,18)"/>
|
||||
<path d="M0,0 L9,1 L8,3 L-2,3 Z " fill="#A4B34C" transform="translate(21,7)"/>
|
||||
<path d="M0,0 L2,0 L7,5 L8,7 L3,6 L0,2 Z " fill="#617FCF" transform="translate(35,18)"/>
|
||||
<path d="M0,0 L5,2 L8,7 L4,5 L0,2 Z " fill="#9D7784" transform="translate(33,11)"/>
|
||||
<path d="M0,0 L6,2 L6,4 L0,3 Z " fill="#BC7F59" transform="translate(31,7)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Claude</title><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" fill="#D97757" fill-rule="nonzero"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Codex</title><path d="M19.503 0H4.496A4.496 4.496 0 000 4.496v15.007A4.496 4.496 0 004.496 24h15.007A4.496 4.496 0 0024 19.503V4.496A4.496 4.496 0 0019.503 0z" fill="#fff"></path><path d="M9.064 3.344a4.578 4.578 0 012.285-.312c1 .115 1.891.54 2.673 1.275.01.01.024.017.037.021a.09.09 0 00.043 0 4.55 4.55 0 013.046.275l.047.022.116.057a4.581 4.581 0 012.188 2.399c.209.51.313 1.041.315 1.595a4.24 4.24 0 01-.134 1.223.123.123 0 00.03.115c.594.607.988 1.33 1.183 2.17.289 1.425-.007 2.71-.887 3.854l-.136.166a4.548 4.548 0 01-2.201 1.388.123.123 0 00-.081.076c-.191.551-.383 1.023-.74 1.494-.9 1.187-2.222 1.846-3.711 1.838-1.187-.006-2.239-.44-3.157-1.302a.107.107 0 00-.105-.024c-.388.125-.78.143-1.204.138a4.441 4.441 0 01-1.945-.466 4.544 4.544 0 01-1.61-1.335c-.152-.202-.303-.392-.414-.617a5.81 5.81 0 01-.37-.961 4.582 4.582 0 01-.014-2.298.124.124 0 00.006-.056.085.085 0 00-.027-.048 4.467 4.467 0 01-1.034-1.651 3.896 3.896 0 01-.251-1.192 5.189 5.189 0 01.141-1.6c.337-1.112.982-1.985 1.933-2.618.212-.141.413-.251.601-.33.215-.089.43-.164.646-.227a.098.098 0 00.065-.066 4.51 4.51 0 01.829-1.615 4.535 4.535 0 011.837-1.388zm3.482 10.565a.637.637 0 000 1.272h3.636a.637.637 0 100-1.272h-3.636zM8.462 9.23a.637.637 0 00-1.106.631l1.272 2.224-1.266 2.136a.636.636 0 101.095.649l1.454-2.455a.636.636 0 00.005-.64L8.462 9.23z" fill="url(#lobe-icons-codex-fill)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-codex-fill" x1="12" x2="12" y1="3" y2="21"><stop stop-color="#B1A7FF"></stop><stop offset=".5" stop-color="#7A9DFF"></stop><stop offset="1" stop-color="#3941FF"></stop></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>DeepSeek</title><path d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z" fill="#4D6BFE"/></svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 115 KiB |
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Gemini</title><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="#3186FF"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-0)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-1)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-2)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-0" x1="7" x2="11" y1="15.5" y2="12"><stop stop-color="#08B962"></stop><stop offset="1" stop-color="#08B962" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-1" x1="8" x2="11.5" y1="5.5" y2="11"><stop stop-color="#F94543"></stop><stop offset="1" stop-color="#F94543" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-2" x1="3.5" x2="17.5" y1="13.5" y2="12"><stop stop-color="#FABC12"></stop><stop offset=".46" stop-color="#FABC12" stop-opacity="0"></stop></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Grok</title><path d="M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815"></path></svg>
|
||||
|
After Width: | Height: | Size: 756 B |
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Kimi</title><path d="M19.738 5.776c.163-.209.306-.4.457-.585.07-.087.064-.153-.004-.244-.655-.861-.717-1.817-.34-2.787.283-.73.909-1.072 1.674-1.145.477-.045.945.004 1.379.236.57.305.902.77 1.01 1.412.086.512.07 1.012-.075 1.508-.257.878-.888 1.333-1.753 1.448-.718.096-1.446.108-2.17.157-.056.004-.113 0-.178 0z" fill="#027AFF"></path><path d="M17.962 1.844h-4.326l-3.425 7.81H5.369V1.878H1.5V22h3.87v-8.477h6.824a3.025 3.025 0 002.743-1.75V22h3.87v-8.477a3.87 3.87 0 00-3.588-3.86v-.01h-2.125a3.94 3.94 0 002.323-2.12l2.545-5.689z"></path></svg>
|
||||
|
After Width: | Height: | Size: 711 B |
@@ -0,0 +1 @@
|
||||
<svg fill="#000000" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenAI</title><path d="M21.55 10.004a5.416 5.416 0 00-.478-4.501c-1.217-2.09-3.662-3.166-6.05-2.66A5.59 5.59 0 0010.831 1C8.39.995 6.224 2.546 5.473 4.838A5.553 5.553 0 001.76 7.496a5.487 5.487 0 00.691 6.5 5.416 5.416 0 00.477 4.502c1.217 2.09 3.662 3.165 6.05 2.66A5.586 5.586 0 0013.168 23c2.443.006 4.61-1.546 5.361-3.84a5.553 5.553 0 003.715-2.66 5.488 5.488 0 00-.693-6.497v.001zm-8.381 11.558a4.199 4.199 0 01-2.675-.954c.034-.018.093-.05.132-.074l4.44-2.53a.71.71 0 00.364-.623v-6.176l1.877 1.069c.02.01.033.029.036.05v5.115c-.003 2.274-1.87 4.118-4.174 4.123zM4.192 17.78a4.059 4.059 0 01-.498-2.763c.032.02.09.055.131.078l4.44 2.53c.225.13.504.13.73 0l5.42-3.088v2.138a.068.068 0 01-.027.057L9.9 19.288c-1.999 1.136-4.552.46-5.707-1.51h-.001zM3.023 8.216A4.15 4.15 0 015.198 6.41l-.002.151v5.06a.711.711 0 00.364.624l5.42 3.087-1.876 1.07a.067.067 0 01-.063.005l-4.489-2.559c-1.995-1.14-2.679-3.658-1.53-5.63h.001zm15.417 3.54l-5.42-3.088L14.896 7.6a.067.067 0 01.063-.006l4.489 2.557c1.998 1.14 2.683 3.662 1.529 5.633a4.163 4.163 0 01-2.174 1.807V12.38a.71.71 0 00-.363-.623zm1.867-2.773a6.04 6.04 0 00-.132-.078l-4.44-2.53a.731.731 0 00-.729 0l-5.42 3.088V7.325a.068.068 0 01.027-.057L14.1 4.713c2-1.137 4.555-.46 5.707 1.513.487.833.664 1.809.499 2.757h.001zm-11.741 3.81l-1.877-1.068a.065.065 0 01-.036-.051V6.559c.001-2.277 1.873-4.122 4.181-4.12.976 0 1.92.338 2.671.954-.034.018-.092.05-.131.073l-4.44 2.53a.71.71 0 00-.365.623l-.003 6.173v.002zm1.02-2.168L12 9.25l2.414 1.375v2.75L12 14.75l-2.415-1.375v-2.75z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24px" height="24px"><path d="M20,13.89A.77.77,0,0,0,19,13.73l-7,5.14v.22a.72.72,0,1,1,0,1.43v0a.74.74,0,0,0,.45-.15l7.41-5.47A.76.76,0,0,0,20,13.89Z" style="fill:#669df6"/><path d="M12,20.52a.72.72,0,0,1,0-1.43h0v-.22L5,13.73a.76.76,0,0,0-1,.16.74.74,0,0,0,.16,1l7.41,5.47a.73.73,0,0,0,.44.15v0Z" style="fill:#aecbfa"/><path d="M12,18.34a1.47,1.47,0,1,0,1.47,1.47A1.47,1.47,0,0,0,12,18.34Zm0,2.18a.72.72,0,1,1,.72-.71A.71.71,0,0,1,12,20.52Z" style="fill:#4285f4"/><path d="M6,6.11a.76.76,0,0,1-.75-.75V3.48a.76.76,0,1,1,1.51,0V5.36A.76.76,0,0,1,6,6.11Z" style="fill:#aecbfa"/><circle cx="5.98" cy="12" r="0.76" style="fill:#aecbfa"/><circle cx="5.98" cy="9.79" r="0.76" style="fill:#aecbfa"/><circle cx="5.98" cy="7.57" r="0.76" style="fill:#aecbfa"/><path d="M18,8.31a.76.76,0,0,1-.75-.76V5.67a.75.75,0,1,1,1.5,0V7.55A.75.75,0,0,1,18,8.31Z" style="fill:#4285f4"/><circle cx="18.02" cy="12.01" r="0.76" style="fill:#4285f4"/><circle cx="18.02" cy="9.76" r="0.76" style="fill:#4285f4"/><circle cx="18.02" cy="3.48" r="0.76" style="fill:#4285f4"/><path d="M12,15a.76.76,0,0,1-.75-.75V12.34a.76.76,0,0,1,1.51,0v1.89A.76.76,0,0,1,12,15Z" style="fill:#669df6"/><circle cx="12" cy="16.45" r="0.76" style="fill:#669df6"/><circle cx="12" cy="10.14" r="0.76" style="fill:#669df6"/><circle cx="12" cy="7.92" r="0.76" style="fill:#669df6"/><path d="M15,10.54a.76.76,0,0,1-.75-.75V7.91a.76.76,0,1,1,1.51,0V9.79A.76.76,0,0,1,15,10.54Z" style="fill:#4285f4"/><circle cx="15.01" cy="5.69" r="0.76" style="fill:#4285f4"/><circle cx="15.01" cy="14.19" r="0.76" style="fill:#4285f4"/><circle cx="15.01" cy="11.97" r="0.76" style="fill:#4285f4"/><circle cx="8.99" cy="14.19" r="0.76" style="fill:#aecbfa"/><circle cx="8.99" cy="7.92" r="0.76" style="fill:#aecbfa"/><circle cx="8.99" cy="5.69" r="0.76" style="fill:#aecbfa"/><path d="M9,12.73A.76.76,0,0,1,8.24,12V10.1a.75.75,0,1,1,1.5,0V12A.75.75,0,0,1,9,12.73Z" style="fill:#aecbfa"/></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 53 KiB |
@@ -0,0 +1,61 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
export type CoreStatus = {
|
||||
installed: boolean;
|
||||
running: boolean;
|
||||
managed: boolean;
|
||||
processId: number | null;
|
||||
currentVersion: string | null;
|
||||
installDir: string;
|
||||
binaryPath: string | null;
|
||||
message: string;
|
||||
};
|
||||
|
||||
type CoreRuntimeContextValue = {
|
||||
status: CoreStatus | null;
|
||||
statusError: string;
|
||||
publishStatus: (status: CoreStatus | null) => void;
|
||||
};
|
||||
|
||||
const CoreRuntimeContext = createContext<CoreRuntimeContextValue | null>(null);
|
||||
|
||||
export function CoreRuntimeProvider({ children }: { children: ReactNode }) {
|
||||
const [status, setStatus] = useState<CoreStatus | null>(null);
|
||||
const [statusError, setStatusError] = useState('');
|
||||
|
||||
const refreshStatus = useCallback(async () => {
|
||||
try {
|
||||
const nextStatus = await invoke<CoreStatus>('get_core_status');
|
||||
setStatus(nextStatus);
|
||||
setStatusError('');
|
||||
} catch (error) {
|
||||
setStatus(null);
|
||||
setStatusError(String(error));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshStatus();
|
||||
const timer = window.setInterval(() => {
|
||||
void refreshStatus();
|
||||
}, 1500);
|
||||
|
||||
return () => window.clearInterval(timer);
|
||||
}, [refreshStatus]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ status, statusError, publishStatus: setStatus }),
|
||||
[status, statusError],
|
||||
);
|
||||
|
||||
return <CoreRuntimeContext.Provider value={value}>{children}</CoreRuntimeContext.Provider>;
|
||||
}
|
||||
|
||||
export function useCoreRuntime() {
|
||||
const context = useContext(CoreRuntimeContext);
|
||||
if (!context) {
|
||||
throw new Error('useCoreRuntime 必须在 CoreRuntimeProvider 内使用');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { AppErrorBoundary } from './AppErrorBoundary';
|
||||
import App from './App';
|
||||
import './styles.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<AppErrorBoundary>
|
||||
<App />
|
||||
</AppErrorBoundary>
|
||||
</StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,567 @@
|
||||
import { ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Check,
|
||||
Copy,
|
||||
Download,
|
||||
FileDown,
|
||||
LoaderCircle,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Trash2,
|
||||
Upload,
|
||||
X,
|
||||
} 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 geminiIcon from '../assets/icons/gemini.svg';
|
||||
import grokIcon from '../assets/icons/grok.svg';
|
||||
import kimiIcon from '../assets/icons/kimi-light.svg';
|
||||
import vertexIcon from '../assets/icons/vertex.svg';
|
||||
import {
|
||||
formatDate,
|
||||
managementApi,
|
||||
readBoolean,
|
||||
readNumber,
|
||||
readString,
|
||||
responseList,
|
||||
} from '../services/managementApi';
|
||||
import {
|
||||
formatQuotaTimestamp,
|
||||
idleQuota,
|
||||
loadQuota,
|
||||
providerForFile as quotaProviderForFile,
|
||||
quotaKey,
|
||||
type QuotaState,
|
||||
} from '../services/quotaService';
|
||||
import {
|
||||
captureQuotaCacheGeneration,
|
||||
commitQuotaCacheIfCurrent,
|
||||
pruneQuotaCache,
|
||||
updateQuotaCache,
|
||||
useQuotaCache,
|
||||
} from '../services/quotaCache';
|
||||
import {
|
||||
authFileName,
|
||||
dedupeAuthFiles,
|
||||
isRuntimeOnlyAuthFile,
|
||||
} from '../services/authFiles';
|
||||
import {
|
||||
exclusionsForOpenOAuthModels,
|
||||
oauthExcludedRulesFromPayload,
|
||||
oauthModelsFromPayload,
|
||||
openOAuthModelNames,
|
||||
type OAuthModelDefinition,
|
||||
} from '../services/oauthModels';
|
||||
|
||||
type AuthFile = Record<string, unknown>;
|
||||
|
||||
const providerIcons: Record<string, string> = {
|
||||
antigravity: antigravityIcon,
|
||||
claude: claudeIcon,
|
||||
codex: codexIcon,
|
||||
gemini: geminiIcon,
|
||||
kimi: kimiIcon,
|
||||
vertex: vertexIcon,
|
||||
xai: grokIcon,
|
||||
};
|
||||
|
||||
const providerName = (file: AuthFile) => {
|
||||
const value = readString(file, 'provider', 'type', 'account_type').toLowerCase();
|
||||
if (value === 'anthropic') return 'Claude';
|
||||
if (value === 'anti-gravity') return 'Antigravity';
|
||||
if (value === 'xai') return 'xAI';
|
||||
return value ? value.charAt(0).toUpperCase() + value.slice(1) : '未知提供商';
|
||||
};
|
||||
|
||||
const providerKey = (file: AuthFile) => {
|
||||
const value = readString(file, 'provider', 'type', 'account_type').toLowerCase();
|
||||
return value === 'anthropic' ? 'claude' : value === 'anti-gravity' ? 'antigravity' : value;
|
||||
};
|
||||
|
||||
const fileName = authFileName;
|
||||
|
||||
const isRuntimeOnly = isRuntimeOnlyAuthFile;
|
||||
|
||||
const statusText = (file: AuthFile) => {
|
||||
if (readBoolean(file, 'disabled')) return '已停用';
|
||||
if (readBoolean(file, 'unavailable')) return '不可用';
|
||||
return readString(file, 'status') || '就绪';
|
||||
};
|
||||
|
||||
function AuthFileQuotaSummary({ quota }: { quota: QuotaState }) {
|
||||
if (quota.status === 'loading') {
|
||||
return (
|
||||
<div className="auth-file-quota loading">
|
||||
<LoaderCircle size={13} className="spin" />
|
||||
<span>额度查询中</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (quota.status === 'error') {
|
||||
return (
|
||||
<div className="auth-file-quota error" title={quota.error}>
|
||||
<span>额度获取失败</span>
|
||||
{quota.error ? <small>{quota.error}</small> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (quota.status !== 'success') return null;
|
||||
|
||||
return (
|
||||
<div className="auth-file-quota" aria-label="额度信息">
|
||||
{quota.plan ? <span className="auth-file-quota-plan">{quota.plan}</span> : null}
|
||||
{quota.rows.length > 0 ? quota.rows.map((row, index) => {
|
||||
const detail = [row.detail, row.reset].filter(Boolean).join(' · ');
|
||||
return (
|
||||
<span className="auth-file-quota-item" key={`${row.label}-${index}`} title={detail || undefined}>
|
||||
<span>{row.label}</span>
|
||||
<strong>{row.remainingPercent === null ? '—' : `${Math.round(row.remainingPercent)}%`}</strong>
|
||||
{row.reset ? <small>{row.reset}</small> : null}
|
||||
</span>
|
||||
);
|
||||
}) : <span className="auth-file-quota-empty">暂无额度数据</span>}
|
||||
{quota.resetCredits !== undefined ? (
|
||||
<span className="auth-file-quota-credit">主动重置 {quota.resetCredits} 次</span>
|
||||
) : null}
|
||||
{quota.resetCreditsEarliestExpiry ? (
|
||||
<span className="auth-file-quota-credit">最早过期 {formatQuotaTimestamp(quota.resetCreditsEarliestExpiry)}</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuthFileManagementPage() {
|
||||
const [files, setFiles] = useState<AuthFile[]>([]);
|
||||
const [filter, setFilter] = useState('');
|
||||
const [providerFilter, setProviderFilter] = useState('all');
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | 'enabled' | 'disabled' | 'runtime'>('all');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [notice, setNotice] = useState('');
|
||||
const [copied, setCopied] = useState('');
|
||||
const [oauthModelProvider, setOauthModelProvider] = useState('');
|
||||
const [oauthModelProviderLabel, setOauthModelProviderLabel] = useState('');
|
||||
const [oauthModels, setOauthModels] = useState<OAuthModelDefinition[]>([]);
|
||||
const [oauthExcludedRules, setOauthExcludedRules] = useState<string[]>([]);
|
||||
const [openOauthModelNames, setOpenOauthModelNames] = useState<Set<string>>(new Set());
|
||||
const [oauthModelSearch, setOauthModelSearch] = useState('');
|
||||
const [oauthModelLoading, setOauthModelLoading] = useState(false);
|
||||
const [oauthModelSaving, setOauthModelSaving] = useState(false);
|
||||
const [oauthModelError, setOauthModelError] = useState('');
|
||||
const quotas = useQuotaCache();
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const oauthModelRequestRef = useRef(0);
|
||||
|
||||
const loadFiles = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const payload = await managementApi.get('/auth-files');
|
||||
const nextFiles = dedupeAuthFiles(responseList(payload, 'files'));
|
||||
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);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshQuota = async (file: AuthFile) => {
|
||||
if (readBoolean(file, 'disabled')) return;
|
||||
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 closeOauthModels = () => {
|
||||
oauthModelRequestRef.current += 1;
|
||||
setOauthModelProvider('');
|
||||
};
|
||||
|
||||
const openOauthModels = async (file: AuthFile) => {
|
||||
const provider = providerKey(file);
|
||||
if (!provider) return;
|
||||
const requestId = oauthModelRequestRef.current + 1;
|
||||
oauthModelRequestRef.current = requestId;
|
||||
setOauthModelProvider(provider);
|
||||
setOauthModelProviderLabel(providerName(file));
|
||||
setOauthModels([]);
|
||||
setOauthExcludedRules([]);
|
||||
setOpenOauthModelNames(new Set());
|
||||
setOauthModelSearch('');
|
||||
setOauthModelError('');
|
||||
setOauthModelLoading(true);
|
||||
try {
|
||||
const [definitionsPayload, excludedPayload] = await Promise.all([
|
||||
managementApi.get(`/model-definitions/${encodeURIComponent(provider)}`),
|
||||
managementApi.get('/oauth-excluded-models'),
|
||||
]);
|
||||
if (oauthModelRequestRef.current !== requestId) return;
|
||||
const models = oauthModelsFromPayload(definitionsPayload);
|
||||
const excludedRules = oauthExcludedRulesFromPayload(excludedPayload, provider);
|
||||
setOauthModels(models);
|
||||
setOauthExcludedRules(excludedRules);
|
||||
setOpenOauthModelNames(openOAuthModelNames(models, excludedRules));
|
||||
if (models.length === 0) setOauthModelError('该提供商没有可配置的模型定义');
|
||||
} catch (requestError) {
|
||||
if (oauthModelRequestRef.current === requestId) setOauthModelError(String(requestError));
|
||||
} finally {
|
||||
if (oauthModelRequestRef.current === requestId) setOauthModelLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveOauthModels = async () => {
|
||||
if (!oauthModelProvider) return;
|
||||
const excludedModels = exclusionsForOpenOAuthModels(
|
||||
oauthExcludedRules,
|
||||
oauthModels,
|
||||
openOauthModelNames,
|
||||
);
|
||||
setOauthModelSaving(true);
|
||||
setOauthModelError('');
|
||||
try {
|
||||
if (excludedModels.length > 0 || oauthExcludedRules.length > 0) {
|
||||
await managementApi.patch('/oauth-excluded-models', {
|
||||
provider: oauthModelProvider,
|
||||
models: excludedModels,
|
||||
});
|
||||
}
|
||||
setNotice(`${oauthModelProviderLabel} 开放模型已更新`);
|
||||
closeOauthModels();
|
||||
} catch (requestError) {
|
||||
setOauthModelError(String(requestError));
|
||||
} finally {
|
||||
setOauthModelSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const visibleOauthModels = useMemo(() => {
|
||||
const query = oauthModelSearch.trim().toLowerCase();
|
||||
if (!query) return oauthModels;
|
||||
return oauthModels.filter((model) =>
|
||||
`${model.id} ${model.displayName ?? ''}`.toLowerCase().includes(query),
|
||||
);
|
||||
}, [oauthModelSearch, oauthModels]);
|
||||
|
||||
const allVisibleOauthModelsOpen = visibleOauthModels.length > 0
|
||||
&& visibleOauthModels.every((model) => openOauthModelNames.has(model.id.toLowerCase()));
|
||||
|
||||
const toggleOauthModel = (model: OAuthModelDefinition) => {
|
||||
const key = model.id.toLowerCase();
|
||||
setOpenOauthModelNames((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleAllVisibleOauthModels = () => {
|
||||
setOpenOauthModelNames((current) => {
|
||||
const next = new Set(current);
|
||||
visibleOauthModels.forEach((model) => {
|
||||
const key = model.id.toLowerCase();
|
||||
if (allVisibleOauthModelsOpen) next.delete(key);
|
||||
else next.add(key);
|
||||
});
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadFiles();
|
||||
}, [loadFiles]);
|
||||
|
||||
const providers = useMemo(
|
||||
() => Array.from(new Set(files.map(providerName))).sort((left, right) => left.localeCompare(right)),
|
||||
[files],
|
||||
);
|
||||
|
||||
const visibleFiles = useMemo(() => {
|
||||
const query = filter.trim().toLowerCase();
|
||||
return files.filter((file) => {
|
||||
const providerMatch = providerFilter === 'all' || providerName(file) === providerFilter;
|
||||
const disabled = readBoolean(file, 'disabled');
|
||||
const runtimeMatch =
|
||||
statusFilter === 'all' ||
|
||||
(statusFilter === 'disabled' && disabled) ||
|
||||
(statusFilter === 'enabled' && !disabled) ||
|
||||
(statusFilter === 'runtime' && isRuntimeOnly(file));
|
||||
const searchMatch =
|
||||
!query ||
|
||||
[fileName(file), providerName(file), readString(file, 'email', 'account', 'label')]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(query);
|
||||
return providerMatch && runtimeMatch && searchMatch;
|
||||
});
|
||||
}, [files, filter, providerFilter, statusFilter]);
|
||||
|
||||
const handleUpload = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const selected = Array.from(event.currentTarget.files ?? []);
|
||||
event.currentTarget.value = '';
|
||||
if (selected.length === 0) return;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
let uploaded = 0;
|
||||
const failures: string[] = [];
|
||||
for (const file of selected) {
|
||||
try {
|
||||
await managementApi.uploadAuthFile(file);
|
||||
uploaded += 1;
|
||||
} catch (requestError) {
|
||||
failures.push(`${file.name}:${String(requestError)}`);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await loadFiles();
|
||||
if (uploaded > 0) setNotice(`已上传 ${uploaded} 个认证文件`);
|
||||
if (failures.length > 0) setError(`${failures.length} 个文件上传失败:${failures.join(';')}`);
|
||||
} catch (requestError) {
|
||||
setError(String(requestError));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleStatus = async (file: AuthFile) => {
|
||||
const name = fileName(file);
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
await managementApi.patch('/auth-files/status', {
|
||||
name,
|
||||
disabled: !readBoolean(file, 'disabled'),
|
||||
});
|
||||
setNotice(readBoolean(file, 'disabled') ? '认证文件已启用' : '认证文件已停用');
|
||||
await loadFiles();
|
||||
} catch (requestError) {
|
||||
setError(String(requestError));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteFile = async (file: AuthFile) => {
|
||||
const name = fileName(file);
|
||||
if (isRuntimeOnly(file)) {
|
||||
setError('运行时认证文件不能从磁盘删除');
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(`确定删除「${name}」吗?`)) return;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
await managementApi.delete('/auth-files', { query: { name } });
|
||||
setNotice('认证文件已删除');
|
||||
await loadFiles();
|
||||
} catch (requestError) {
|
||||
setError(String(requestError));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteAll = async () => {
|
||||
if (!window.confirm('确定删除所有磁盘认证文件吗?运行时认证不会被删除。')) return;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
await managementApi.delete('/auth-files', { query: { all: true } });
|
||||
setNotice('磁盘认证文件已全部删除');
|
||||
await loadFiles();
|
||||
} catch (requestError) {
|
||||
setError(String(requestError));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadFile = async (file: AuthFile) => {
|
||||
const name = fileName(file);
|
||||
if (isRuntimeOnly(file)) {
|
||||
setError('运行时认证文件没有可下载的磁盘文件');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
const content = await managementApi.downloadAuthFile(name);
|
||||
const url = URL.createObjectURL(new Blob([content], { type: 'application/json' }));
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = name;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
setNotice('认证文件已下载');
|
||||
} catch (requestError) {
|
||||
setError(String(requestError));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyName = async (name: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(name);
|
||||
setCopied(name);
|
||||
window.setTimeout(() => setCopied((current) => (current === name ? '' : current)), 1500);
|
||||
} catch {
|
||||
setError('复制失败');
|
||||
}
|
||||
};
|
||||
|
||||
const disabledCount = files.filter((file) => readBoolean(file, 'disabled')).length;
|
||||
const runtimeCount = files.filter(isRuntimeOnly).length;
|
||||
const diskCount = files.length - runtimeCount;
|
||||
|
||||
return (
|
||||
<section className="page management-page auth-files-page">
|
||||
<header className="management-header">
|
||||
<div>
|
||||
<span>Auth Files</span>
|
||||
<h1>认证文件</h1>
|
||||
</div>
|
||||
<div className="management-heading-actions">
|
||||
<span className="muted-summary">{files.length} 个文件 · {disabledCount} 个停用</span>
|
||||
<button type="button" className="secondary-button compact-button" onClick={() => void loadFiles()} disabled={loading || busy}>
|
||||
<RefreshCw size={16} />刷新
|
||||
</button>
|
||||
<button type="button" className="secondary-button compact-button" onClick={deleteAll} disabled={loading || busy || diskCount === 0}>
|
||||
<Trash2 size={16} />清空磁盘文件
|
||||
</button>
|
||||
<button type="button" className="primary-button compact-button" onClick={() => fileInputRef.current?.click()} disabled={busy}>
|
||||
<Upload size={16} />上传
|
||||
</button>
|
||||
<input ref={fileInputRef} type="file" accept=".json,application/json" multiple hidden onChange={(event) => void handleUpload(event)} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error ? <div className="management-alert error">{error}</div> : null}
|
||||
{notice ? <div className="management-alert success">{notice}</div> : null}
|
||||
|
||||
<section className="panel auth-files-panel real-auth-files-panel">
|
||||
<div className="management-toolbar auth-files-toolbar">
|
||||
<Search size={16} />
|
||||
<input value={filter} onChange={(event) => setFilter(event.currentTarget.value)} placeholder="搜索文件名、账号或提供商" />
|
||||
<select value={providerFilter} onChange={(event) => setProviderFilter(event.currentTarget.value)}>
|
||||
<option value="all">全部提供商</option>
|
||||
{providers.map((provider) => <option key={provider} value={provider}>{provider}</option>)}
|
||||
</select>
|
||||
<select value={statusFilter} onChange={(event) => setStatusFilter(event.currentTarget.value as typeof statusFilter)}>
|
||||
<option value="all">全部状态</option>
|
||||
<option value="enabled">已启用</option>
|
||||
<option value="disabled">已停用</option>
|
||||
<option value="runtime">仅运行时</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="management-loading"><LoaderCircle size={20} className="spin" />读取认证文件中</div>
|
||||
) : visibleFiles.length === 0 ? (
|
||||
<div className="management-empty"><FileDown size={24} /><strong>{files.length ? '没有匹配的认证文件' : '暂无认证文件'}</strong><span>{files.length ? '换个筛选条件试试' : '上传 JSON 认证文件后会显示在这里'}</span></div>
|
||||
) : (
|
||||
<div className="real-auth-file-list">
|
||||
{visibleFiles.map((file) => {
|
||||
const name = fileName(file);
|
||||
const icon = providerIcons[providerKey(file)] ?? geminiIcon;
|
||||
const disabled = readBoolean(file, 'disabled');
|
||||
return (
|
||||
<article className={`real-auth-file-row ${disabled ? 'disabled' : ''}`} key={`${name}-${readString(file, 'auth_index', 'authIndex')}`}>
|
||||
<img src={icon} alt="" className="provider-logo" />
|
||||
<div className="auth-file-main">
|
||||
<div className="auth-file-title"><strong title={name}>{name}</strong><span className={`state-pill ${disabled ? 'error' : readBoolean(file, 'unavailable') ? 'error' : 'success'}`} title={readString(file, 'status_message', 'statusMessage') || undefined}>{statusText(file)}</span></div>
|
||||
<span>{providerName(file)}{readString(file, 'email', 'account', 'label') ? ` · ${readString(file, 'email', 'account', 'label')}` : ''}</span>
|
||||
</div>
|
||||
<div className="auth-file-meta">
|
||||
<span>{readNumber(file, 'size') === null ? '大小未知' : `${Math.ceil((readNumber(file, 'size') ?? 0) / 1024)} KB`}</span>
|
||||
<span>{formatDate(file.modtime ?? file.updated_at ?? file.last_refresh)}</span>
|
||||
{isRuntimeOnly(file) ? <span className="state-pill">运行时</span> : null}
|
||||
</div>
|
||||
<div className="auth-file-actions">
|
||||
{quotaProviderForFile(file) ? <button type="button" className="secondary-button compact-button" onClick={() => void refreshQuota(file)} disabled={busy || disabled || quotas[quotaKey(file)]?.status === 'loading'}>{disabled ? '已停用' : quotas[quotaKey(file)]?.status === 'loading' ? '查询中' : quotas[quotaKey(file)]?.status === 'success' ? '刷新额度' : '获取额度'}</button> : null}
|
||||
{providerKey(file) ? <button type="button" className="secondary-button compact-button" onClick={() => void openOauthModels(file)} disabled={busy} title="设置开放模型">模型</button> : null}
|
||||
<button type="button" className="icon-button quiet" onClick={() => void copyName(name)} disabled={busy} title="复制文件名">{copied === name ? <Check size={16} /> : <Copy size={16} />}</button>
|
||||
<button type="button" className="icon-button quiet" onClick={() => void downloadFile(file)} disabled={busy || isRuntimeOnly(file)} title="下载"><Download size={16} /></button>
|
||||
<button type="button" className="secondary-button compact-button" onClick={() => void toggleStatus(file)} disabled={busy}>{disabled ? '启用' : '停用'}</button>
|
||||
<button type="button" className="icon-button danger" onClick={() => void deleteFile(file)} disabled={busy || isRuntimeOnly(file)} title="删除"><Trash2 size={16} /></button>
|
||||
</div>
|
||||
{quotaProviderForFile(file) && quotas[quotaKey(file)]?.status !== 'idle' ? <AuthFileQuotaSummary quota={quotas[quotaKey(file)] ?? idleQuota()} /> : null}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
{runtimeCount > 0 ? <p className="page-footnote">{runtimeCount} 个凭据来自运行时存储,只能在对应提供商或认证源中管理。</p> : null}
|
||||
|
||||
{oauthModelProvider ? (
|
||||
<div className="model-discovery-backdrop" onMouseDown={(event) => event.currentTarget === event.target && !oauthModelSaving && closeOauthModels()}>
|
||||
<section className="model-discovery-dialog" role="dialog" aria-modal="true" aria-labelledby="oauth-model-title">
|
||||
<div className="model-discovery-header">
|
||||
<div><h2 id="oauth-model-title">开放模型</h2><span>{oauthModelProviderLabel} · 默认全部开放,可取消不需要的模型</span></div>
|
||||
<button type="button" className="icon-button quiet" onClick={closeOauthModels} disabled={oauthModelSaving} title="关闭"><X size={18} /></button>
|
||||
</div>
|
||||
|
||||
<div className="model-discovery-search">
|
||||
<Search size={16} aria-hidden="true" />
|
||||
<input value={oauthModelSearch} onChange={(event) => setOauthModelSearch(event.currentTarget.value)} placeholder="搜索模型名称" />
|
||||
</div>
|
||||
|
||||
<div className="model-discovery-toolbar">
|
||||
<span>共 {oauthModels.length} 个 · 已开放 {openOauthModelNames.size} 个</span>
|
||||
<div>
|
||||
<button type="button" className="secondary-button compact-button" onClick={toggleAllVisibleOauthModels} disabled={oauthModelLoading || visibleOauthModels.length === 0}>{allVisibleOauthModelsOpen ? '关闭当前' : '开放当前'}</button>
|
||||
<button type="button" className="secondary-button compact-button" onClick={() => setOpenOauthModelNames(new Set())} disabled={oauthModelLoading || openOauthModelNames.size === 0}>全部关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="model-discovery-content">
|
||||
{oauthModelLoading ? (
|
||||
<div className="model-discovery-message"><LoaderCircle size={20} className="spin" />正在读取模型定义</div>
|
||||
) : oauthModelError ? (
|
||||
<div className="model-discovery-message error"><strong>读取模型失败</strong><span>{oauthModelError}</span></div>
|
||||
) : visibleOauthModels.length === 0 ? (
|
||||
<div className="model-discovery-message"><strong>{oauthModels.length ? '没有匹配的模型' : '暂无模型定义'}</strong></div>
|
||||
) : (
|
||||
<div className="model-discovery-list">
|
||||
{visibleOauthModels.map((model) => {
|
||||
const checked = openOauthModelNames.has(model.id.toLowerCase());
|
||||
return (
|
||||
<label className={`model-discovery-row ${checked ? 'selected' : ''}`} key={model.id}>
|
||||
<input type="checkbox" checked={checked} onChange={() => toggleOauthModel(model)} />
|
||||
<span><strong title={model.id}>{model.id}</strong>{model.displayName ? <small title={model.displayName}>{model.displayName}</small> : null}</span>
|
||||
{checked ? <Check size={16} aria-hidden="true" /> : null}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="model-discovery-actions">
|
||||
<button type="button" className="secondary-button" onClick={closeOauthModels} disabled={oauthModelSaving}>取消</button>
|
||||
<button type="button" className="primary-button" onClick={() => void saveOauthModels()} disabled={oauthModelLoading || oauthModelSaving || oauthModels.length === 0}>{oauthModelSaving ? '保存中' : `保存(开放 ${openOauthModelNames.size} 个)`}</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,47 +1,565 @@
|
||||
import { FormEvent, useEffect, useRef, useState } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import {
|
||||
AlertCircle,
|
||||
Check,
|
||||
Copy,
|
||||
Eye,
|
||||
EyeOff,
|
||||
KeyRound,
|
||||
Plus,
|
||||
Plug,
|
||||
RefreshCw,
|
||||
Route,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
|
||||
type CoreConfigSettings = {
|
||||
apiKeys: CoreApiKey[];
|
||||
pluginsEnabled: boolean;
|
||||
routingStrategy: string;
|
||||
};
|
||||
|
||||
type CoreApiKey = {
|
||||
apiKey: string;
|
||||
remark: string;
|
||||
builtIn: boolean;
|
||||
};
|
||||
|
||||
type ConfigAction = 'add-key' | 'delete-key' | 'plugins' | 'routing' | null;
|
||||
type NoticeTone = 'success' | 'error';
|
||||
|
||||
const ROUTING_OPTIONS = [
|
||||
{ value: 'round-robin', label: '轮询' },
|
||||
{ value: 'fill-first', label: '优先填充' },
|
||||
] as const;
|
||||
|
||||
export function ConfigPanelPage() {
|
||||
const [settings, setSettings] = useState<CoreConfigSettings | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState('');
|
||||
const [busyAction, setBusyAction] = useState<ConfigAction>(null);
|
||||
const [addDialogOpen, setAddDialogOpen] = useState(false);
|
||||
const [deleteIndex, setDeleteIndex] = useState<number | null>(null);
|
||||
const [newApiKey, setNewApiKey] = useState('');
|
||||
const [newApiKeyRemark, setNewApiKeyRemark] = useState('');
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [formError, setFormError] = useState('');
|
||||
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
|
||||
const [notice, setNotice] = useState<{ message: string; tone: NoticeTone } | null>(null);
|
||||
const noticeTimerRef = useRef<number | null>(null);
|
||||
const copyTimerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void loadSettings();
|
||||
return () => {
|
||||
if (noticeTimerRef.current !== null) {
|
||||
window.clearTimeout(noticeTimerRef.current);
|
||||
}
|
||||
if (copyTimerRef.current !== null) {
|
||||
window.clearTimeout(copyTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const showNotice = (message: string, tone: NoticeTone) => {
|
||||
if (noticeTimerRef.current !== null) {
|
||||
window.clearTimeout(noticeTimerRef.current);
|
||||
}
|
||||
setNotice({ message, tone });
|
||||
noticeTimerRef.current = window.setTimeout(() => {
|
||||
setNotice(null);
|
||||
noticeTimerRef.current = null;
|
||||
}, 3200);
|
||||
};
|
||||
|
||||
async function loadSettings() {
|
||||
setLoading(true);
|
||||
setLoadError('');
|
||||
try {
|
||||
const result = await invoke<CoreConfigSettings>('get_core_config_settings');
|
||||
setSettings(result);
|
||||
} catch (error) {
|
||||
setSettings(null);
|
||||
setLoadError(String(error));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const runMutation = async (
|
||||
action: Exclude<ConfigAction, null>,
|
||||
command: string,
|
||||
args: Record<string, unknown>,
|
||||
successMessage: string,
|
||||
) => {
|
||||
setBusyAction(action);
|
||||
try {
|
||||
const result = await invoke<CoreConfigSettings>(command, args);
|
||||
setSettings(result);
|
||||
setLoadError('');
|
||||
showNotice(successMessage, 'success');
|
||||
return true;
|
||||
} catch (error) {
|
||||
showNotice(String(error), 'error');
|
||||
void loadSettings();
|
||||
return false;
|
||||
} finally {
|
||||
setBusyAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const openAddDialog = () => {
|
||||
setNewApiKey('');
|
||||
setNewApiKeyRemark('');
|
||||
setShowApiKey(false);
|
||||
setFormError('');
|
||||
setAddDialogOpen(true);
|
||||
};
|
||||
|
||||
const closeAddDialog = () => {
|
||||
if (busyAction === 'add-key') {
|
||||
return;
|
||||
}
|
||||
setAddDialogOpen(false);
|
||||
setFormError('');
|
||||
};
|
||||
|
||||
const generateApiKey = () => {
|
||||
const bytes = new Uint8Array(24);
|
||||
crypto.getRandomValues(bytes);
|
||||
const value = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||
setNewApiKey(`sk-${value}`);
|
||||
setShowApiKey(true);
|
||||
setFormError('');
|
||||
};
|
||||
|
||||
const submitApiKey = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const apiKey = newApiKey.trim();
|
||||
if (!apiKey) {
|
||||
setFormError('鉴权密钥不能为空');
|
||||
return;
|
||||
}
|
||||
if (!/^[\x21-\x7e]+$/.test(apiKey)) {
|
||||
setFormError('只能使用 ASCII 可见字符,且不能包含空格');
|
||||
return;
|
||||
}
|
||||
if (settings?.apiKeys.some((entry) => entry.apiKey === apiKey)) {
|
||||
setFormError('该鉴权密钥已经存在');
|
||||
return;
|
||||
}
|
||||
const remark = newApiKeyRemark.trim();
|
||||
if (remark.length > 80) {
|
||||
setFormError('备注不能超过 80 个字符');
|
||||
return;
|
||||
}
|
||||
|
||||
const saved = await runMutation(
|
||||
'add-key',
|
||||
'add_core_api_key',
|
||||
{ apiKey, remark },
|
||||
'鉴权密钥已新增',
|
||||
);
|
||||
if (saved) {
|
||||
setAddDialogOpen(false);
|
||||
setNewApiKey('');
|
||||
setNewApiKeyRemark('');
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (deleteIndex === null) {
|
||||
return;
|
||||
}
|
||||
const deleted = await runMutation(
|
||||
'delete-key',
|
||||
'delete_core_api_key',
|
||||
{ apiKey: selectedDeleteKey },
|
||||
'鉴权密钥已删除',
|
||||
);
|
||||
if (deleted) {
|
||||
setDeleteIndex(null);
|
||||
}
|
||||
};
|
||||
|
||||
const copyApiKey = async (apiKey: string, index: number) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(apiKey);
|
||||
setCopiedIndex(index);
|
||||
showNotice('鉴权密钥已复制', 'success');
|
||||
if (copyTimerRef.current !== null) {
|
||||
window.clearTimeout(copyTimerRef.current);
|
||||
}
|
||||
copyTimerRef.current = window.setTimeout(() => {
|
||||
setCopiedIndex(null);
|
||||
copyTimerRef.current = null;
|
||||
}, 1800);
|
||||
} catch {
|
||||
showNotice('复制鉴权密钥失败', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const togglePlugins = async (enabled: boolean) => {
|
||||
await runMutation(
|
||||
'plugins',
|
||||
'set_core_plugins_enabled',
|
||||
{ enabled },
|
||||
enabled ? '插件系统已启用' : '插件系统已停用',
|
||||
);
|
||||
};
|
||||
|
||||
const changeRoutingStrategy = async (strategy: string) => {
|
||||
if (strategy === settings?.routingStrategy) {
|
||||
return;
|
||||
}
|
||||
await runMutation(
|
||||
'routing',
|
||||
'set_core_routing_strategy',
|
||||
{ strategy },
|
||||
'路由策略已更新',
|
||||
);
|
||||
};
|
||||
|
||||
const controlsDisabled = loading || settings === null || busyAction !== null;
|
||||
const selectedDeleteKey =
|
||||
deleteIndex === null ? '' : settings?.apiKeys[deleteIndex]?.apiKey || '';
|
||||
|
||||
return (
|
||||
<section className="page config-page">
|
||||
<div className="config-layout">
|
||||
<div className="panel">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<h2>基础配置</h2>
|
||||
<p>配置文件和工作目录</p>
|
||||
<div className="config-workspace-grid">
|
||||
<section className="panel config-keys-panel">
|
||||
<div className="config-panel-heading">
|
||||
<div className="config-heading-title">
|
||||
<KeyRound size={18} aria-hidden="true" />
|
||||
<h2>鉴权密钥</h2>
|
||||
</div>
|
||||
<div className="config-heading-actions">
|
||||
<span className="config-count" aria-label="密钥数量">
|
||||
{settings?.apiKeys.length ?? 0}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={openAddDialog}
|
||||
disabled={controlsDisabled}
|
||||
title="新增鉴权密钥"
|
||||
aria-label="新增鉴权密钥"
|
||||
>
|
||||
<Plus size={18} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-grid">
|
||||
<label className="field">
|
||||
配置文件
|
||||
<input placeholder="待接入配置文件" />
|
||||
</label>
|
||||
<label className="field">
|
||||
工作目录
|
||||
<input placeholder="待接入工作目录" />
|
||||
</label>
|
||||
<div className="config-key-list" aria-busy={loading || undefined}>
|
||||
{loading ? (
|
||||
Array.from({ length: 5 }, (_, index) => (
|
||||
<div className="config-key-row skeleton" key={index} aria-hidden="true">
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
))
|
||||
) : loadError ? (
|
||||
<div className="config-unavailable">
|
||||
<AlertCircle size={24} aria-hidden="true" />
|
||||
<strong>配置不可用</strong>
|
||||
<span title={loadError}>{loadError}</span>
|
||||
<button type="button" className="secondary-button compact-button" onClick={loadSettings}>
|
||||
<RefreshCw size={16} aria-hidden="true" />
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : settings && settings.apiKeys.length > 0 ? (
|
||||
settings.apiKeys.map((entry, index) => (
|
||||
<div className="config-key-row" key={`${index}-${entry.apiKey}`}>
|
||||
<div className="config-key-identity">
|
||||
<span className="config-key-index">{String(index + 1).padStart(2, '0')}</span>
|
||||
<div className="config-key-details">
|
||||
<div className="config-key-label-line">
|
||||
<strong title={entry.remark || '未填写备注'}>
|
||||
{entry.remark || '未填写备注'}
|
||||
</strong>
|
||||
</div>
|
||||
<code title={maskApiKey(entry.apiKey)}>{maskApiKey(entry.apiKey)}</code>
|
||||
</div>
|
||||
</div>
|
||||
<div className="config-key-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button quiet"
|
||||
onClick={() => void copyApiKey(entry.apiKey, index)}
|
||||
disabled={controlsDisabled}
|
||||
title="复制鉴权密钥"
|
||||
aria-label={`复制第 ${index + 1} 个鉴权密钥`}
|
||||
>
|
||||
{copiedIndex === index ? (
|
||||
<Check size={16} aria-hidden="true" />
|
||||
) : (
|
||||
<Copy size={16} aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
{entry.builtIn ? (
|
||||
<span className="config-protected-key" title="内置密钥不能删除">
|
||||
内置
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button danger"
|
||||
onClick={() => setDeleteIndex(index)}
|
||||
disabled={controlsDisabled}
|
||||
title="删除鉴权密钥"
|
||||
aria-label={`删除第 ${index + 1} 个鉴权密钥`}
|
||||
>
|
||||
<Trash2 size={16} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="config-empty-list">
|
||||
<KeyRound size={26} aria-hidden="true" />
|
||||
<strong>暂无鉴权密钥</strong>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="panel quiet-panel">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<h2>连接信息</h2>
|
||||
<p>当前前端占位状态</p>
|
||||
<div className="config-side-stack">
|
||||
<section className="panel config-setting-panel">
|
||||
<div className="config-panel-heading">
|
||||
<div className="config-heading-title">
|
||||
<Plug size={18} aria-hidden="true" />
|
||||
<h2>插件系统</h2>
|
||||
</div>
|
||||
<span className={`state-pill ${settings?.pluginsEnabled ? 'success' : ''}`}>
|
||||
{loading
|
||||
? '读取中'
|
||||
: settings === null
|
||||
? '不可用'
|
||||
: settings.pluginsEnabled
|
||||
? '已启用'
|
||||
: '已停用'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="config-single-control">
|
||||
<span>启用插件系统</span>
|
||||
<label className="switch-control" title="启用插件系统">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label="启用插件系统"
|
||||
checked={Boolean(settings?.pluginsEnabled)}
|
||||
disabled={controlsDisabled}
|
||||
onChange={(event) => void togglePlugins(event.currentTarget.checked)}
|
||||
/>
|
||||
<span className="switch-track" />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<dl className="info-list">
|
||||
<div>
|
||||
<dt>配置源</dt>
|
||||
<dd>未接入</dd>
|
||||
<section className="panel config-setting-panel">
|
||||
<div className="config-panel-heading">
|
||||
<div className="config-heading-title">
|
||||
<Route size={18} aria-hidden="true" />
|
||||
<h2>路由策略</h2>
|
||||
</div>
|
||||
<span className="state-pill" title={settings?.routingStrategy || undefined}>
|
||||
{loading
|
||||
? '读取中'
|
||||
: settings === null
|
||||
? '不可用'
|
||||
: routingStrategyLabel(settings.routingStrategy)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<dt>保存状态</dt>
|
||||
<dd>待实现</dd>
|
||||
<div className="routing-segmented" role="group" aria-label="路由策略">
|
||||
{ROUTING_OPTIONS.map((option) => (
|
||||
<button
|
||||
type="button"
|
||||
key={option.value}
|
||||
className={settings?.routingStrategy === option.value ? 'active' : ''}
|
||||
aria-pressed={settings?.routingStrategy === option.value}
|
||||
disabled={controlsDisabled}
|
||||
onClick={() => void changeRoutingStrategy(option.value)}
|
||||
title={option.value}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{addDialogOpen ? (
|
||||
<div className="config-dialog-backdrop" onMouseDown={(event) => {
|
||||
if (event.currentTarget === event.target) closeAddDialog();
|
||||
}}>
|
||||
<form
|
||||
className="config-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="add-api-key-title"
|
||||
onSubmit={(event) => void submitApiKey(event)}
|
||||
>
|
||||
<div className="config-dialog-heading">
|
||||
<div>
|
||||
<KeyRound size={19} aria-hidden="true" />
|
||||
<h2 id="add-api-key-title">新增鉴权密钥</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button quiet"
|
||||
onClick={closeAddDialog}
|
||||
disabled={busyAction === 'add-key'}
|
||||
title="关闭"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<X size={18} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className="config-dialog-field">
|
||||
<span>鉴权密钥</span>
|
||||
<div className="config-secret-input">
|
||||
<input
|
||||
autoFocus
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
value={newApiKey}
|
||||
onChange={(event) => {
|
||||
setNewApiKey(event.currentTarget.value);
|
||||
setFormError('');
|
||||
}}
|
||||
disabled={busyAction === 'add-key'}
|
||||
aria-invalid={Boolean(formError)}
|
||||
placeholder="sk-..."
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button quiet"
|
||||
onClick={() => setShowApiKey((visible) => !visible)}
|
||||
disabled={busyAction === 'add-key'}
|
||||
title={showApiKey ? '隐藏密钥' : '显示密钥'}
|
||||
aria-label={showApiKey ? '隐藏密钥' : '显示密钥'}
|
||||
>
|
||||
{showApiKey ? (
|
||||
<EyeOff size={17} aria-hidden="true" />
|
||||
) : (
|
||||
<Eye size={17} aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="config-dialog-field">
|
||||
<span>备注</span>
|
||||
<input
|
||||
className="config-dialog-text-input"
|
||||
type="text"
|
||||
value={newApiKeyRemark}
|
||||
maxLength={80}
|
||||
onChange={(event) => {
|
||||
setNewApiKeyRemark(event.currentTarget.value);
|
||||
setFormError('');
|
||||
}}
|
||||
disabled={busyAction === 'add-key'}
|
||||
placeholder="例如:开发环境、张三的密钥"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className={`config-form-message ${formError ? 'error' : ''}`}>
|
||||
{formError || ' '}
|
||||
</div>
|
||||
|
||||
<div className="config-dialog-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
onClick={generateApiKey}
|
||||
disabled={busyAction === 'add-key'}
|
||||
>
|
||||
<Sparkles size={16} aria-hidden="true" />
|
||||
生成密钥
|
||||
</button>
|
||||
<button type="submit" className="primary-button" disabled={busyAction === 'add-key'}>
|
||||
<Plus size={16} aria-hidden="true" />
|
||||
{busyAction === 'add-key' ? '正在新增' : '新增'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{deleteIndex !== null ? (
|
||||
<div className="config-dialog-backdrop" onMouseDown={(event) => {
|
||||
if (event.currentTarget === event.target && busyAction !== 'delete-key') {
|
||||
setDeleteIndex(null);
|
||||
}
|
||||
}}>
|
||||
<div
|
||||
className="config-dialog config-delete-dialog"
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="delete-api-key-title"
|
||||
>
|
||||
<div className="config-dialog-heading">
|
||||
<div>
|
||||
<Trash2 size={19} aria-hidden="true" />
|
||||
<h2 id="delete-api-key-title">删除鉴权密钥</h2>
|
||||
</div>
|
||||
</div>
|
||||
<code className="config-delete-key">{maskApiKey(selectedDeleteKey)}</code>
|
||||
<div className="config-dialog-actions two-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
onClick={() => setDeleteIndex(null)}
|
||||
disabled={busyAction === 'delete-key'}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="danger-button"
|
||||
onClick={() => void confirmDelete()}
|
||||
disabled={busyAction === 'delete-key'}
|
||||
>
|
||||
<Trash2 size={16} aria-hidden="true" />
|
||||
{busyAction === 'delete-key' ? '正在删除' : '删除'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{notice ? (
|
||||
<div className={`config-toast ${notice.tone}`} role="status" title={notice.message}>
|
||||
{notice.tone === 'success' ? (
|
||||
<Check size={17} aria-hidden="true" />
|
||||
) : (
|
||||
<AlertCircle size={17} aria-hidden="true" />
|
||||
)}
|
||||
<span>{notice.message}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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<CoreStatus | null>(null);
|
||||
const [statusError, setStatusError] = useState('');
|
||||
const [platform, setPlatform] = useState<CorePlatform | null>(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<MessageType>('info');
|
||||
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 [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) {
|
||||
@@ -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<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 loadCoreStatus = async () => {
|
||||
try {
|
||||
const result = await invoke<CoreStatus>('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<CoreStatus>(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<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 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: '网络设置已保存,内核已重启',
|
||||
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<CorePlatform>('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<CoreStatus>(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 (
|
||||
<section className="page kernel-page">
|
||||
@@ -462,11 +647,10 @@ export function KernelPage() {
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<h2>运行控制</h2>
|
||||
<p className={controlSubtitleTone} title={controlSubtitle}>
|
||||
{controlSubtitle}
|
||||
</p>
|
||||
</div>
|
||||
<span className={`state-pill ${statusTone}`}>{statusLabel}</span>
|
||||
<span className={`state-pill ${statusTone}`} title={statusError || undefined}>
|
||||
{processBusy || networkBusy ? '处理中' : statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<dl className="panel-detail-grid">
|
||||
@@ -483,14 +667,15 @@ export function KernelPage() {
|
||||
<dd>{coreStatus?.processId || '无'}</dd>
|
||||
</div>
|
||||
<div className="panel-detail-row">
|
||||
<dt>局域网</dt>
|
||||
<dt>允许局域网</dt>
|
||||
<dd className="detail-control-cell">
|
||||
<span className="switch-control" title="允许局域网访问">
|
||||
<span className="switch-control" title="切换后会自动重启正在运行的内核">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label="允许局域网访问"
|
||||
disabled={!settingsLoaded || networkBusy || processBusy}
|
||||
checked={allowLanAccess}
|
||||
onChange={(event) => setAllowLanAccess(event.currentTarget.checked)}
|
||||
onChange={(event) => updateAllowLanAccess(event.currentTarget.checked)}
|
||||
/>
|
||||
<span className="switch-track" />
|
||||
</span>
|
||||
@@ -503,16 +688,25 @@ export function KernelPage() {
|
||||
<dd className="detail-control-cell">
|
||||
<input
|
||||
id="custom-port"
|
||||
className="compact-text-input"
|
||||
className={`compact-text-input ${settingsError ? 'error' : ''}`}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxLength={5}
|
||||
placeholder="端口号"
|
||||
title={settingsError || '端口范围 1-65535'}
|
||||
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>
|
||||
@@ -521,34 +715,23 @@ export function KernelPage() {
|
||||
<div className="button-row panel-action-row control-action-row">
|
||||
<button
|
||||
type="button"
|
||||
className="primary-button"
|
||||
disabled={!coreInstalled || coreRunning || installing || processBusy}
|
||||
className={coreRunning ? 'danger-button' : 'primary-button'}
|
||||
disabled={!coreInstalled || installing || processBusy || networkBusy}
|
||||
onClick={() =>
|
||||
runCoreProcessCommand('start_core_process', '正在启动 CPA 内核', 'CPA 内核已启动')
|
||||
void runCoreProcessCommand(
|
||||
coreRunning ? 'stop_core_process' : 'start_core_process',
|
||||
{ success: coreRunning ? '内核已关闭' : '内核已启动' },
|
||||
)
|
||||
}
|
||||
>
|
||||
{processBusy ? '处理中' : '启动'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="danger-button"
|
||||
disabled={!coreInstalled || !coreRunning || installing || processBusy}
|
||||
onClick={() =>
|
||||
runCoreProcessCommand('stop_core_process', '正在关闭 CPA 内核', 'CPA 内核已关闭')
|
||||
}
|
||||
>
|
||||
关闭
|
||||
{processBusy ? '处理中' : coreRunning ? '关闭' : '启动'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
disabled={!coreInstalled || !coreRunning || installing || processBusy}
|
||||
disabled={!coreInstalled || !coreRunning || installing || processBusy || networkBusy}
|
||||
onClick={() =>
|
||||
runCoreProcessCommand(
|
||||
'restart_core_process',
|
||||
'正在重启 CPA 内核',
|
||||
'CPA 内核已重启',
|
||||
)
|
||||
void runCoreProcessCommand('restart_core_process', { success: '内核已重启' })
|
||||
}
|
||||
>
|
||||
重启
|
||||
@@ -556,7 +739,7 @@ export function KernelPage() {
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
disabled={processBusy}
|
||||
disabled={processBusy || networkBusy}
|
||||
onClick={loadCoreStatus}
|
||||
>
|
||||
刷新状态
|
||||
@@ -569,7 +752,6 @@ export function KernelPage() {
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<h2>版本管理</h2>
|
||||
<p>检查和安装 CPA 内核版本</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -630,6 +812,93 @@ export function KernelPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="panel client-api-panel">
|
||||
<div className="panel-heading client-api-heading">
|
||||
<div>
|
||||
<h2>API URL</h2>
|
||||
<p>默认密钥:{DEFAULT_CLIENT_API_KEY}</p>
|
||||
</div>
|
||||
<span className={`state-pill ${coreRunning ? 'success' : ''}`}>
|
||||
{coreRunning ? '可连接' : '等待内核启动'}
|
||||
</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>本机 URL</span>
|
||||
<code title={profile.baseUrl}>{profile.baseUrl}</code>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button quiet"
|
||||
onClick={() =>
|
||||
void copyApiValue(
|
||||
profile.baseUrl,
|
||||
`${profile.id}:base`,
|
||||
`${profile.name} 本机 URL 已复制`,
|
||||
)
|
||||
}
|
||||
title={`复制 ${profile.name} 本机 URL`}
|
||||
aria-label={`复制 ${profile.name} 本机 URL`}
|
||||
>
|
||||
{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>局域网 URL</span>
|
||||
<code title={profile.lanUrl || undefined}>
|
||||
{!lanIpChecked
|
||||
? '正在检测局域网 IP'
|
||||
: profile.lanUrl || '未检测到局域网 IP'}
|
||||
</code>
|
||||
{profile.lanUrl ? (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button quiet"
|
||||
onClick={() =>
|
||||
void copyApiValue(
|
||||
profile.lanUrl!,
|
||||
`${profile.id}:lan`,
|
||||
`${profile.name} 局域网 URL 已复制`,
|
||||
)
|
||||
}
|
||||
title={`复制 ${profile.name} 局域网 URL`}
|
||||
aria-label={`复制 ${profile.name} 局域网 URL`}
|
||||
>
|
||||
{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>
|
||||
|
||||
{installDialogOpen && progress ? (
|
||||
<div className="install-dialog-backdrop">
|
||||
<div
|
||||
@@ -687,6 +956,23 @@ export function KernelPage() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<OAuthProviderId>([
|
||||
'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<Partial<Record<OAuthProviderId, OAuthProviderState>>>({});
|
||||
const [notice, setNotice] = useState<{
|
||||
message: string;
|
||||
tone: 'success' | 'error' | 'info';
|
||||
} | null>(null);
|
||||
const pollingTimers = useRef<Partial<Record<OAuthProviderId, number>>>({});
|
||||
const pollingRequests = useRef<Partial<Record<OAuthProviderId, boolean>>>({});
|
||||
const successResetTimers = useRef<Partial<Record<OAuthProviderId, number>>>({});
|
||||
const noticeTimerRef = useRef<number | null>(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<OAuthProviderState>) => {
|
||||
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<OAuthStatusResult>('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<OAuthStartResult>('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 (
|
||||
<section className="page management-page">
|
||||
<header className="management-header">
|
||||
<div><span>OAuth</span><h1>OAuth 登录</h1></div>
|
||||
</header>
|
||||
|
||||
{notice ? (
|
||||
<div className={`inline-notice ${notice.tone === 'error' ? 'error' : notice.tone === 'success' ? 'success' : ''}`}>
|
||||
{notice.message}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="oauth-grid">
|
||||
{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 (
|
||||
<section className="panel oauth-card" key={provider.id}>
|
||||
<div className="provider-title-row">
|
||||
<img src={provider.icon} alt="" className="provider-logo" />
|
||||
<div>
|
||||
<h2>{provider.name}</h2>
|
||||
<span className={`state-pill ${state.status === 'success' ? 'success' : state.status === 'error' ? 'error' : ''}`}>
|
||||
{oauthStatusLabel(state)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="oauth-card-body">
|
||||
<p className="oauth-hint">点击开始登录后,将自动打开浏览器完成授权;如未自动跳转,可手动打开链接</p>
|
||||
{state.url ? (
|
||||
<div className="oauth-auth-url-box">
|
||||
<div className="oauth-auth-url-label">授权链接</div>
|
||||
<div className="oauth-auth-url-value" title={state.url}>{state.url}</div>
|
||||
<div className="oauth-auth-url-actions">
|
||||
<button type="button" className="secondary-button compact-button" onClick={() => void copyAuthUrl(state.url)}>
|
||||
<Copy size={15} aria-hidden="true" />复制链接
|
||||
</button>
|
||||
<button type="button" className="secondary-button compact-button" onClick={() => void openAuthUrl(state.url)}>
|
||||
<ExternalLink size={15} aria-hidden="true" />打开链接
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canSubmitCallback ? (
|
||||
<div className="oauth-callback-block">
|
||||
<div className="oauth-callback-row">
|
||||
<input
|
||||
value={state.callbackUrl ?? ''}
|
||||
onChange={(event) => {
|
||||
const value = event.currentTarget.value;
|
||||
updateProviderState(provider.id, {
|
||||
callbackUrl: value,
|
||||
callbackStatus: undefined,
|
||||
callbackError: undefined,
|
||||
});
|
||||
}}
|
||||
placeholder={provider.id === 'xai' ? '粘贴 code 或完整回调 URL' : '粘贴完整回调 URL'}
|
||||
/>
|
||||
<button type="button" className="secondary-button" disabled={state.callbackSubmitting} onClick={() => void submitCallback(provider.id)}>
|
||||
{state.callbackSubmitting ? <LoaderCircle size={16} className="spin" aria-hidden="true" /> : <Check size={16} aria-hidden="true" />}
|
||||
提交回调
|
||||
</button>
|
||||
</div>
|
||||
{state.callbackStatus === 'success' && state.status === 'waiting' ? (
|
||||
<div className="oauth-inline-status success">回调已提交,等待认证完成</div>
|
||||
) : null}
|
||||
{state.callbackStatus === 'error' ? (
|
||||
<div className="oauth-inline-status error">回调提交失败{state.callbackError ? `:${state.callbackError}` : ''}</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.status === 'error' && state.error ? (
|
||||
<div className="oauth-inline-status error">{state.error}</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="button-row management-card-actions">
|
||||
<button type="button" className="primary-button" disabled={Boolean(state.polling)} onClick={() => void startLogin(provider.id)}>
|
||||
{state.polling ? <LoaderCircle size={16} className="spin" aria-hidden="true" /> : <LogIn size={16} aria-hidden="true" />}
|
||||
{loginLabel}
|
||||
</button>
|
||||
{state.url ? (
|
||||
<button type="button" className="secondary-button" onClick={() => void openAuthUrl(state.url)}>
|
||||
<ExternalLink size={16} aria-hidden="true" />打开浏览器
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -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<QuotaProvider, { label: string; icon: string }> = {
|
||||
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<AuthFile[]>([]);
|
||||
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<QuotaProvider, { file: AuthFile; quota: QuotaState }[]>();
|
||||
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 (
|
||||
<section className="page management-page quota-page">
|
||||
<header className="management-header">
|
||||
<div><span>Quota</span><h1>配额</h1></div>
|
||||
<div className="management-heading-actions">
|
||||
<span className="muted-summary">{files.length} 个可查询凭据</span>
|
||||
<button type="button" className="secondary-button compact-button" onClick={() => void loadFiles()} disabled={loading || refreshing}>
|
||||
<RefreshCw size={16} />读取列表
|
||||
</button>
|
||||
<button type="button" className="secondary-button compact-button" onClick={() => void refreshAll()} disabled={refreshing || loading || files.length === 0}>
|
||||
<RefreshCw size={16} className={refreshing ? 'spin' : ''} />刷新全部
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
{error ? <div className="management-alert error">{error}</div> : null}
|
||||
{loading ? (
|
||||
<div className="management-loading"><LoaderCircle size={20} className="spin" />读取认证文件中</div>
|
||||
) : grouped.length === 0 ? (
|
||||
<div className="management-empty"><AlertCircle size={24} /><strong>暂无可查询配额</strong><span>先在认证文件中添加支持配额查询的凭据。</span></div>
|
||||
) : (
|
||||
<div className="quota-group-list">
|
||||
{grouped.map(([provider, items]) => (
|
||||
<section className="quota-provider-group" key={provider}>
|
||||
<div className="quota-group-heading"><div><img src={providerMeta[provider].icon} alt="" className="provider-logo" /><h2>{providerMeta[provider].label}</h2></div><span>{items.length} 个凭据</span></div>
|
||||
<div className="real-quota-grid">{items.map(({ file, quota }) => <QuotaCard key={quotaKey(file)} file={file} quota={quota} onRefresh={() => void refreshOne(file)} onReset={provider === 'codex' ? () => void resetCodexQuota(file, quota) : undefined} />)}</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<article className="panel real-quota-card">
|
||||
<div className="real-quota-card-header"><div><strong title={name}>{name}</strong><span>{provider ? providerMeta[provider].label : '未知'}{quota.plan ? ` · ${quota.plan}` : ''}</span></div><div className="quota-card-actions">{onReset && (quota.resetCredits ?? 0) > 0 ? <button type="button" className="secondary-button compact-button" onClick={onReset} disabled={disabled || quota.status === 'loading'}>重置额度</button> : null}<button type="button" className="icon-button quiet" onClick={onRefresh} disabled={disabled || quota.status === 'loading'} title={disabled ? '认证文件已停用' : '获取/刷新额度'}><RefreshCw size={16} className={quota.status === 'loading' ? 'spin' : ''} /></button></div></div>
|
||||
{quota.status === 'idle' ? <div className="quota-card-message"><span>{disabled ? '认证文件已停用' : '尚未获取额度'}</span><button type="button" className="secondary-button compact-button" onClick={onRefresh} disabled={disabled}>{disabled ? '已停用' : '获取额度'}</button></div> : null}
|
||||
{quota.status === 'loading' ? <div className="quota-card-message"><LoaderCircle size={18} className="spin" />查询中</div> : null}
|
||||
{quota.status === 'error' ? <div className="quota-card-error"><AlertCircle size={18} />{quota.error}</div> : null}
|
||||
{quota.status === 'success' && provider === 'codex' ? <div className="quota-reset-credit-summary"><span>主动重置次数 <strong>{quota.resetCredits ?? '未记录'}</strong></span><span>最早过期 <strong>{formatQuotaTimestamp(quota.resetCreditsEarliestExpiry)}</strong></span></div> : null}
|
||||
{quota.status === 'success' ? <div className="quota-row-list">{quota.rows.map((row, index) => <div className="real-quota-row" key={`${row.label}-${index}`}><div><span>{row.label}</span><strong>{row.remainingPercent === null ? '—' : `剩余 ${Math.round(row.remainingPercent)}%`}</strong></div><div className="real-quota-track"><span style={{ width: `${Math.max(0, Math.min(100, row.remainingPercent ?? 0))}%` }} /></div><small>{row.detail ?? ''}{row.reset ? `${row.detail ? ' · ' : ''}${row.reset}` : ''}</small></div>)}</div> : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { readBoolean, readString } from './managementApi';
|
||||
|
||||
export type AuthFileRecord = Record<string, unknown>;
|
||||
|
||||
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<string, AuthFileRecord[]>();
|
||||
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' }),
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
export type ManagementJson = Record<string, unknown> | unknown[] | string | number | boolean | null;
|
||||
|
||||
type ManagementRequestOptions = {
|
||||
query?: Record<string, string | number | boolean | undefined>;
|
||||
body?: ManagementJson;
|
||||
};
|
||||
|
||||
const normalizeQuery = (
|
||||
query?: Record<string, string | number | boolean | undefined>,
|
||||
): Record<string, string> | undefined => {
|
||||
if (!query) {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = Object.entries(query).reduce<Record<string, string>>((result, [key, value]) => {
|
||||
if (value !== undefined) {
|
||||
result[key] = String(value);
|
||||
}
|
||||
return result;
|
||||
}, {});
|
||||
return Object.keys(normalized).length > 0 ? normalized : undefined;
|
||||
};
|
||||
|
||||
async function request<T = ManagementJson>(
|
||||
method: string,
|
||||
path: string,
|
||||
options: ManagementRequestOptions = {},
|
||||
): Promise<T> {
|
||||
return invoke<T>('management_request', {
|
||||
request: {
|
||||
method,
|
||||
path,
|
||||
query: normalizeQuery(options.query),
|
||||
body: options.body,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const managementApi = {
|
||||
get: <T = ManagementJson>(path: string, query?: ManagementRequestOptions['query']) =>
|
||||
request<T>('GET', path, { query }),
|
||||
post: <T = ManagementJson>(path: string, body?: ManagementJson) =>
|
||||
request<T>('POST', path, { body }),
|
||||
put: <T = ManagementJson>(path: string, body?: ManagementJson) =>
|
||||
request<T>('PUT', path, { body }),
|
||||
patch: <T = ManagementJson>(path: string, body?: ManagementJson) =>
|
||||
request<T>('PATCH', path, { body }),
|
||||
delete: <T = ManagementJson>(
|
||||
path: string,
|
||||
options: ManagementRequestOptions = {},
|
||||
) => request<T>('DELETE', path, options),
|
||||
uploadAuthFile: async (file: File) => {
|
||||
const data = Array.from(new Uint8Array(await file.arrayBuffer()));
|
||||
return invoke<ManagementJson>('upload_auth_file', {
|
||||
name: file.name,
|
||||
data,
|
||||
});
|
||||
},
|
||||
downloadAuthFile: (name: string) => invoke<string>('download_auth_file', { name }),
|
||||
};
|
||||
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
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<string, unknown>[] {
|
||||
if (!isRecord(payload) || !Array.isArray(payload[key])) {
|
||||
return [];
|
||||
}
|
||||
return payload[key].filter(isRecord);
|
||||
}
|
||||
|
||||
export function maskSecret(value: string): string {
|
||||
const normalized = value.trim();
|
||||
if (!normalized) {
|
||||
return '未配置';
|
||||
}
|
||||
if (normalized.length <= 8) {
|
||||
return `${normalized.slice(0, 2)}••••`;
|
||||
}
|
||||
return `${normalized.slice(0, 4)}••••${normalized.slice(-4)}`;
|
||||
}
|
||||
|
||||
export function formatDate(value: unknown): string {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return '—';
|
||||
}
|
||||
const numeric = typeof value === 'number' ? value : Number(value);
|
||||
const date = Number.isFinite(numeric)
|
||||
? new Date(numeric < 1e12 ? numeric * 1000 : numeric)
|
||||
: new Date(String(value));
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return String(value);
|
||||
}
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function normalizeAuthIndex(value: unknown): string {
|
||||
return typeof value === 'string' || typeof value === 'number' ? String(value).trim() : '';
|
||||
}
|
||||
|
||||
const messageFromPayload = (value: unknown, depth = 0): string => {
|
||||
if (value === null || value === undefined || depth > 3) return '';
|
||||
if (typeof value === 'string') {
|
||||
const text = value.trim();
|
||||
if (!text) return '';
|
||||
try {
|
||||
const parsed = JSON.parse(text) as unknown;
|
||||
const nested = messageFromPayload(parsed, depth + 1);
|
||||
if (nested) return nested;
|
||||
} catch {
|
||||
// The response is plain text rather than JSON.
|
||||
}
|
||||
return text;
|
||||
}
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
const nested = messageFromPayload(item, depth + 1);
|
||||
if (nested) return nested;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
if (isRecord(value)) {
|
||||
for (const key of ['message', 'error', 'detail', 'error_description', 'title']) {
|
||||
const nested = messageFromPayload(value[key], depth + 1);
|
||||
if (nested) return nested;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
export function apiCallErrorMessage(
|
||||
response: Record<string, unknown>,
|
||||
fallback = '上游请求失败',
|
||||
): string {
|
||||
const status = Number(response.status_code ?? response.statusCode ?? 0);
|
||||
const message = messageFromPayload(response.body ?? response.bodyText);
|
||||
if (message) return message;
|
||||
return status > 0 ? `上游返回 HTTP ${status}` : fallback;
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { apiCallErrorMessage, isRecord, managementApi, readString } from './managementApi';
|
||||
|
||||
export type ModelOption = {
|
||||
name: string;
|
||||
alias?: string;
|
||||
thinking?: Record<string, unknown>;
|
||||
};
|
||||
export type ModelProvider = 'gemini' | 'codex' | 'claude' | 'openai';
|
||||
|
||||
const DEFAULT_GEMINI_BASE_URL = 'https://generativelanguage.googleapis.com';
|
||||
const DEFAULT_CLAUDE_BASE_URL = 'https://api.anthropic.com';
|
||||
|
||||
export function normalizeBaseUrl(value: string): string {
|
||||
let raw = value.trim();
|
||||
if (!raw) return '';
|
||||
if (!/^[a-z][a-z\d+.-]*:\/\//i.test(raw)) raw = `https://${raw}`;
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(raw);
|
||||
} catch {
|
||||
throw new Error('Base URL 不是有效地址,请输入类似 https://api.example.com 的 URL');
|
||||
}
|
||||
if (!['http:', 'https:'].includes(parsed.protocol) || !parsed.hostname) {
|
||||
throw new Error('Base URL 只支持 http:// 或 https:// 地址');
|
||||
}
|
||||
parsed.hash = '';
|
||||
parsed.search = '';
|
||||
return parsed.toString()
|
||||
.replace(/\/(?:chat\/completions|messages|responses|generateContent)$/i, '')
|
||||
.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
const stripKnownSuffix = (baseUrl: string) =>
|
||||
normalizeBaseUrl(baseUrl)
|
||||
.replace(/\/(?:v1beta|v1)\/models$/i, '')
|
||||
.replace(/\/models$/i, '');
|
||||
|
||||
const endpointCandidates = (provider: ModelProvider, baseUrl: string): string[] => {
|
||||
const resolvedBaseUrl = baseUrl.trim()
|
||||
|| (provider === 'gemini'
|
||||
? DEFAULT_GEMINI_BASE_URL
|
||||
: provider === 'claude'
|
||||
? DEFAULT_CLAUDE_BASE_URL
|
||||
: '');
|
||||
const base = stripKnownSuffix(resolvedBaseUrl);
|
||||
if (!base) return [];
|
||||
const withoutVersion = base.replace(/\/(?:v1beta|v1)$/i, '');
|
||||
if (provider === 'gemini') return [`${withoutVersion}/v1beta/models`];
|
||||
if (provider === 'claude') return [`${withoutVersion}/v1/models`];
|
||||
const first = /\/v1$/i.test(base) ? `${base}/models` : `${base}/v1/models`;
|
||||
const fallback = `${base}/models`;
|
||||
return first === fallback ? [first] : [first, fallback];
|
||||
};
|
||||
|
||||
const normalizeModelList = (payload: unknown): ModelOption[] => {
|
||||
const parsed = typeof payload === 'string' ? (() => {
|
||||
try { return JSON.parse(payload) as unknown; } catch { return payload; }
|
||||
})() : payload;
|
||||
const source = isRecord(parsed)
|
||||
? (Array.isArray(parsed.data) ? parsed.data : Array.isArray(parsed.models) ? parsed.models : [])
|
||||
: Array.isArray(parsed) ? parsed : [];
|
||||
const seen = new Set<string>();
|
||||
return source.map((item): ModelOption | null => {
|
||||
const name = typeof item === 'string' ? item : isRecord(item) ? readString(item, 'id', 'name', 'model', 'value') : '';
|
||||
if (!name || seen.has(name.toLowerCase())) return null;
|
||||
seen.add(name.toLowerCase());
|
||||
const alias = typeof item === 'object' && isRecord(item) ? readString(item, 'alias', 'display_name', 'displayName') : '';
|
||||
const thinking = typeof item === 'object' && isRecord(item) && isRecord(item.thinking)
|
||||
? { ...item.thinking }
|
||||
: undefined;
|
||||
return {
|
||||
name,
|
||||
...(alias && alias !== name ? { alias } : {}),
|
||||
...(thinking ? { thinking } : {}),
|
||||
};
|
||||
}).filter((item): item is ModelOption => item !== null);
|
||||
};
|
||||
|
||||
export function modelsFromRecord(value: unknown): ModelOption[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return normalizeModelList(value);
|
||||
}
|
||||
|
||||
export async function fetchModels(
|
||||
provider: ModelProvider,
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
authIndex?: string,
|
||||
customHeaders: Record<string, string> = {},
|
||||
): Promise<ModelOption[]> {
|
||||
const normalized = baseUrl.trim() ? normalizeBaseUrl(baseUrl) : '';
|
||||
const candidates = endpointCandidates(provider, normalized);
|
||||
if (candidates.length === 0) throw new Error('请先输入 Base URL');
|
||||
const headers: Record<string, string> = { ...customHeaders };
|
||||
const hasHeader = (name: string) =>
|
||||
Object.keys(headers).some((key) => key.toLowerCase() === name.toLowerCase());
|
||||
const headerValue = (name: string) =>
|
||||
Object.entries(headers).find(([key]) => key.toLowerCase() === name.toLowerCase())?.[1] ?? '';
|
||||
const key = apiKey.trim();
|
||||
if (provider === 'gemini') {
|
||||
if (key && !hasHeader('x-goog-api-key')) headers['x-goog-api-key'] = key;
|
||||
else if (authIndex && !hasHeader('x-goog-api-key')) headers['x-goog-api-key'] = '$TOKEN$';
|
||||
} else if (provider === 'claude') {
|
||||
const bearerToken = headerValue('authorization').match(/^Bearer\s+(.+)$/i)?.[1]?.trim() ?? '';
|
||||
if (key && !hasHeader('x-api-key')) headers['x-api-key'] = key;
|
||||
else if (bearerToken && !hasHeader('x-api-key')) headers['x-api-key'] = bearerToken;
|
||||
else if (authIndex && !hasHeader('x-api-key')) headers['x-api-key'] = '$TOKEN$';
|
||||
if (!hasHeader('anthropic-version')) headers['anthropic-version'] = '2023-06-01';
|
||||
} else if (key && !hasHeader('authorization')) {
|
||||
headers.Authorization = `Bearer ${key}`;
|
||||
} else if (authIndex && !hasHeader('authorization')) {
|
||||
headers.Authorization = 'Bearer $TOKEN$';
|
||||
}
|
||||
|
||||
let lastError = '';
|
||||
for (const url of candidates) {
|
||||
try {
|
||||
const collected: ModelOption[] = [];
|
||||
const seen = new Set<string>();
|
||||
let pageToken = '';
|
||||
|
||||
for (let page = 0; page < (provider === 'gemini' ? 20 : 1); page += 1) {
|
||||
const pageUrl = new URL(url);
|
||||
if (pageToken) pageUrl.searchParams.set('pageToken', pageToken);
|
||||
const response = await managementApi.post<Record<string, unknown>>('/api-call', {
|
||||
authIndex: authIndex?.trim() || undefined,
|
||||
method: 'GET',
|
||||
url: pageUrl.toString(),
|
||||
header: Object.keys(headers).length ? headers : undefined,
|
||||
});
|
||||
const status = Number(response.status_code ?? response.statusCode ?? 0);
|
||||
if (status < 200 || status >= 300) {
|
||||
lastError = apiCallErrorMessage(response);
|
||||
break;
|
||||
}
|
||||
|
||||
const payload = response.body ?? response.bodyText;
|
||||
normalizeModelList(payload).forEach((model) => {
|
||||
const name = provider === 'gemini' ? model.name.replace(/^models\//i, '') : model.name;
|
||||
const dedupeKey = name.toLowerCase();
|
||||
if (!name || seen.has(dedupeKey)) return;
|
||||
seen.add(dedupeKey);
|
||||
collected.push(
|
||||
name === model.name
|
||||
? model
|
||||
: { ...model, name, alias: model.alias === model.name ? undefined : model.alias },
|
||||
);
|
||||
});
|
||||
|
||||
const parsedPayload = typeof payload === 'string'
|
||||
? (() => {
|
||||
try { return JSON.parse(payload) as unknown; } catch { return null; }
|
||||
})()
|
||||
: payload;
|
||||
pageToken = isRecord(parsedPayload) ? readString(parsedPayload, 'nextPageToken') : '';
|
||||
if (!pageToken) break;
|
||||
}
|
||||
|
||||
if (collected.length) return collected;
|
||||
|
||||
if (provider === 'openai' && Object.keys(headers).length > 0) {
|
||||
const response = await managementApi.post<Record<string, unknown>>('/api-call', {
|
||||
method: 'GET',
|
||||
url,
|
||||
});
|
||||
const status = Number(response.status_code ?? response.statusCode ?? 0);
|
||||
if (status >= 200 && status < 300) {
|
||||
const models = normalizeModelList(response.body ?? response.bodyText);
|
||||
if (models.length) return models;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = String(error);
|
||||
if (provider === 'openai' && Object.keys(headers).length > 0) {
|
||||
try {
|
||||
const response = await managementApi.post<Record<string, unknown>>('/api-call', {
|
||||
method: 'GET',
|
||||
url,
|
||||
});
|
||||
const status = Number(response.status_code ?? response.statusCode ?? 0);
|
||||
if (status >= 200 && status < 300) {
|
||||
const models = normalizeModelList(response.body ?? response.bodyText);
|
||||
if (models.length) return models;
|
||||
}
|
||||
} catch {
|
||||
// Keep the authenticated request error as the useful failure reason.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(lastError || '模型接口无响应');
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { isRecord, readString } from './managementApi';
|
||||
|
||||
export type OAuthModelDefinition = {
|
||||
id: string;
|
||||
displayName?: string;
|
||||
};
|
||||
|
||||
export const oauthModelsFromPayload = (payload: unknown): OAuthModelDefinition[] => {
|
||||
if (!isRecord(payload) || !Array.isArray(payload.models)) return [];
|
||||
const seen = new Set<string>();
|
||||
return payload.models.flatMap((item) => {
|
||||
if (!isRecord(item)) return [];
|
||||
const id = readString(item, 'id', 'name');
|
||||
const key = id.toLowerCase();
|
||||
if (!id || seen.has(key)) return [];
|
||||
seen.add(key);
|
||||
const displayName = readString(item, 'display_name', 'displayName');
|
||||
return [{ id, displayName: displayName && displayName !== id ? displayName : undefined }];
|
||||
});
|
||||
};
|
||||
|
||||
export const oauthExcludedRulesFromPayload = (payload: unknown, provider: string): string[] => {
|
||||
if (!isRecord(payload)) return [];
|
||||
const source = isRecord(payload['oauth-excluded-models'])
|
||||
? payload['oauth-excluded-models']
|
||||
: payload;
|
||||
const value = source[provider.trim().toLowerCase()];
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.map(String)
|
||||
.map((rule) => rule.trim().toLowerCase())
|
||||
.filter((rule, index, rules) => rule && rules.indexOf(rule) === index);
|
||||
};
|
||||
|
||||
const wildcardPattern = (rule: string) => new RegExp(
|
||||
`^${rule.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')}$`,
|
||||
'i',
|
||||
);
|
||||
|
||||
export const modelMatchesRule = (modelId: string, rule: string) => {
|
||||
const normalizedRule = rule.trim();
|
||||
if (!normalizedRule) return false;
|
||||
return wildcardPattern(normalizedRule).test(modelId.trim());
|
||||
};
|
||||
|
||||
export const openOAuthModelNames = (
|
||||
models: OAuthModelDefinition[],
|
||||
excludedRules: Iterable<string>,
|
||||
) => {
|
||||
const rules = Array.from(excludedRules, (rule) => rule.trim()).filter(Boolean);
|
||||
return new Set(
|
||||
models
|
||||
.filter((model) => !rules.some((rule) => modelMatchesRule(model.id, rule)))
|
||||
.map((model) => model.id.toLowerCase()),
|
||||
);
|
||||
};
|
||||
|
||||
export const exclusionsForOpenOAuthModels = (
|
||||
currentRules: Iterable<string>,
|
||||
models: OAuthModelDefinition[],
|
||||
openModelNames: Iterable<string>,
|
||||
) => {
|
||||
const open = new Set(
|
||||
Array.from(openModelNames, (name) => name.trim().toLowerCase()).filter(Boolean),
|
||||
);
|
||||
const modelIds = new Set(models.map((model) => model.id.toLowerCase()));
|
||||
const openedModels = models.filter((model) => open.has(model.id.toLowerCase()));
|
||||
const preservedRules = Array.from(currentRules, (rule) => rule.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
.filter((rule) => {
|
||||
if (modelIds.has(rule)) return false;
|
||||
return !openedModels.some((model) => modelMatchesRule(model.id, rule));
|
||||
});
|
||||
const closedModels = models
|
||||
.filter((model) => !open.has(model.id.toLowerCase()))
|
||||
.map((model) => model.id.toLowerCase());
|
||||
|
||||
return [...preservedRules, ...closedModels]
|
||||
.filter((rule, index, rules) => rules.indexOf(rule) === index)
|
||||
.sort();
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import type { QuotaState } from './quotaService';
|
||||
|
||||
type QuotaCache = Record<string, QuotaState>;
|
||||
type QuotaCacheUpdater = QuotaCache | ((current: QuotaCache) => QuotaCache);
|
||||
|
||||
let cache: QuotaCache = {};
|
||||
let generation = 0;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
const subscribe = (listener: () => void) => {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
};
|
||||
|
||||
const getSnapshot = () => cache;
|
||||
export const getQuotaCacheSnapshot = getSnapshot;
|
||||
export const captureQuotaCacheGeneration = () => generation;
|
||||
|
||||
export const commitQuotaCacheIfCurrent = (expectedGeneration: number, commit: () => void) => {
|
||||
if (generation !== expectedGeneration) return false;
|
||||
commit();
|
||||
return true;
|
||||
};
|
||||
|
||||
export const updateQuotaCache = (updater: QuotaCacheUpdater) => {
|
||||
const next = typeof updater === 'function' ? updater(cache) : updater;
|
||||
if (Object.is(next, cache)) return;
|
||||
cache = next;
|
||||
listeners.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
export const pruneQuotaCache = (validKeys: Set<string>) => {
|
||||
updateQuotaCache((current) => {
|
||||
const next = Object.fromEntries(
|
||||
Object.entries(current)
|
||||
.filter(([key]) => validKeys.has(key))
|
||||
.map(([key, value]) => [
|
||||
key,
|
||||
value.status === 'loading' ? { status: 'idle', rows: [] } : value,
|
||||
]),
|
||||
) as QuotaCache;
|
||||
const unchanged = Object.keys(next).length === Object.keys(current).length;
|
||||
if (unchanged) return current;
|
||||
generation += 1;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
export function useQuotaCache() {
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
}
|
||||
@@ -0,0 +1,882 @@
|
||||
import {
|
||||
apiCallErrorMessage,
|
||||
isRecord,
|
||||
managementApi,
|
||||
normalizeAuthIndex,
|
||||
readString,
|
||||
} from './managementApi';
|
||||
import { authFileName } from './authFiles';
|
||||
|
||||
export type AuthFile = Record<string, unknown>;
|
||||
export type QuotaProvider = 'claude' | 'codex' | 'kimi' | 'xai' | 'antigravity';
|
||||
export type QuotaStatus = 'idle' | 'loading' | 'success' | 'error';
|
||||
export type QuotaRow = {
|
||||
label: string;
|
||||
remainingPercent: number | null;
|
||||
reset?: string;
|
||||
detail?: string;
|
||||
};
|
||||
export type QuotaState = {
|
||||
status: QuotaStatus;
|
||||
rows: QuotaRow[];
|
||||
error?: string;
|
||||
plan?: string;
|
||||
resetCredits?: number;
|
||||
resetCreditsEarliestExpiry?: string;
|
||||
fetchedAt?: number;
|
||||
};
|
||||
|
||||
export const idleQuota = (): QuotaState => ({ status: 'idle', rows: [] });
|
||||
|
||||
const endpointByProvider: Record<QuotaProvider, string> = {
|
||||
claude: 'https://api.anthropic.com/api/oauth/usage',
|
||||
codex: 'https://chatgpt.com/backend-api/wham/usage',
|
||||
kimi: 'https://api.kimi.com/coding/v1/usages',
|
||||
xai: 'https://cli-chat-proxy.grok.com/v1/billing',
|
||||
antigravity: 'https://daily-cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary',
|
||||
};
|
||||
|
||||
const CLAUDE_PROFILE_URL = 'https://api.anthropic.com/api/oauth/profile';
|
||||
const XAI_WEEKLY_URL = 'https://cli-chat-proxy.grok.com/v1/billing?format=credits';
|
||||
const CODEX_RESET_CREDITS_URL =
|
||||
'https://chatgpt.com/backend-api/wham/rate-limit-reset-credits';
|
||||
const CODEX_RESET_CREDITS_CONSUME_URL =
|
||||
'https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume';
|
||||
const ANTIGRAVITY_CODE_ASSIST_URL =
|
||||
'https://daily-cloudcode-pa.googleapis.com/v1internal:loadCodeAssist';
|
||||
|
||||
const headersByProvider: Record<QuotaProvider, Record<string, string>> = {
|
||||
claude: {
|
||||
Authorization: 'Bearer $TOKEN$',
|
||||
'Content-Type': 'application/json',
|
||||
'anthropic-beta': 'oauth-2025-04-20',
|
||||
},
|
||||
codex: {
|
||||
Authorization: 'Bearer $TOKEN$',
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'codex_cli_rs/0.76.0 (Debian 13.0.0; x86_64) WindowsTerminal',
|
||||
},
|
||||
kimi: { Authorization: 'Bearer $TOKEN$' },
|
||||
xai: {
|
||||
Authorization: 'Bearer $TOKEN$',
|
||||
'x-xai-token-auth': 'xai-grok-cli',
|
||||
'x-grok-client-version': '0.2.91',
|
||||
accept: '*/*',
|
||||
'user-agent': 'grok-pager/0.2.91 grok-shell/0.2.91 (macos; aarch64)',
|
||||
},
|
||||
antigravity: {
|
||||
Authorization: 'Bearer $TOKEN$',
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'antigravity/cli/1.0.13 (aidev_client; os_type=darwin; arch=arm64)',
|
||||
},
|
||||
};
|
||||
|
||||
export const providerForFile = (file: AuthFile): QuotaProvider | null => {
|
||||
const value = readString(file, 'provider', 'type', 'account_type').toLowerCase();
|
||||
if (value === 'anthropic') return 'claude';
|
||||
if (value === 'anti-gravity') return 'antigravity';
|
||||
return ['claude', 'codex', 'kimi', 'xai', 'antigravity'].includes(value)
|
||||
? (value as QuotaProvider)
|
||||
: null;
|
||||
};
|
||||
|
||||
export const fileName = authFileName;
|
||||
|
||||
export const quotaKey = (file: AuthFile) =>
|
||||
`${fileName(file)}::${normalizeAuthIndex(file.auth_index ?? file.authIndex)}`;
|
||||
|
||||
const parseBody = (value: unknown): unknown => {
|
||||
if (typeof value !== 'string') return value;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
const numberValue = (value: unknown): number | null => {
|
||||
if (isRecord(value) && 'val' in value) return numberValue(value.val);
|
||||
if (value === null || value === undefined || typeof value === 'boolean') return null;
|
||||
if (typeof value === 'string' && !value.trim()) return null;
|
||||
const parsed = typeof value === 'number' ? value : Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
};
|
||||
|
||||
const clampPercent = (value: unknown): number | null => {
|
||||
const parsed = numberValue(value);
|
||||
if (parsed === null) return null;
|
||||
return Math.max(0, Math.min(100, parsed));
|
||||
};
|
||||
|
||||
const remainingFromUsedPercent = (value: unknown): number | null => {
|
||||
const used = clampPercent(value);
|
||||
return used === null ? null : Math.max(0, Math.min(100, 100 - used));
|
||||
};
|
||||
|
||||
const quotaFraction = (value: unknown): number | null => {
|
||||
if (typeof value === 'string' && value.trim().endsWith('%')) {
|
||||
const parsed = Number(value.trim().slice(0, -1));
|
||||
return Number.isFinite(parsed) ? Math.max(0, Math.min(1, parsed / 100)) : null;
|
||||
}
|
||||
const parsed = numberValue(value);
|
||||
return parsed === null ? null : Math.max(0, Math.min(1, parsed));
|
||||
};
|
||||
|
||||
const formatDateTime = (date: Date): string | undefined => {
|
||||
if (Number.isNaN(date.getTime())) return undefined;
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
export const formatQuotaTimestamp = (value: string | undefined): string => {
|
||||
if (!value) return '—';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '—';
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
const absoluteResetLabel = (value: unknown): string | undefined => {
|
||||
if (value === null || value === undefined || value === '') return undefined;
|
||||
const numeric = numberValue(value);
|
||||
const numericText = typeof value === 'string' && /^\d+(?:\.\d+)?$/.test(value.trim());
|
||||
const date = typeof value === 'number' || numericText
|
||||
? new Date((numeric ?? 0) < 1e12 ? (numeric ?? 0) * 1000 : (numeric ?? 0))
|
||||
: new Date(String(value));
|
||||
return formatDateTime(date);
|
||||
};
|
||||
|
||||
const relativeResetLabel = (value: unknown): string | undefined => {
|
||||
const seconds = numberValue(value);
|
||||
if (seconds === null || seconds <= 0) return undefined;
|
||||
const minutes = Math.max(1, Math.ceil(seconds / 60));
|
||||
const days = Math.floor(minutes / 1440);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remainingHours = Math.floor((minutes % 1440) / 60);
|
||||
const remainingMinutes = minutes % 60;
|
||||
if (days > 0) {
|
||||
return remainingHours > 0 ? `${days} 天 ${remainingHours} 小时后` : `${days} 天后`;
|
||||
}
|
||||
if (hours > 0) {
|
||||
return remainingMinutes > 0 ? `${hours} 小时 ${remainingMinutes} 分钟后` : `${hours} 小时后`;
|
||||
}
|
||||
return `${minutes} 分钟后`;
|
||||
};
|
||||
|
||||
const formatUsdFromCents = (value: number | null) =>
|
||||
value === null
|
||||
? undefined
|
||||
: new Intl.NumberFormat(undefined, { style: 'currency', currency: 'USD' }).format(value / 100);
|
||||
|
||||
const codexResetLabel = (window: Record<string, unknown>): string | undefined =>
|
||||
absoluteResetLabel(window.reset_at ?? window.resetAt)
|
||||
?? relativeResetLabel(window.reset_after_seconds ?? window.resetAfterSeconds);
|
||||
|
||||
const FIVE_HOUR_SECONDS = 18_000;
|
||||
const WEEK_SECONDS = 604_800;
|
||||
const MIN_MONTH_SECONDS = 28 * 86_400;
|
||||
const MAX_MONTH_SECONDS = 31 * 86_400;
|
||||
|
||||
const formatWindowDuration = (seconds: number | null): string => {
|
||||
if (seconds === null || seconds <= 0) return '未知周期';
|
||||
const day = 86_400;
|
||||
const hour = 3_600;
|
||||
const minute = 60;
|
||||
if (seconds % day === 0) return `${seconds / day} 天`;
|
||||
if (seconds % hour === 0) return `${seconds / hour} 小时`;
|
||||
if (seconds % minute === 0) return `${seconds / minute} 分钟`;
|
||||
return `${seconds} 秒`;
|
||||
};
|
||||
|
||||
const codexWindowLabel = (
|
||||
duration: number | null,
|
||||
prefix: string,
|
||||
kind: 'primary' | 'secondary',
|
||||
teamPlan: boolean,
|
||||
) => {
|
||||
if (duration === FIVE_HOUR_SECONDS) return `${prefix}5 小时限额`;
|
||||
if (duration === WEEK_SECONDS) return `${prefix}周限额`;
|
||||
if (duration !== null && duration >= MIN_MONTH_SECONDS && duration <= MAX_MONTH_SECONDS) {
|
||||
return `${prefix}月限额`;
|
||||
}
|
||||
if (duration !== null) return `${prefix}${formatWindowDuration(duration)}限额`;
|
||||
if (kind === 'primary') return `${prefix}5 小时限额`;
|
||||
return `${prefix}${teamPlan ? '月限额' : '周限额'}`;
|
||||
};
|
||||
|
||||
const codexWindowRows = (value: Record<string, unknown>): QuotaRow[] => {
|
||||
const windows: Array<{
|
||||
raw: unknown;
|
||||
kind: 'primary' | 'secondary';
|
||||
prefix: string;
|
||||
source: Record<string, unknown>;
|
||||
}> = [];
|
||||
const addRateLimit = (rawRateLimit: unknown, prefix: string) => {
|
||||
if (!isRecord(rawRateLimit)) return;
|
||||
windows.push(
|
||||
{ raw: rawRateLimit.primary_window ?? rawRateLimit.primaryWindow, kind: 'primary', prefix, source: rawRateLimit },
|
||||
{ raw: rawRateLimit.secondary_window ?? rawRateLimit.secondaryWindow, kind: 'secondary', prefix, source: rawRateLimit },
|
||||
);
|
||||
};
|
||||
|
||||
addRateLimit(value.rate_limit ?? value.rateLimit, '');
|
||||
addRateLimit(value.code_review_rate_limit ?? value.codeReviewRateLimit, '代码审查 ');
|
||||
const additional = value.additional_rate_limits ?? value.additionalRateLimits;
|
||||
if (Array.isArray(additional)) {
|
||||
additional.forEach((item, index) => {
|
||||
if (!isRecord(item)) return;
|
||||
const name = readString(item, 'limit_name', 'limitName', 'metered_feature', 'meteredFeature') || `附加 ${index + 1}`;
|
||||
addRateLimit(item.rate_limit ?? item.rateLimit, `${name} `);
|
||||
});
|
||||
}
|
||||
|
||||
const teamPlan = readString(value, 'plan_type', 'planType').toLowerCase() === 'team';
|
||||
return windows.map(({ raw, kind, prefix, source }): QuotaRow | null => {
|
||||
if (!isRecord(raw)) return null;
|
||||
const duration = numberValue(raw.limit_window_seconds ?? raw.limitWindowSeconds);
|
||||
const reached = source.limit_reached === true || source.limitReached === true || source.allowed === false;
|
||||
return {
|
||||
label: codexWindowLabel(duration, prefix, kind, teamPlan),
|
||||
remainingPercent: remainingFromUsedPercent(raw.used_percent ?? raw.usedPercent) ?? (reached ? 0 : null),
|
||||
reset: codexResetLabel(raw),
|
||||
};
|
||||
}).filter((row): row is QuotaRow => row !== null);
|
||||
};
|
||||
|
||||
export const codexResetCreditsFor = (payload: unknown): number | undefined => {
|
||||
const value = parseBody(payload);
|
||||
if (!isRecord(value)) return undefined;
|
||||
const credits = isRecord(value.rate_limit_reset_credits)
|
||||
? value.rate_limit_reset_credits
|
||||
: isRecord(value.rateLimitResetCredits)
|
||||
? value.rateLimitResetCredits
|
||||
: null;
|
||||
const count = numberValue(credits?.available_count ?? credits?.availableCount);
|
||||
return count === null ? undefined : Math.max(0, Math.floor(count));
|
||||
};
|
||||
|
||||
export const codexResetCreditDetailsFor = (
|
||||
payload: unknown,
|
||||
nowMs = Date.now(),
|
||||
): { availableCount?: number; earliestExpiry?: string } => {
|
||||
const value = parseBody(payload);
|
||||
if (!isRecord(value)) return {};
|
||||
const credits = Array.isArray(value.credits)
|
||||
? value.credits
|
||||
.filter(isRecord)
|
||||
.filter((credit) =>
|
||||
readString(credit, 'reset_type', 'resetType') === 'codex_rate_limits'
|
||||
&& readString(credit, 'status') === 'available',
|
||||
)
|
||||
: [];
|
||||
const availableCount = numberValue(value.available_count ?? value.availableCount);
|
||||
const earliestExpiry = credits
|
||||
.map((credit) => readString(credit, 'expires_at', 'expiresAt'))
|
||||
.map((expiresAt) => ({ expiresAt, expiresAtMs: new Date(expiresAt).getTime() }))
|
||||
.filter((credit) =>
|
||||
credit.expiresAt
|
||||
&& Number.isFinite(credit.expiresAtMs)
|
||||
&& credit.expiresAtMs > nowMs,
|
||||
)
|
||||
.sort((left, right) => left.expiresAtMs - right.expiresAtMs)[0]?.expiresAt;
|
||||
|
||||
return {
|
||||
availableCount: availableCount === null
|
||||
? credits.length || undefined
|
||||
: Math.max(0, Math.floor(availableCount)),
|
||||
earliestExpiry,
|
||||
};
|
||||
};
|
||||
|
||||
export const quotaRowsFor = (provider: QuotaProvider, payload: unknown): QuotaRow[] => {
|
||||
const value = parseBody(payload);
|
||||
if (!isRecord(value)) return [];
|
||||
|
||||
if (provider === 'codex') return codexWindowRows(value);
|
||||
|
||||
if (provider === 'claude') {
|
||||
const labels: Record<string, string> = {
|
||||
five_hour: '5 小时窗口',
|
||||
seven_day: '7 天窗口',
|
||||
seven_day_oauth_apps: '7 天 OAuth 应用窗口',
|
||||
seven_day_opus: '7 天 Opus 窗口',
|
||||
seven_day_sonnet: '7 天 Sonnet 窗口',
|
||||
seven_day_cowork: '7 天 Cowork 窗口',
|
||||
iguana_necktie: 'Iguana Necktie',
|
||||
};
|
||||
const rows = Object.entries(value)
|
||||
.filter(([key]) => key in labels)
|
||||
.map(([key, raw]): QuotaRow | null => {
|
||||
if (!isRecord(raw)) return null;
|
||||
return {
|
||||
label: labels[key],
|
||||
remainingPercent: remainingFromUsedPercent(raw.utilization),
|
||||
reset: absoluteResetLabel(raw.resets_at ?? raw.resetsAt),
|
||||
};
|
||||
})
|
||||
.filter((row): row is QuotaRow => row !== null);
|
||||
const extraUsage = isRecord(value.extra_usage)
|
||||
? value.extra_usage
|
||||
: isRecord(value.extraUsage)
|
||||
? value.extraUsage
|
||||
: null;
|
||||
if (extraUsage && booleanValue(extraUsage.is_enabled ?? extraUsage.isEnabled) === true) {
|
||||
const monthlyLimit = numberValue(extraUsage.monthly_limit ?? extraUsage.monthlyLimit);
|
||||
const usedCredits = numberValue(extraUsage.used_credits ?? extraUsage.usedCredits);
|
||||
const computedRemaining = monthlyLimit !== null && monthlyLimit > 0 && usedCredits !== null
|
||||
? ((monthlyLimit - usedCredits) / monthlyLimit) * 100
|
||||
: null;
|
||||
const usedLabel = formatUsdFromCents(usedCredits);
|
||||
const limitLabel = formatUsdFromCents(monthlyLimit);
|
||||
rows.push({
|
||||
label: '额外用量',
|
||||
remainingPercent:
|
||||
remainingFromUsedPercent(extraUsage.utilization)
|
||||
?? clampPercent(computedRemaining),
|
||||
detail: usedLabel && limitLabel ? `已用 ${usedLabel} / ${limitLabel}` : undefined,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
if (provider === 'kimi') {
|
||||
const items: unknown[] = [];
|
||||
if (isRecord(value.usage)) items.push({ ...value.usage, label: '每周额度' });
|
||||
if (Array.isArray(value.limits)) items.push(...value.limits);
|
||||
return items
|
||||
.map((raw, index): QuotaRow | null => {
|
||||
if (!isRecord(raw)) return null;
|
||||
const detail = isRecord(raw.detail) ? raw.detail : raw;
|
||||
const limit = numberValue(detail.limit);
|
||||
const used = numberValue(detail.used);
|
||||
const remaining = numberValue(detail.remaining);
|
||||
const usedValue = used ?? (limit !== null && remaining !== null ? limit - remaining : null);
|
||||
if (usedValue === null && limit === null) return null;
|
||||
const window = isRecord(raw.window) ? raw.window : null;
|
||||
const duration = numberValue(window?.duration ?? raw.duration ?? detail.duration);
|
||||
const unit = readString(window, 'timeUnit', 'time_unit')
|
||||
|| readString(raw, 'timeUnit', 'time_unit')
|
||||
|| readString(detail, 'timeUnit', 'time_unit');
|
||||
const durationLabel = duration !== null && duration > 0
|
||||
? `${unit.toLowerCase().startsWith('day')
|
||||
? `${duration} 天`
|
||||
: unit.toLowerCase().startsWith('hour')
|
||||
? `${duration} 小时`
|
||||
: unit.toLowerCase().startsWith('second')
|
||||
? `${duration} 秒`
|
||||
: duration % 60 === 0
|
||||
? `${duration / 60} 小时`
|
||||
: `${duration} 分钟`}窗口`
|
||||
: '';
|
||||
return {
|
||||
label:
|
||||
readString(raw, 'label', 'name', 'title', 'scope')
|
||||
|| readString(detail, 'name', 'title', 'scope')
|
||||
|| durationLabel
|
||||
|| `限制 ${index + 1}`,
|
||||
remainingPercent: clampPercent(
|
||||
limit !== null && limit > 0
|
||||
? (Math.max(0, limit - (usedValue ?? 0)) / limit) * 100
|
||||
: (usedValue ?? 0) > 0
|
||||
? 0
|
||||
: null,
|
||||
),
|
||||
reset:
|
||||
absoluteResetLabel(
|
||||
detail.reset_at ?? detail.resetAt ?? detail.reset_time ?? detail.resetTime,
|
||||
)
|
||||
?? relativeResetLabel(detail.reset_in ?? detail.resetIn ?? detail.ttl),
|
||||
detail: limit === null ? undefined : `${usedValue ?? 0} / ${limit}`,
|
||||
};
|
||||
})
|
||||
.filter((row): row is QuotaRow => row !== null);
|
||||
}
|
||||
|
||||
if (provider === 'xai') {
|
||||
const payloads = isRecord(value.weekly) || isRecord(value.monthly)
|
||||
? [value.weekly, value.monthly]
|
||||
: [value];
|
||||
const rows: QuotaRow[] = [];
|
||||
|
||||
payloads.forEach((payload) => {
|
||||
if (!isRecord(payload)) return;
|
||||
const config = isRecord(payload.config) ? payload.config : payload;
|
||||
const currentPeriod = isRecord(config.current_period)
|
||||
? config.current_period
|
||||
: isRecord(config.currentPeriod)
|
||||
? config.currentPeriod
|
||||
: null;
|
||||
const periodType = readString(currentPeriod, 'type').toLowerCase();
|
||||
const weeklyUsed = numberValue(config.credit_usage_percent ?? config.creditUsagePercent);
|
||||
|
||||
if (weeklyUsed !== null || periodType.includes('week')) {
|
||||
rows.push({
|
||||
label: '每周额度',
|
||||
remainingPercent: remainingFromUsedPercent(weeklyUsed),
|
||||
reset: absoluteResetLabel(currentPeriod?.end),
|
||||
});
|
||||
const productUsage = Array.isArray(config.product_usage)
|
||||
? config.product_usage
|
||||
: Array.isArray(config.productUsage)
|
||||
? config.productUsage
|
||||
: [];
|
||||
productUsage.forEach((item, index) => {
|
||||
if (!isRecord(item)) return;
|
||||
rows.push({
|
||||
label: readString(item, 'product') || `产品 ${index + 1}`,
|
||||
remainingPercent: remainingFromUsedPercent(
|
||||
item.usage_percent ?? item.usagePercent,
|
||||
),
|
||||
reset: absoluteResetLabel(currentPeriod?.end),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const limit = numberValue(config.monthly_limit ?? config.monthlyLimit);
|
||||
const used = numberValue(config.used);
|
||||
const includedUsed = used === null
|
||||
? null
|
||||
: limit !== null && limit > 0
|
||||
? Math.min(used, limit)
|
||||
: used;
|
||||
if (limit !== null || used !== null) {
|
||||
const remaining = limit !== null && limit > 0 && includedUsed !== null
|
||||
? ((limit - includedUsed) / limit) * 100
|
||||
: null;
|
||||
rows.push({
|
||||
label: '月度包含额度',
|
||||
remainingPercent: clampPercent(remaining),
|
||||
reset: absoluteResetLabel(config.billing_period_end ?? config.billingPeriodEnd),
|
||||
detail: limit !== null
|
||||
? `${formatUsdFromCents(Math.max(0, limit - (includedUsed ?? 0)))} / ${formatUsdFromCents(limit)}`
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const onDemandCap = numberValue(config.on_demand_cap ?? config.onDemandCap);
|
||||
const explicitOnDemandUsed = numberValue(config.on_demand_used ?? config.onDemandUsed);
|
||||
const onDemandUsed = explicitOnDemandUsed
|
||||
?? (used !== null && limit !== null ? Math.max(0, used - limit) : null);
|
||||
if (onDemandCap !== null && onDemandCap > 0) {
|
||||
rows.push({
|
||||
label: '按量付费额度',
|
||||
remainingPercent: clampPercent(
|
||||
onDemandUsed === null ? null : ((onDemandCap - onDemandUsed) / onDemandCap) * 100,
|
||||
),
|
||||
reset: absoluteResetLabel(config.billing_period_end ?? config.billingPeriodEnd),
|
||||
detail: `${formatUsdFromCents(Math.max(0, onDemandCap - (onDemandUsed ?? 0)))} / ${formatUsdFromCents(onDemandCap)}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const seen = new Set<string>();
|
||||
return rows.filter((row) => {
|
||||
const key = `${row.label}::${row.reset ?? ''}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
const groups = Array.isArray(value.groups) ? value.groups : [];
|
||||
return groups.flatMap((group) => {
|
||||
if (!isRecord(group) || !Array.isArray(group.buckets)) return [];
|
||||
const buckets = group.buckets;
|
||||
const groupLabel = readString(group, 'display_name', 'displayName') || '配额';
|
||||
const groupDescription = readString(group, 'description');
|
||||
return buckets
|
||||
.map((bucket, index): QuotaRow | null => {
|
||||
if (!isRecord(bucket)) return null;
|
||||
const remaining = quotaFraction(bucket.remaining_fraction ?? bucket.remainingFraction);
|
||||
if (remaining === null) return null;
|
||||
const bucketLabel = readString(bucket, 'display_name', 'displayName', 'window');
|
||||
const label = bucketLabel && (buckets.length > 1 || bucketLabel !== groupLabel)
|
||||
? `${groupLabel} · ${bucketLabel}`
|
||||
: groupLabel;
|
||||
return {
|
||||
label: label || `配额 ${index + 1}`,
|
||||
remainingPercent: remaining * 100,
|
||||
reset: absoluteResetLabel(bucket.reset_time ?? bucket.resetTime),
|
||||
detail: readString(bucket, 'description') || groupDescription || undefined,
|
||||
};
|
||||
})
|
||||
.filter((row): row is QuotaRow => row !== null);
|
||||
});
|
||||
};
|
||||
|
||||
const resolveProjectId = async (file: AuthFile): Promise<string> => {
|
||||
const direct = readString(file, 'project_id', 'projectId');
|
||||
if (direct) return direct;
|
||||
try {
|
||||
const content = await managementApi.downloadAuthFile(fileName(file));
|
||||
const parsed = JSON.parse(content) as Record<string, unknown>;
|
||||
const direct = readString(parsed, 'project_id', 'projectId');
|
||||
if (direct) return direct;
|
||||
for (const container of [parsed.installed, parsed.web]) {
|
||||
if (!isRecord(container)) continue;
|
||||
const nested = readString(container, 'project_id', 'projectId');
|
||||
if (nested) return nested;
|
||||
}
|
||||
return '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const decodeJwtPayload = (value: unknown): Record<string, unknown> | null => {
|
||||
if (isRecord(value)) return value;
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (isRecord(parsed)) return parsed;
|
||||
} catch {
|
||||
// Continue with JWT payload decoding.
|
||||
}
|
||||
const segment = trimmed.split('.')[1];
|
||||
if (!segment) return null;
|
||||
try {
|
||||
const normalized = segment.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(segment.length / 4) * 4, '=');
|
||||
const parsed = JSON.parse(atob(normalized));
|
||||
return isRecord(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const codexAccountIdFromRecord = (record: Record<string, unknown>): string => {
|
||||
const direct = readString(
|
||||
record,
|
||||
'chatgpt_account_id',
|
||||
'chatgptAccountId',
|
||||
'account_id',
|
||||
'accountId',
|
||||
);
|
||||
if (direct) return direct;
|
||||
|
||||
for (const container of [record.metadata, record.attributes]) {
|
||||
if (!isRecord(container)) continue;
|
||||
const nestedDirect = readString(
|
||||
container,
|
||||
'chatgpt_account_id',
|
||||
'chatgptAccountId',
|
||||
'account_id',
|
||||
'accountId',
|
||||
);
|
||||
if (nestedDirect) return nestedDirect;
|
||||
}
|
||||
|
||||
for (const candidate of [record.id_token, record.idToken]) {
|
||||
const payload = decodeJwtPayload(candidate);
|
||||
const nested = payload && isRecord(payload['https://api.openai.com/auth']) ? payload['https://api.openai.com/auth'] : payload;
|
||||
const accountId = nested
|
||||
? readString(nested, 'chatgpt_account_id', 'chatgptAccountId', 'account_id', 'accountId')
|
||||
: '';
|
||||
if (accountId) return accountId;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const resolveCodexAccountId = async (file: AuthFile): Promise<string> => {
|
||||
const direct = codexAccountIdFromRecord(file);
|
||||
if (direct) return direct;
|
||||
|
||||
try {
|
||||
const content = await managementApi.downloadAuthFile(fileName(file));
|
||||
const parsed = JSON.parse(content);
|
||||
return isRecord(parsed) ? codexAccountIdFromRecord(parsed) : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const xaiUserIdFromRecord = (record: Record<string, unknown>): string => {
|
||||
const nestedRecords = [record, record.metadata, record.attributes]
|
||||
.filter(isRecord);
|
||||
for (const source of nestedRecords) {
|
||||
const direct = readString(source, 'sub', 'subject', 'user_id', 'userId');
|
||||
if (direct) return direct;
|
||||
for (const container of [source.oauth, source.user]) {
|
||||
if (!isRecord(container)) continue;
|
||||
const nested = readString(container, 'sub', 'subject', 'user_id', 'userId', 'id');
|
||||
if (nested) return nested;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const resolveXaiUserId = async (file: AuthFile): Promise<string> => {
|
||||
const direct = xaiUserIdFromRecord(file);
|
||||
if (direct) return direct;
|
||||
try {
|
||||
const content = await managementApi.downloadAuthFile(fileName(file));
|
||||
const parsed = JSON.parse(content);
|
||||
return isRecord(parsed) ? xaiUserIdFromRecord(parsed) : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const requestQuotaPayload = async (
|
||||
authIndex: string,
|
||||
url: string,
|
||||
header: Record<string, string>,
|
||||
method: 'GET' | 'POST' = 'GET',
|
||||
data?: string,
|
||||
) => {
|
||||
const response = await managementApi.post<Record<string, unknown>>('/api-call', {
|
||||
authIndex,
|
||||
method,
|
||||
url,
|
||||
header,
|
||||
data,
|
||||
});
|
||||
const status = Number(response.status_code ?? response.statusCode ?? 0);
|
||||
if (status < 200 || status >= 300) {
|
||||
throw new Error(apiCallErrorMessage(response));
|
||||
}
|
||||
return parseBody(response.body ?? response.bodyText);
|
||||
};
|
||||
|
||||
const callXaiQuota = async (file: AuthFile): Promise<unknown> => {
|
||||
const authIndex = normalizeAuthIndex(file.auth_index ?? file.authIndex);
|
||||
if (!authIndex) throw new Error('缺少 auth-index,无法查询配额');
|
||||
const header = { ...headersByProvider.xai };
|
||||
const userId = await resolveXaiUserId(file);
|
||||
if (userId) header['x-userid'] = userId;
|
||||
const [weekly, monthly] = await Promise.allSettled([
|
||||
requestQuotaPayload(authIndex, XAI_WEEKLY_URL, header),
|
||||
requestQuotaPayload(authIndex, endpointByProvider.xai, header),
|
||||
]);
|
||||
if (weekly.status === 'rejected' && monthly.status === 'rejected') {
|
||||
throw weekly.reason;
|
||||
}
|
||||
return {
|
||||
weekly: weekly.status === 'fulfilled' ? weekly.value : null,
|
||||
monthly: monthly.status === 'fulfilled' ? monthly.value : null,
|
||||
};
|
||||
};
|
||||
|
||||
const booleanValue = (value: unknown): boolean | null => {
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (typeof value === 'number') return value !== 0;
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (['true', '1', 'yes', 'on'].includes(normalized)) return true;
|
||||
if (['false', '0', 'no', 'off'].includes(normalized)) return false;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const resolveClaudePlan = (payload: unknown): string | undefined => {
|
||||
if (!isRecord(payload)) return undefined;
|
||||
const account = isRecord(payload.account) ? payload.account : null;
|
||||
const organization = isRecord(payload.organization) ? payload.organization : null;
|
||||
if (booleanValue(account?.has_claude_max) === true) return 'Max';
|
||||
if (booleanValue(account?.has_claude_pro) === true) return 'Pro';
|
||||
if (
|
||||
readString(organization, 'organization_type').toLowerCase() === 'claude_team'
|
||||
&& readString(organization, 'subscription_status').toLowerCase() === 'active'
|
||||
) return 'Team';
|
||||
if (
|
||||
booleanValue(account?.has_claude_max) === false
|
||||
&& booleanValue(account?.has_claude_pro) === false
|
||||
) return 'Free';
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const loadClaudePlan = async (file: AuthFile): Promise<string | undefined> => {
|
||||
const authIndex = normalizeAuthIndex(file.auth_index ?? file.authIndex);
|
||||
if (!authIndex) return undefined;
|
||||
try {
|
||||
return resolveClaudePlan(
|
||||
await requestQuotaPayload(authIndex, CLAUDE_PROFILE_URL, headersByProvider.claude),
|
||||
);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const loadAntigravityPlan = async (file: AuthFile): Promise<string | undefined> => {
|
||||
const authIndex = normalizeAuthIndex(file.auth_index ?? file.authIndex);
|
||||
if (!authIndex) return undefined;
|
||||
try {
|
||||
const payload = await requestQuotaPayload(
|
||||
authIndex,
|
||||
ANTIGRAVITY_CODE_ASSIST_URL,
|
||||
headersByProvider.antigravity,
|
||||
'POST',
|
||||
JSON.stringify({ metadata: { ideType: 'ANTIGRAVITY' } }),
|
||||
);
|
||||
if (!isRecord(payload)) return undefined;
|
||||
const currentTier = isRecord(payload.currentTier)
|
||||
? payload.currentTier
|
||||
: isRecord(payload.current_tier)
|
||||
? payload.current_tier
|
||||
: null;
|
||||
const paidTier = isRecord(payload.paidTier)
|
||||
? payload.paidTier
|
||||
: isRecord(payload.paid_tier)
|
||||
? payload.paid_tier
|
||||
: null;
|
||||
const effectiveTier = readString(paidTier, 'id') ? paidTier : currentTier;
|
||||
const tierId = readString(effectiveTier, 'id').toLowerCase();
|
||||
const tierName = readString(effectiveTier, 'name');
|
||||
const knownPlans: Record<string, string> = {
|
||||
'free-tier': 'Free',
|
||||
'g1-pro-tier': 'Pro',
|
||||
'g1-ultra-tier': 'Ultra',
|
||||
'g1-ultra-lite-tier': 'Ultra Lite',
|
||||
};
|
||||
return knownPlans[tierId] || tierName || tierId || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
async function callUpstreamQuota(
|
||||
file: AuthFile,
|
||||
provider: QuotaProvider,
|
||||
resolvedCodexAccountId?: string,
|
||||
): Promise<unknown> {
|
||||
const authIndex = normalizeAuthIndex(file.auth_index ?? file.authIndex);
|
||||
if (!authIndex) throw new Error('缺少 auth-index,无法查询配额');
|
||||
const header = { ...headersByProvider[provider] };
|
||||
if (provider === 'codex') {
|
||||
const accountId = resolvedCodexAccountId ?? await resolveCodexAccountId(file);
|
||||
if (accountId) header['Chatgpt-Account-Id'] = accountId;
|
||||
}
|
||||
const project = provider === 'antigravity' ? await resolveProjectId(file) : '';
|
||||
if (provider === 'antigravity' && !project) throw new Error('缺少 Antigravity project ID');
|
||||
const urls = provider === 'antigravity'
|
||||
? [endpointByProvider.antigravity, 'https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:retrieveUserQuotaSummary', 'https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary']
|
||||
: [endpointByProvider[provider]];
|
||||
let lastError = '';
|
||||
let hadSuccessfulResponse = false;
|
||||
for (const url of urls) {
|
||||
try {
|
||||
const payload = await requestQuotaPayload(
|
||||
authIndex,
|
||||
url,
|
||||
header,
|
||||
provider === 'antigravity' ? 'POST' : 'GET',
|
||||
project ? JSON.stringify({ project }) : undefined,
|
||||
);
|
||||
if (provider === 'antigravity') {
|
||||
hadSuccessfulResponse = true;
|
||||
if (quotaRowsFor('antigravity', payload).length === 0) {
|
||||
lastError = 'Antigravity 返回成功,但没有可识别的配额分组';
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return payload;
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
lastError || (hadSuccessfulResponse ? '上游返回了空配额数据' : '配额接口无响应'),
|
||||
);
|
||||
}
|
||||
|
||||
const callCodexResetCredits = async (
|
||||
file: AuthFile,
|
||||
accountId: string,
|
||||
): Promise<{ availableCount?: number; earliestExpiry?: string }> => {
|
||||
const authIndex = normalizeAuthIndex(file.auth_index ?? file.authIndex);
|
||||
if (!authIndex) throw new Error('缺少 auth-index,无法查询主动重置次数');
|
||||
const header: Record<string, string> = {
|
||||
...headersByProvider.codex,
|
||||
Accept: 'application/json',
|
||||
'OpenAI-Beta': 'codex-1',
|
||||
Originator: 'Codex Desktop',
|
||||
};
|
||||
if (accountId) header['Chatgpt-Account-Id'] = accountId;
|
||||
const payload = await requestQuotaPayload(authIndex, CODEX_RESET_CREDITS_URL, header);
|
||||
return codexResetCreditDetailsFor(payload);
|
||||
};
|
||||
|
||||
export async function loadQuota(file: AuthFile): Promise<QuotaState> {
|
||||
const provider = providerForFile(file);
|
||||
if (!provider) return { status: 'error', rows: [], error: '暂不支持该提供商的配额查询' };
|
||||
try {
|
||||
const codexAccountId = provider === 'codex' ? await resolveCodexAccountId(file) : '';
|
||||
const payloadPromise = provider === 'xai'
|
||||
? callXaiQuota(file)
|
||||
: callUpstreamQuota(file, provider, codexAccountId);
|
||||
const planPromise = provider === 'claude'
|
||||
? loadClaudePlan(file)
|
||||
: provider === 'antigravity'
|
||||
? loadAntigravityPlan(file)
|
||||
: Promise.resolve(undefined);
|
||||
const resetCreditsPromise = provider === 'codex'
|
||||
? callCodexResetCredits(file, codexAccountId).catch(() => null)
|
||||
: Promise.resolve(null);
|
||||
const [payload, detectedPlan, resetCreditDetails] = await Promise.all([
|
||||
payloadPromise,
|
||||
planPromise,
|
||||
resetCreditsPromise,
|
||||
]);
|
||||
const rows = quotaRowsFor(provider, payload);
|
||||
if (rows.length === 0) return { status: 'error', rows: [], error: '上游返回了配额数据,但无法识别' };
|
||||
return {
|
||||
status: 'success',
|
||||
rows,
|
||||
plan: detectedPlan
|
||||
?? readString(isRecord(payload) ? payload : {}, 'plan_type', 'planType'),
|
||||
resetCredits: provider === 'codex'
|
||||
? resetCreditDetails?.availableCount ?? codexResetCreditsFor(payload)
|
||||
: undefined,
|
||||
resetCreditsEarliestExpiry: resetCreditDetails?.earliestExpiry,
|
||||
fetchedAt: Date.now(),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'error',
|
||||
rows: [],
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const createRedeemRequestId = () => {
|
||||
if (typeof globalThis.crypto?.randomUUID === 'function') return globalThis.crypto.randomUUID();
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (character) => {
|
||||
const random = Math.floor(Math.random() * 16);
|
||||
const value = character === 'x' ? random : (random & 0x3) | 0x8;
|
||||
return value.toString(16);
|
||||
});
|
||||
};
|
||||
|
||||
export async function consumeCodexResetCredit(file: AuthFile): Promise<QuotaState> {
|
||||
if (providerForFile(file) !== 'codex') {
|
||||
throw new Error('只有 Codex 凭据支持使用重置额度');
|
||||
}
|
||||
const authIndex = normalizeAuthIndex(file.auth_index ?? file.authIndex);
|
||||
if (!authIndex) throw new Error('缺少 auth-index,无法使用重置额度');
|
||||
const header = {
|
||||
...headersByProvider.codex,
|
||||
};
|
||||
const accountId = await resolveCodexAccountId(file);
|
||||
if (accountId) header['Chatgpt-Account-Id'] = accountId;
|
||||
await requestQuotaPayload(
|
||||
authIndex,
|
||||
CODEX_RESET_CREDITS_CONSUME_URL,
|
||||
header,
|
||||
'POST',
|
||||
JSON.stringify({ redeem_request_id: createRedeemRequestId() }),
|
||||
);
|
||||
return loadQuota(file);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { dedupeAuthFiles } from '../src/services/authFiles';
|
||||
|
||||
describe('认证文件列表规范化', () => {
|
||||
it('合并同名的磁盘和运行时记录并优先保留磁盘状态', () => {
|
||||
const files = dedupeAuthFiles([
|
||||
{
|
||||
name: 'codex-user.json',
|
||||
provider: 'codex',
|
||||
runtime_only: true,
|
||||
auth_index: 'runtime-index',
|
||||
email: 'user@example.com',
|
||||
},
|
||||
{
|
||||
name: 'codex-user.json',
|
||||
provider: 'codex',
|
||||
source: 'file',
|
||||
path: '/tmp/codex-user.json',
|
||||
disabled: false,
|
||||
modtime: 100,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(files).toHaveLength(1);
|
||||
expect(files[0].source).toBe('file');
|
||||
expect(files[0].path).toBe('/tmp/codex-user.json');
|
||||
expect(files[0].email).toBe('user@example.com');
|
||||
expect(files[0].auth_index).toBe('runtime-index');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { clientApiProfiles } from '../src/services/clientAccess';
|
||||
|
||||
describe('客户端 API 接入信息', () => {
|
||||
it('生成 OpenAI、Claude 和 Gemini 三种正确格式', () => {
|
||||
const profiles = clientApiProfiles(9527, '192.168.1.8');
|
||||
|
||||
expect(profiles.map((profile) => profile.id)).toEqual(['openai', 'claude', 'gemini']);
|
||||
expect(profiles[0].baseUrl).toBe('http://127.0.0.1:9527/v1');
|
||||
expect(profiles[0].lanUrl).toBe('http://192.168.1.8:9527/v1');
|
||||
expect(profiles[1].baseUrl).toBe('http://127.0.0.1:9527');
|
||||
expect(profiles[1].lanUrl).toBe('http://192.168.1.8:9527');
|
||||
expect(profiles[2].baseUrl).toBe('http://127.0.0.1:9527');
|
||||
expect(profiles[2].lanUrl).toBe('http://192.168.1.8:9527');
|
||||
});
|
||||
|
||||
it('端口无效时回退到 8317,缺少局域网地址时保持为空', () => {
|
||||
const [openai] = clientApiProfiles(0);
|
||||
|
||||
expect(openai.baseUrl).toBe('http://127.0.0.1:8317/v1');
|
||||
expect(openai.lanUrl).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { apiCallErrorMessage } from '../src/services/managementApi';
|
||||
|
||||
describe('apiCallErrorMessage', () => {
|
||||
it('从对象和 JSON 字符串错误体中提取可读消息', () => {
|
||||
expect(apiCallErrorMessage({
|
||||
status_code: 401,
|
||||
body: { error: { message: 'token expired' } },
|
||||
})).toBe('token expired');
|
||||
|
||||
expect(apiCallErrorMessage({
|
||||
statusCode: 403,
|
||||
bodyText: JSON.stringify({ detail: 'permission denied' }),
|
||||
})).toBe('permission denied');
|
||||
});
|
||||
|
||||
it('没有错误体时回退到 HTTP 状态', () => {
|
||||
expect(apiCallErrorMessage({ status_code: 429 })).toBe('上游返回 HTTP 429');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
exclusionsForOpenOAuthModels,
|
||||
oauthExcludedRulesFromPayload,
|
||||
oauthModelsFromPayload,
|
||||
openOAuthModelNames,
|
||||
} from '../src/services/oauthModels';
|
||||
|
||||
const models = [
|
||||
{ id: 'gpt-5.4', displayName: 'GPT 5.4' },
|
||||
{ id: 'gpt-image-1.5', displayName: 'GPT Image 1.5' },
|
||||
{ id: 'gpt-image-2', displayName: 'GPT Image 2' },
|
||||
];
|
||||
|
||||
describe('OAuth 开放模型', () => {
|
||||
it('解析模型定义和提供商排除规则', () => {
|
||||
expect(oauthModelsFromPayload({ models: [
|
||||
{ id: 'gpt-5.4', display_name: 'GPT 5.4' },
|
||||
{ id: 'GPT-5.4' },
|
||||
] })).toEqual([{ id: 'gpt-5.4', displayName: 'GPT 5.4' }]);
|
||||
expect(oauthExcludedRulesFromPayload({
|
||||
'oauth-excluded-models': { codex: [' GPT-IMAGE-* ', 'gpt-image-*'] },
|
||||
}, 'codex')).toEqual(['gpt-image-*']);
|
||||
});
|
||||
|
||||
it('排除规则会让图像模型默认不勾选', () => {
|
||||
expect(Array.from(openOAuthModelNames(models, ['gpt-image-*']))).toEqual(['gpt-5.4']);
|
||||
});
|
||||
|
||||
it('OAuth 没有已保存排除规则时默认全选全部模型', () => {
|
||||
expect(Array.from(openOAuthModelNames(models, []))).toEqual([
|
||||
'gpt-5.4',
|
||||
'gpt-image-1.5',
|
||||
'gpt-image-2',
|
||||
]);
|
||||
});
|
||||
|
||||
it('保存时把未勾选模型转换成 OAuth 排除规则', () => {
|
||||
expect(exclusionsForOpenOAuthModels([], models, ['gpt-5.4'])).toEqual([
|
||||
'gpt-image-1.5',
|
||||
'gpt-image-2',
|
||||
]);
|
||||
});
|
||||
|
||||
it('显式开放模型时移除与其冲突的旧通配符', () => {
|
||||
expect(exclusionsForOpenOAuthModels(
|
||||
['gpt-image-*', 'future-*'],
|
||||
models,
|
||||
['gpt-5.4', 'gpt-image-2'],
|
||||
)).toEqual(['future-*', 'gpt-image-1.5']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
applyProviderPreset,
|
||||
buildProviderRecord,
|
||||
createProviderDraft,
|
||||
DEEPSEEK_BASE_URL,
|
||||
DEEPSEEK_THINKING_LEVELS,
|
||||
exclusionsForModelSelection,
|
||||
modelSelectionForDiscovery,
|
||||
parseProviderHeaders,
|
||||
providerCategoryMatchesRecord,
|
||||
providerSectionOrder,
|
||||
sectionRecordsFromConfig,
|
||||
stripResponseFields,
|
||||
} from '../src/pages/ApiAccessPage';
|
||||
|
||||
describe('API 接入配置合并', () => {
|
||||
it('固定使用 Codex、OpenAI、DeepSeek、Claude、Gemini 顺序且不包含 Vertex', () => {
|
||||
expect(providerSectionOrder).toEqual([
|
||||
'codex-api-key',
|
||||
'openai-compatibility',
|
||||
'deepseek',
|
||||
'claude-api-key',
|
||||
'gemini-api-key',
|
||||
]);
|
||||
});
|
||||
|
||||
it('DeepSeek 新增预设使用 OpenAI 兼容格式和内置思考等级', () => {
|
||||
const draft = createProviderDraft('deepseek');
|
||||
const result = buildProviderRecord('openai-compatibility', {
|
||||
...draft,
|
||||
apiKey: 'deepseek-key',
|
||||
});
|
||||
|
||||
expect(draft.name).toBe('DeepSeek');
|
||||
expect(draft.baseUrl).toBe(DEEPSEEK_BASE_URL);
|
||||
expect(result).toMatchObject({
|
||||
name: 'DeepSeek',
|
||||
'base-url': 'https://api.deepseek.com',
|
||||
'api-key-entries': [{ 'api-key': 'deepseek-key' }],
|
||||
models: [
|
||||
{
|
||||
name: 'deepseek-chat',
|
||||
thinking: { levels: [...DEEPSEEK_THINKING_LEVELS] },
|
||||
},
|
||||
{
|
||||
name: 'deepseek-reasoner',
|
||||
thinking: { levels: [...DEEPSEEK_THINKING_LEVELS] },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('DeepSeek 接入单独归类,不在 OpenAI 兼容列表重复显示', () => {
|
||||
const record = {
|
||||
name: 'custom-deepseek',
|
||||
'base-url': 'https://api.deepseek.com/v1',
|
||||
};
|
||||
|
||||
expect(providerCategoryMatchesRecord('deepseek', record)).toBe(true);
|
||||
expect(providerCategoryMatchesRecord('openai-compatibility', record)).toBe(false);
|
||||
});
|
||||
|
||||
it('OpenAI 兼容接入把选定思考等级写入全部开放模型', () => {
|
||||
const draft = applyProviderPreset('openai-compatibility', {
|
||||
...createProviderDraft('openai-compatibility'),
|
||||
apiKey: 'openai-key',
|
||||
name: 'custom-openai',
|
||||
baseUrl: 'https://api.example.com',
|
||||
thinkingLevels: ['low', 'high'],
|
||||
models: [
|
||||
{ name: 'reasoning-a' },
|
||||
{ name: 'reasoning-b', thinking: { effort: 'high' } },
|
||||
],
|
||||
});
|
||||
const result = buildProviderRecord('openai-compatibility', draft);
|
||||
|
||||
expect(result.models).toEqual([
|
||||
{ name: 'reasoning-a', thinking: { levels: ['low', 'high'] } },
|
||||
{
|
||||
name: 'reasoning-b',
|
||||
thinking: { effort: 'high', levels: ['low', 'high'] },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('只删除响应字段,不破坏隐藏的代理和扩展配置', () => {
|
||||
const result = stripResponseFields({
|
||||
'api-key': 'key',
|
||||
'auth-index': 'runtime-id',
|
||||
'proxy-url': 'http://proxy.example',
|
||||
custom: { enabled: true },
|
||||
'api-key-entries': [
|
||||
{ 'api-key': 'first', 'auth-index': 'entry-id', 'proxy-url': 'direct' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result['auth-index']).toBeUndefined();
|
||||
expect(result['proxy-url']).toBe('http://proxy.example');
|
||||
expect(result.custom).toEqual({ enabled: true });
|
||||
expect(result['api-key-entries']).toEqual([
|
||||
{ 'api-key': 'first', 'proxy-url': 'direct' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('编辑 OpenAI 接入时保留后续密钥和模型扩展字段', () => {
|
||||
const result = buildProviderRecord(
|
||||
'openai-compatibility',
|
||||
{
|
||||
name: 'openrouter',
|
||||
apiKey: 'new-first\nsecond-key',
|
||||
baseUrl: 'https://openrouter.ai/api',
|
||||
priority: '20',
|
||||
models: [{ name: 'gpt-test', alias: 'gpt-alias' }],
|
||||
},
|
||||
{
|
||||
name: 'openrouter',
|
||||
'base-url': 'https://old.example',
|
||||
'api-key-entries': [
|
||||
{ 'api-key': 'old-first', 'proxy-url': 'direct', 'auth-index': 'runtime-1' },
|
||||
{ 'api-key': 'second-key', custom: true },
|
||||
],
|
||||
models: [{ name: 'gpt-test', alias: 'old-alias', image: true }],
|
||||
headers: { 'X-Test': '1' },
|
||||
},
|
||||
);
|
||||
|
||||
expect(result['api-key-entries']).toEqual([
|
||||
{ 'api-key': 'new-first', 'proxy-url': 'direct' },
|
||||
{ 'api-key': 'second-key', custom: true },
|
||||
]);
|
||||
expect(result.models).toEqual([{ name: 'gpt-test', alias: 'gpt-alias', image: true }]);
|
||||
expect(result.headers).toEqual({ 'X-Test': '1' });
|
||||
});
|
||||
|
||||
it('从最新完整配置读取对应提供商列表', () => {
|
||||
expect(sectionRecordsFromConfig({
|
||||
'codex-api-key': [{ 'api-key': 'one' }, null, 'invalid'],
|
||||
}, 'codex-api-key')).toEqual([{ 'api-key': 'one' }]);
|
||||
});
|
||||
|
||||
it('空优先级不会被错误写成 0', () => {
|
||||
const result = buildProviderRecord(
|
||||
'codex-api-key',
|
||||
{
|
||||
name: '',
|
||||
apiKey: 'codex-key',
|
||||
baseUrl: 'https://api.example.com',
|
||||
priority: '',
|
||||
models: [],
|
||||
},
|
||||
{
|
||||
'api-key': 'old-key',
|
||||
priority: 30,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.priority).toBeUndefined();
|
||||
});
|
||||
|
||||
it('高级设置可编辑且不会引入代理字段', () => {
|
||||
const result = buildProviderRecord('codex-api-key', {
|
||||
name: '',
|
||||
apiKey: 'codex-key',
|
||||
baseUrl: 'https://api.example.com',
|
||||
priority: '',
|
||||
models: [],
|
||||
prefix: 'team-a',
|
||||
headersText: 'X-Team: production\nX-Trace: enabled',
|
||||
excludedModelsText: 'old-*\npreview-model',
|
||||
disableCooling: true,
|
||||
websockets: true,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
prefix: 'team-a',
|
||||
headers: { 'X-Team': 'production', 'X-Trace': 'enabled' },
|
||||
'excluded-models': ['old-*', 'preview-model'],
|
||||
'disable-cooling': true,
|
||||
websockets: true,
|
||||
});
|
||||
expect(result['proxy-url']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('编辑已停用的普通提供商时保留停用规则', () => {
|
||||
const result = buildProviderRecord('claude-api-key', {
|
||||
name: '',
|
||||
apiKey: 'claude-key',
|
||||
baseUrl: '',
|
||||
priority: '',
|
||||
models: [],
|
||||
excludedModelsText: 'claude-old-*',
|
||||
disabled: true,
|
||||
});
|
||||
|
||||
expect(result['excluded-models']).toEqual(['claude-old-*', '*']);
|
||||
});
|
||||
|
||||
it('校验自定义请求头格式', () => {
|
||||
expect(parseProviderHeaders('Authorization: Bearer abc:def')).toEqual({
|
||||
Authorization: 'Bearer abc:def',
|
||||
});
|
||||
expect(() => parseProviderHeaders('Invalid header')).toThrow('缺少冒号');
|
||||
});
|
||||
|
||||
it('把未勾选的上游模型写入排除列表', () => {
|
||||
const result = exclusionsForModelSelection(
|
||||
'legacy-*\ngpt-image',
|
||||
[{ name: 'gpt-5-codex' }, { name: 'gpt-image' }, { name: 'gpt-5-mini' }],
|
||||
new Set(['gpt-5-codex']),
|
||||
);
|
||||
|
||||
expect(result.split('\n')).toEqual(['legacy-*', 'gpt-image', 'gpt-5-mini']);
|
||||
});
|
||||
|
||||
it('重新勾选模型时移除对应的精确排除规则', () => {
|
||||
const result = exclusionsForModelSelection(
|
||||
'legacy-*\ngpt-image\nmanual-model',
|
||||
[{ name: 'gpt-5-codex' }, { name: 'gpt-image' }],
|
||||
new Set(['gpt-image']),
|
||||
);
|
||||
|
||||
expect(result.split('\n')).toEqual(['legacy-*', 'manual-model', 'gpt-5-codex']);
|
||||
});
|
||||
|
||||
it('普通提供商没有模型映射时按真实开放状态初始化勾选', () => {
|
||||
const selected = modelSelectionForDiscovery(
|
||||
'codex-api-key',
|
||||
[],
|
||||
[{ name: 'gpt-5.4' }, { name: 'gpt-image-1.5' }, { name: 'gpt-image-2' }],
|
||||
'gpt-image-*',
|
||||
);
|
||||
|
||||
expect(Array.from(selected)).toEqual(['gpt-5.4']);
|
||||
});
|
||||
|
||||
it('普通提供商未限制模型时默认显示全部已开放', () => {
|
||||
const selected = modelSelectionForDiscovery(
|
||||
'codex-api-key',
|
||||
[],
|
||||
[{ name: 'gpt-5.4' }, { name: 'gpt-image-2' }],
|
||||
'',
|
||||
);
|
||||
|
||||
expect(Array.from(selected)).toEqual(['gpt-5.4', 'gpt-image-2']);
|
||||
});
|
||||
|
||||
it('OpenAI 兼容接入没有已保存模型时默认全选发现的模型', () => {
|
||||
const selected = modelSelectionForDiscovery(
|
||||
'openai-compatibility',
|
||||
[],
|
||||
[{ name: 'model-a' }, { name: 'model-b' }],
|
||||
'',
|
||||
);
|
||||
|
||||
expect(Array.from(selected)).toEqual(['model-a', 'model-b']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
captureQuotaCacheGeneration,
|
||||
commitQuotaCacheIfCurrent,
|
||||
getQuotaCacheSnapshot,
|
||||
pruneQuotaCache,
|
||||
updateQuotaCache,
|
||||
} from '../src/services/quotaCache';
|
||||
|
||||
describe('额度跨页面缓存', () => {
|
||||
it('保留仍存在的认证文件额度并清理失效项', () => {
|
||||
updateQuotaCache({
|
||||
first: { status: 'success', rows: [], fetchedAt: 1 },
|
||||
removed: { status: 'error', rows: [], error: 'old' },
|
||||
});
|
||||
pruneQuotaCache(new Set(['first']));
|
||||
|
||||
expect(getQuotaCacheSnapshot()).toEqual({
|
||||
first: { status: 'success', rows: [], fetchedAt: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it('认证文件集合变化后拒绝过期请求写回', () => {
|
||||
updateQuotaCache({
|
||||
stale: { status: 'loading', rows: [] },
|
||||
retained: { status: 'loading', rows: [] },
|
||||
});
|
||||
const generation = captureQuotaCacheGeneration();
|
||||
pruneQuotaCache(new Set(['retained']));
|
||||
let committed = false;
|
||||
|
||||
expect(commitQuotaCacheIfCurrent(generation, () => {
|
||||
committed = true;
|
||||
})).toBe(false);
|
||||
expect(committed).toBe(false);
|
||||
expect(getQuotaCacheSnapshot().retained).toEqual({ status: 'idle', rows: [] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
codexResetCreditDetailsFor,
|
||||
codexResetCreditsFor,
|
||||
quotaRowsFor,
|
||||
} from '../src/services/quotaService';
|
||||
|
||||
describe('quotaRowsFor', () => {
|
||||
it('把 Codex 的已用百分比转换为剩余额度', () => {
|
||||
const rows = quotaRowsFor('codex', {
|
||||
rate_limit: {
|
||||
allowed: true,
|
||||
primary_window: {
|
||||
used_percent: 97,
|
||||
limit_window_seconds: 604800,
|
||||
reset_at: 1784698723,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].label).toBe('周限额');
|
||||
expect(rows[0].remainingPercent).toBe(3);
|
||||
expect(rows[0].reset).toBeTruthy();
|
||||
});
|
||||
|
||||
it('区分 Codex 的 5 小时、周和月限额', () => {
|
||||
const rows = quotaRowsFor('codex', {
|
||||
rate_limit: {
|
||||
primary_window: {
|
||||
used_percent: 10,
|
||||
limit_window_seconds: 18_000,
|
||||
},
|
||||
secondary_window: {
|
||||
used_percent: 20,
|
||||
limit_window_seconds: 2_592_000,
|
||||
},
|
||||
},
|
||||
additional_rate_limits: [
|
||||
{
|
||||
limit_name: '代码审查增强',
|
||||
rate_limit: {
|
||||
primary_window: {
|
||||
used_percent: 30,
|
||||
limit_window_seconds: 604_800,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(rows.map((row) => row.label)).toEqual([
|
||||
'5 小时限额',
|
||||
'月限额',
|
||||
'代码审查增强 周限额',
|
||||
]);
|
||||
});
|
||||
|
||||
it('Team 次级窗口缺少时长时按月限额处理', () => {
|
||||
const rows = quotaRowsFor('codex', {
|
||||
plan_type: 'team',
|
||||
rate_limit: {
|
||||
primary_window: { used_percent: 10 },
|
||||
secondary_window: { used_percent: 20 },
|
||||
},
|
||||
});
|
||||
|
||||
expect(rows.map((row) => row.label)).toEqual(['5 小时限额', '月限额']);
|
||||
});
|
||||
|
||||
it('读取 Codex 可用重置额度', () => {
|
||||
expect(codexResetCreditsFor({
|
||||
rate_limit_reset_credits: { available_count: '2' },
|
||||
})).toBe(2);
|
||||
});
|
||||
|
||||
it('读取 Codex 重置次数和最早有效过期时间', () => {
|
||||
const result = codexResetCreditDetailsFor({
|
||||
available_count: '2',
|
||||
credits: [
|
||||
{
|
||||
id: 'later',
|
||||
reset_type: 'codex_rate_limits',
|
||||
status: 'available',
|
||||
expires_at: '2026-08-20T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'earlier',
|
||||
reset_type: 'codex_rate_limits',
|
||||
status: 'available',
|
||||
expires_at: '2026-08-12T18:06:25Z',
|
||||
},
|
||||
{
|
||||
id: 'used',
|
||||
reset_type: 'codex_rate_limits',
|
||||
status: 'used',
|
||||
expires_at: '2026-07-20T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'expired',
|
||||
reset_type: 'codex_rate_limits',
|
||||
status: 'available',
|
||||
expires_at: '2026-07-15T00:00:00Z',
|
||||
},
|
||||
],
|
||||
}, Date.parse('2026-07-16T00:00:00Z'));
|
||||
|
||||
expect(result).toEqual({
|
||||
availableCount: 2,
|
||||
earliestExpiry: '2026-08-12T18:06:25Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('不会把小于 1 的上游百分比错误放大 100 倍', () => {
|
||||
const codex = quotaRowsFor('codex', {
|
||||
rate_limit: { primary_window: { used_percent: 0.63 } },
|
||||
});
|
||||
const claude = quotaRowsFor('claude', {
|
||||
five_hour: { utilization: 0.44 },
|
||||
});
|
||||
|
||||
expect(codex[0].remainingPercent).toBeCloseTo(99.37);
|
||||
expect(claude[0].remainingPercent).toBeCloseTo(99.56);
|
||||
});
|
||||
|
||||
it('显示 Claude 已启用的额外用量', () => {
|
||||
const rows = quotaRowsFor('claude', {
|
||||
five_hour: { utilization: 20, resets_at: '2027-01-01T00:00:00Z' },
|
||||
extra_usage: {
|
||||
is_enabled: true,
|
||||
monthly_limit: 5000,
|
||||
used_credits: 1250,
|
||||
utilization: 25,
|
||||
},
|
||||
});
|
||||
|
||||
expect(rows.at(-1)).toMatchObject({
|
||||
label: '额外用量',
|
||||
remainingPercent: 75,
|
||||
});
|
||||
expect(rows.at(-1)?.detail).toContain('$12.50');
|
||||
expect(rows.at(-1)?.detail).toContain('$50.00');
|
||||
});
|
||||
|
||||
it('按剩余量计算 Kimi 和 Antigravity 百分比', () => {
|
||||
const kimi = quotaRowsFor('kimi', {
|
||||
usage: { used: 99.5, limit: 100 },
|
||||
});
|
||||
const antigravity = quotaRowsFor('antigravity', {
|
||||
groups: [
|
||||
{
|
||||
displayName: 'Gemini',
|
||||
buckets: [{ remainingFraction: 0.03 }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(kimi[0].remainingPercent).toBeCloseTo(0.5);
|
||||
expect(antigravity[0].remainingPercent).toBe(3);
|
||||
});
|
||||
|
||||
it('区分 Antigravity 同一分组中的不同窗口', () => {
|
||||
const rows = quotaRowsFor('antigravity', {
|
||||
groups: [{
|
||||
displayName: 'Gemini Pro',
|
||||
buckets: [
|
||||
{ window: '5h', remainingFraction: 0.8 },
|
||||
{ window: 'weekly', remainingFraction: 0.4 },
|
||||
],
|
||||
}],
|
||||
});
|
||||
|
||||
expect(rows.map((row) => row.label)).toEqual([
|
||||
'Gemini Pro · 5h',
|
||||
'Gemini Pro · weekly',
|
||||
]);
|
||||
});
|
||||
|
||||
it('合并 xAI 每周、月度和按量付费额度', () => {
|
||||
const rows = quotaRowsFor('xai', {
|
||||
weekly: {
|
||||
config: {
|
||||
currentPeriod: { type: 'weekly', end: '2027-01-01T00:00:00Z' },
|
||||
creditUsagePercent: 25,
|
||||
productUsage: [{ product: 'grok-code', usagePercent: 40 }],
|
||||
},
|
||||
},
|
||||
monthly: {
|
||||
config: {
|
||||
monthlyLimit: { val: 1000 },
|
||||
used: { val: 1200 },
|
||||
onDemandCap: { val: 500 },
|
||||
billingPeriodEnd: '2027-01-31T00:00:00Z',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(rows.find((row) => row.label === '每周额度')?.remainingPercent).toBe(75);
|
||||
expect(rows.find((row) => row.label === 'grok-code')?.remainingPercent).toBe(60);
|
||||
expect(rows.find((row) => row.label === '月度包含额度')?.remainingPercent).toBe(0);
|
||||
expect(rows.find((row) => row.label === '按量付费额度')?.remainingPercent).toBe(60);
|
||||
});
|
||||
});
|
||||