feat(providers): add API, OAuth, quota, and DeepSeek support

This commit is contained in:
lzt404 committed 2026-07-16 18:33:28 +08:00
1 parent 3bb5ff7dd4
commit 24b4d1f74e
47 files changed
+11156 -192

No files matched your search

+3
View File
@@ -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=="],
+1
View File
@@ -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"
},
+106 -3
View File
@@ -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"
+16
View File
@@ -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",
] }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 295 KiB

+2778 -53
View File
File diff suppressed because it is too large. Load diff
+90 -13
View File
@@ -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;
+32
View File
@@ -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>
);
}
}
+28
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+1
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -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

+1
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+1
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

+61
View File
@@ -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;
}
+4 -1
View File
@@ -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>
);
File diff suppressed because it is too large. Load diff
+567
View File
@@ -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>
);
}
+548 -30
View File
@@ -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;
}
+353 -67
View File
@@ -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>
);
}
+482
View File
@@ -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();
}
+203
View File
@@ -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>
);
}
+70
View File
@@ -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' }),
);
};
+39
View File
@@ -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,
},
];
}
+190
View File
@@ -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;
}
+192
View File
@@ -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 || '模型接口无响应');
}
+81
View File
@@ -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();
};
+52
View File
@@ -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);
}
+882
View File
@@ -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);
}
+2455 -25
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+30
View File
@@ -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');
});
});
+23
View File
@@ -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();
});
});
+20
View File
@@ -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');
});
});
+52
View File
@@ -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']);
});
});
+258
View File
@@ -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']);
});
});
+38
View File
@@ -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: [] });
});
});
+203
View File
@@ -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);
});
});