Merge pull request #78 from router-for-me/dev

Enhance settings, OAuth, and agent configurations with new features
This commit is contained in:
lzt404 authored and GitHub committed 2026-08-02 17:52:38 +08:00
commit 6f44fb76a7
47 files changed
+10168 -1236

No files matched your search

+32 -15
View File
@@ -190,8 +190,8 @@ jobs:
throw new Error(`cpa-core must contain exactly one archive; found: ${entries.map((entry) => entry.name).join(', ')}`);
}
NODE
- name: Prepare Windows portable updater payload
if: matrix.os == 'windows'
- name: Prepare v0.2.10 legacy updater bridge
if: matrix.os == 'windows' && steps.package.outputs.version == '0.2.10'
shell: pwsh
run: |
$updateDir = "release/update-${{ matrix.arch }}"
@@ -201,11 +201,11 @@ jobs:
$marker = Get-Content -Raw -LiteralPath "$updateDir/portable-app.json" | ConvertFrom-Json
if ($marker.schemaVersion -ne 1 -or
$marker.application -ne "EasyCLIProxyAPI" -or
$marker.version -ne "${{ steps.package.outputs.version }}" -or
$marker.version -ne "0.2.10" -or
$marker.platform -ne "windows" -or
$marker.arch -ne "${{ matrix.arch }}" -or
$marker.autoUpdate -ne $true) {
throw "portable-app.json does not match the Windows updater target"
throw "v0.2.10 legacy updater bridge does not match the Windows target"
}
- name: Build signed macOS DMG
if: matrix.os == 'darwin'
@@ -341,22 +341,40 @@ jobs:
- name: Archive Windows release
if: matrix.os == 'windows'
shell: pwsh
env:
LEGACY_UPDATE_BRIDGE: ${{ steps.package.outputs.version == '0.2.10' }}
run: |
Compress-Archive -Path "release/${{ steps.package.outputs.stem }}" -DestinationPath "${{ steps.package.outputs.stem }}.zip"
$updateArchive = "EasyCLIProxyAPI-update-v${{ steps.package.outputs.version }}-Windows-${{ matrix.arch }}.zip"
Compress-Archive -Path "release/update-${{ matrix.arch }}/*" -DestinationPath $updateArchive
Add-Type -AssemblyName System.IO.Compression.FileSystem
$zip = [System.IO.Compression.ZipFile]::OpenRead((Resolve-Path $updateArchive))
$releaseArchive = "${{ steps.package.outputs.stem }}.zip"
$zip = [System.IO.Compression.ZipFile]::OpenRead((Resolve-Path $releaseArchive))
try {
$entries = @($zip.Entries | ForEach-Object { $_.FullName.Replace('\', '/') })
if ($entries.Count -ne 2 -or
$entries -notcontains "EasyCLIProxyAPI.exe" -or
$entries -notcontains "portable-app.json") {
throw "Portable updater ZIP contains unexpected entries: $($entries -join ', ')"
$root = "${{ steps.package.outputs.stem }}"
if ($entries -notcontains "$root/EasyCLIProxyAPI.exe" -or
$entries -notcontains "$root/portable-app.json" -or
$entries -notcontains "$root/core-version.txt" -or
-not ($entries | Where-Object { $_ -match "^$([regex]::Escape($root))/cpa-core/CLIProxyAPI_.+_windows_${{ matrix.arch }}\.zip$" })) {
throw "Full Windows release ZIP is incomplete: $($entries -join ', ')"
}
} finally {
$zip.Dispose()
}
if ($env:LEGACY_UPDATE_BRIDGE -eq "true") {
$updateArchive = "EasyCLIProxyAPI-update-v0.2.10-Windows-${{ matrix.arch }}.zip"
Compress-Archive -Path "release/update-${{ matrix.arch }}/*" -DestinationPath $updateArchive
$updateZip = [System.IO.Compression.ZipFile]::OpenRead((Resolve-Path $updateArchive))
try {
$updateEntries = @($updateZip.Entries | ForEach-Object { $_.FullName.Replace('\', '/') })
if ($updateEntries.Count -ne 2 -or
$updateEntries -notcontains "EasyCLIProxyAPI.exe" -or
$updateEntries -notcontains "portable-app.json") {
throw "v0.2.10 legacy updater bridge contains unexpected entries: $($updateEntries -join ', ')"
}
} finally {
$updateZip.Dispose()
}
}
- uses: actions/upload-artifact@v7
with:
name: ${{ steps.package.outputs.stem }}
@@ -364,13 +382,12 @@ jobs:
if-no-files-found: error
retention-days: 14
- uses: actions/upload-artifact@v7
if: matrix.os == 'windows'
if: matrix.os == 'windows' && steps.package.outputs.version == '0.2.10'
with:
name: portable-updater-windows-${{ matrix.arch }}
path: EasyCLIProxyAPI-update-v${{ steps.package.outputs.version }}-Windows-${{ matrix.arch }}.zip
name: legacy-updater-bridge-windows-${{ matrix.arch }}
path: EasyCLIProxyAPI-update-v0.2.10-Windows-${{ matrix.arch }}.zip
if-no-files-found: error
retention-days: 14
publish:
name: Publish GitHub release
if: >-
+1
View File
@@ -7,3 +7,4 @@ cpa-core/
*.log
*.tmp
misc/
+7 -1
View File
@@ -90,7 +90,7 @@ OpenAI、Claude、Gemini、およびその他の互換形式の間で変換で
## その他の機能
- コア設定、API Key、リモート管理用認証情報、プラグイン、ルーティング戦略の管理。
- コア設定、API Key、リモート管理用認証情報、ルーティング戦略の管理。
- クライアント向けモデルエイリアスを作成し、プロバイダーモデルや推論レベルへマッピング。
- 認証ファイルのアップロード、ダウンロード、確認、管理。
- プロバイダーのクォータとアカウント利用状態を確認。
@@ -106,6 +106,12 @@ OpenAI、Claude、Gemini、およびその他の互換形式の間で変換で
4. **コア** 画面を開き、同梱版または最新版の CLIProxyAPI コアをインストールします。
5. コアを起動し、必要なローカル API エンドポイントをコピーするか、OAuth/API プロバイダーを設定します。
## アップグレード
v0.2.10 は一度限りの移行用リリースです。完全版 Windows ZIP と旧クライアント互換の `update` ZIP を同時に公開し、v0.2.6~v0.2.9 からアプリ内で v0.2.10 へ更新できます。v0.2.11 以降は完全版 ZIP のみを公開し、v0.2.10 へ移行済みのクライアントは途中のバージョンを個別に導入せず直接アップグレードできます。
v0.2.5 以前を使用している場合は、一度だけ手動で移行してください。EasyCLIProxyAPI を終了し、使用中のアーキテクチャに対応する最新版の完全版 Windows ZIP をダウンロードして、その最上位ディレクトリの内容を既存のインストール先へコピーし、同名ファイルを上書きします。既存のディレクトリを先に削除しないでください。`config.toml`、`oauth`、`cpa-core/config.yaml` などのユーザーデータは保持されます。新しいバージョンを起動した後は、以降のリリースでアプリ内自動更新を利用できます。
## 対応プラットフォーム
GitHub Actions では、次のリリースパッケージをビルドします。
+7 -1
View File
@@ -91,7 +91,7 @@ previous configuration, and launch an available desktop or CLI entry point.
## Additional Capabilities
- Manage core settings, API keys, remote management credentials, plugins, and routing strategy.
- Manage core settings, API keys, remote management credentials, and routing strategy.
- Create client-visible model aliases and map them to provider models and reasoning levels.
- Upload, download, inspect, and manage authentication files.
- Review provider quotas and account availability.
@@ -107,6 +107,12 @@ previous configuration, and launch an available desktop or CLI entry point.
4. Open the **Core** page and install the bundled or latest CLIProxyAPI core.
5. Start the core, then copy the required local endpoint or configure an OAuth/API provider.
## Upgrading
v0.2.10 is a one-time bridge release. It publishes both the complete Windows ZIP and the legacy `update` ZIP so v0.2.6–v0.2.9 can upgrade to v0.2.10 in the app. Starting with v0.2.11, only the complete ZIP is published; clients already on v0.2.10 can consume it and jump directly across intermediate releases.
If you are running v0.2.5 or earlier, perform one manual migration: exit EasyCLIProxyAPI, download the latest complete Windows ZIP for your architecture, then copy the contents of its top-level directory over the existing installation directory. Do not delete the existing directory first; user data such as `config.toml`, `oauth`, and `cpa-core/config.yaml` will remain in place. After launching the new version, later releases can use in-app automatic updates.
## Supported Platforms
GitHub Actions builds the following release packages:
+7 -1
View File
@@ -86,7 +86,7 @@ API 接入页面按照协议或 Provider 管理上游 API 凭证和服务地址
## 其他功能
- 管理内核配置、API Key、远程管理凭证、插件和路由策略。
- 管理内核配置、API Key、远程管理凭证和路由策略。
- 创建客户端可见的模型别名,并映射到 Provider 模型和推理等级。
- 上传、下载、检查和管理认证文件。
- 查看 Provider 配额和账号可用状态。
@@ -102,6 +102,12 @@ API 接入页面按照协议或 Provider 管理上游 API 凭证和服务地址
4. 打开 **内核** 页面,安装内置版本或最新版本的 CLIProxyAPI 内核。
5. 启动内核,然后复制所需的本地 API 地址,或配置 OAuth/API Provider。
## 升级
v0.2.10 是一次性桥接版本:它会同时发布完整 Windows ZIP 和兼容旧客户端的 `update` ZIP,使 v0.2.6–v0.2.9 可以通过应用内更新升级到 v0.2.10。v0.2.11 起只发布完整 ZIP;已升级到 v0.2.10 的客户端可以识别完整包并跨过多个中间版本直接升级。
如果当前版本是 v0.2.5 或更早版本,请执行一次手动迁移:退出 EasyCLIProxyAPI,下载最新版对应架构的完整 Windows ZIP,将 ZIP 顶层目录内的内容复制到现有安装目录并覆盖同名文件。不要先删除现有安装目录;`config.toml`、`oauth` 和 `cpa-core/config.yaml` 等用户数据会被保留。启动新版后,后续版本即可继续使用应用内自动升级。
## 支持的平台
GitHub Actions 会构建以下发行包:
+7 -8
View File
@@ -58,7 +58,7 @@ if (-not (Test-Path -LiteralPath $AppBin -PathType Leaf)) {
throw "Build finished, but executable not found: $AppBin"
}
& bun $PreparePortable --binary $AppBin --output $BinDir --download true
& bun $PreparePortable --binary $AppBin --output $BinDir --download true --preserve-runtime-config true
if ($LASTEXITCODE -ne 0) {
throw "Portable preparation failed with exit code $LASTEXITCODE."
}
@@ -68,15 +68,14 @@ if (-not (Test-Path -LiteralPath $BinOut -PathType Leaf)) {
}
$CoreOut = Join-Path $BinDir 'cpa-core'
$CoreEntries = @(Get-ChildItem -LiteralPath $CoreOut -Force)
if (
$CoreEntries.Count -ne 1 -or
$CoreEntries[0].PSIsContainer -or
$CoreEntries[0].Name -notmatch '^CLIProxyAPI_.+_windows_.+\.zip$'
) {
$CoreArchives = @(
Get-ChildItem -LiteralPath $CoreOut -File |
Where-Object { $_.Name -match '^CLIProxyAPI_.+_windows_.+\.zip$' }
)
if ($CoreArchives.Count -ne 1) {
throw "Portable core output must contain exactly one Windows core archive: $CoreOut"
}
Write-Host "Built: $AppBin"
Write-Host "Copied: $BinOut"
Write-Host "Bundled core archive: $($CoreEntries[0].FullName)"
Write-Host "Bundled core archive: $($CoreArchives[0].FullName)"
+3
View File
@@ -6,6 +6,7 @@
"name": "cpa-gui",
"dependencies": {
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.7.1",
"lucide-react": "^1.24.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@@ -199,6 +200,8 @@
"@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.11.4", "", { "os": "win32", "cpu": "x64" }, "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw=="],
"@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.7.2", "", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-pX0IGm1I3I6wc+zeKYcq1GSqogK6okCNX5fOdaNU5ab1AjGS6l1E5wFNjEb7meg7ZFSp0JUs+0jQGQNyOvLrsg=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
+1 -1
View File
@@ -1 +1 @@
7.2.101
7.2.112
+1 -1
View File
@@ -12,7 +12,7 @@
: matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
document.documentElement.style.backgroundColor = theme === 'dark' ? '#101419' : '#faf9f5';
document.documentElement.style.backgroundColor = theme === 'dark' ? '#111412' : '#f6f7f5';
} catch {
// The app applies the theme again during initialization.
}
+2 -1
View File
@@ -1,7 +1,7 @@
{
"name": "cpa-gui",
"private": true,
"version": "0.2.9",
"version": "0.2.10",
"type": "module",
"packageManager": "bun@1.3.14",
"scripts": {
@@ -13,6 +13,7 @@
},
"dependencies": {
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.7.1",
"lucide-react": "^1.24.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
@@ -4,6 +4,8 @@ export type PortableUpdateManifestAsset = {
sizeBytes: number;
};
export const LEGACY_UPDATE_BRIDGE_VERSION: string;
export type PortableUpdateManifest = {
schemaVersion: number;
version: string;
@@ -3,6 +3,8 @@ import { readFile, stat, writeFile } from 'node:fs/promises';
import { basename, join, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
export const LEGACY_UPDATE_BRIDGE_VERSION = '0.2.10';
export async function generatePortableUpdateManifest({
directory,
output,
@@ -29,12 +31,15 @@ export async function generatePortableUpdateManifest({
}
const assets = {};
const useLegacyBridgeAssets = version === LEGACY_UPDATE_BRIDGE_VERSION;
for (const arch of ['amd64', 'aarch64']) {
const filename = `EasyCLIProxyAPI-update-${tag}-Windows-${arch}.zip`;
const filename = useLegacyBridgeAssets
? `EasyCLIProxyAPI-update-${tag}-Windows-${arch}.zip`
: `EasyCLIProxyAPI-${tag}-Windows-${arch}.zip`;
const path = join(resolvedDirectory, filename);
const [contents, metadata] = await Promise.all([readFile(path), stat(path)]);
if (!metadata.isFile() || metadata.size === 0) {
throw new Error(`Portable updater asset is empty or not a file: ${filename}`);
throw new Error(`Portable release asset is empty or not a file: ${filename}`);
}
assets[`windows-${arch}`] = {
url: `https://github.com/${resolvedRepository}/releases/download/${tag}/${filename}`,
+16 -7
View File
@@ -14,6 +14,7 @@ const binary = resolve(args.get('--binary') ?? join(root, 'src-tauri', 'target',
const targetOS = args.get('--os') ?? ({ linux: 'linux', darwin: 'darwin', win32: 'windows' })[process.platform];
const targetArch = args.get('--arch') ?? ({ x64: 'amd64', arm64: 'aarch64' })[process.arch];
const shouldDownload = args.get('--download') === 'true';
const preserveRuntimeConfig = args.get('--preserve-runtime-config') === 'true';
if (!targetOS || !targetArch) throw new Error(`Unsupported target: ${process.platform}/${process.arch}`);
if (!existsSync(binary)) throw new Error(`GUI binary not found: ${binary}`);
@@ -75,17 +76,25 @@ await writeFile(join(output, 'portable-app.json'), `${JSON.stringify({
}, null, 2)}\n`);
const coreOutput = join(output, 'cpa-core');
await rm(coreOutput, { recursive: true, force: true });
if (preserveRuntimeConfig) {
await mkdir(coreOutput, { recursive: true });
const entries = await readdir(coreOutput, { withFileTypes: true });
await Promise.all(entries
.filter((entry) => entry.name !== 'config.yaml')
.map((entry) => rm(join(coreOutput, entry.name), { recursive: true, force: true })));
} else {
await rm(coreOutput, { recursive: true, force: true });
}
await mkdir(coreOutput, { recursive: true });
await copyFile(sourceArchive, join(coreOutput, assetName));
const coreEntries = await readdir(coreOutput, { withFileTypes: true });
if (
coreEntries.length !== 1
|| !coreEntries[0].isFile()
|| coreEntries[0].name !== assetName
) {
const bundledArchive = coreEntries.find((entry) => entry.isFile() && entry.name === assetName);
if (!bundledArchive) {
throw new Error(`Core output must contain ${assetName}`);
}
if (!preserveRuntimeConfig && coreEntries.length !== 1) {
throw new Error(`Core output must contain only ${assetName}`);
}
console.log(`Prepared portable directory: ${output}`);
console.log(`Bundled core: ${basename(sourceArchive)} (archive only)`);
console.log(`Bundled core: ${basename(sourceArchive)}${preserveRuntimeConfig ? ' (preserved cpa-core/config.yaml)' : ' (archive only)'}`);
+145 -2
View File
@@ -582,12 +582,13 @@ checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636"
[[package]]
name = "cpa-gui"
version = "0.2.9"
version = "0.2.10"
dependencies = [
"base64 0.22.1",
"chrono",
"flate2",
"futures-util",
"getrandom 0.3.4",
"json5",
"libc",
"notify",
@@ -604,6 +605,7 @@ dependencies = [
"tar",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-opener",
"tokio",
"tokio-util",
@@ -2486,6 +2488,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.13.0",
"block2",
"libc",
"objc2",
"objc2-core-foundation",
]
@@ -3167,6 +3170,30 @@ dependencies = [
"web-sys",
]
[[package]]
name = "rfd"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
dependencies = [
"block2",
"dispatch2",
"glib-sys",
"gobject-sys",
"gtk-sys",
"js-sys",
"log",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"raw-window-handle",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows-sys 0.60.2",
]
[[package]]
name = "ring"
version = "0.17.14"
@@ -4003,6 +4030,48 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-dialog"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2d3c1dbe38037e7f590cdf2492594d5ceebe031e7bc7e827509b22a999d2940"
dependencies = [
"log",
"raw-window-handle",
"rfd",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
"thiserror 2.0.18",
"url",
]
[[package]]
name = "tauri-plugin-fs"
version = "2.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371"
dependencies = [
"anyhow",
"dunce",
"glob",
"log",
"objc2-foundation",
"percent-encoding",
"schemars 0.8.22",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.18",
"toml 1.1.3+spec-1.1.0",
"url",
]
[[package]]
name = "tauri-plugin-opener"
version = "2.5.4"
@@ -5150,6 +5219,15 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets 0.53.5",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
@@ -5183,13 +5261,30 @@ dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm",
"windows_i686_gnullvm 0.52.6",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
]
[[package]]
name = "windows-targets"
version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link 0.2.1",
"windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc 0.53.1",
"windows_i686_gnu 0.53.1",
"windows_i686_gnullvm 0.53.1",
"windows_i686_msvc 0.53.1",
"windows_x86_64_gnu 0.53.1",
"windows_x86_64_gnullvm 0.53.1",
"windows_x86_64_msvc 0.53.1",
]
[[package]]
name = "windows-threading"
version = "0.1.0"
@@ -5220,6 +5315,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
@@ -5232,6 +5333,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
@@ -5244,12 +5351,24 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
@@ -5262,6 +5381,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.42.2"
@@ -5274,6 +5399,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.42.2"
@@ -5286,6 +5417,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.42.2"
@@ -5298,6 +5435,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winnow"
version = "0.5.40"
+3 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "cpa-gui"
version = "0.2.9"
version = "0.2.10"
description = "CPA desktop management shell"
authors = ["lzt404"]
edition = "2021"
@@ -13,6 +13,7 @@ base64 = "0.22"
chrono = { version = "0.4", features = ["serde"] }
flate2 = "1"
futures-util = "0.3"
getrandom = "0.3"
json5 = "0.4"
notify = "7"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
@@ -25,6 +26,7 @@ serde_yaml = "0.9"
sha2 = "0.10"
tar = "0.4"
tauri = { version = "~2.11", features = ["tray-icon"] }
tauri-plugin-dialog = "2.7.1"
tauri-plugin-opener = "2"
tokio = { version = "1", features = ["macros", "sync", "time"] }
tokio-util = "0.7"
+2 -1
View File
@@ -8,6 +8,7 @@
"core:event:allow-unlisten",
"core:window:allow-set-theme",
"core:window:allow-set-background-color",
"core:webview:allow-set-webview-background-color"
"core:webview:allow-set-webview-background-color",
"dialog:allow-open"
]
}
+120 -16
View File
@@ -99,11 +99,14 @@ pub(crate) fn parse_runtime_models(payload: &Value) -> Result<Vec<CodexRuntimeMo
.find(|value| !value.is_empty())
.unwrap_or_default()
};
if slug.is_empty() || !seen.insert(normalize_id(slug)) {
if slug.is_empty() {
continue;
}
let display_name = optional_string(value, &["display_name", "displayName", "alias"]);
let display_name = optional_string(value, &["display_name", "displayName"]);
let model_alias =
optional_string(value, &["alias"]).filter(|alias| !alias.eq_ignore_ascii_case(slug));
let keep_original = value.get("fork").and_then(Value::as_bool).unwrap_or(false);
let description = optional_string(value, &["description"]);
let context_window = positive_u64_field(
value,
@@ -125,17 +128,28 @@ pub(crate) fn parse_runtime_models(payload: &Value) -> Result<Vec<CodexRuntimeMo
let hidden = optional_string(value, &["visibility"])
.is_some_and(|value| value.eq_ignore_ascii_case("hide"));
models.push(CodexRuntimeModel {
let make_runtime_model = |slug: &str, display_name: Option<String>| CodexRuntimeModel {
slug: slug.to_string(),
display_name,
description,
description: description.clone(),
context_window,
max_context_window,
input_modalities,
supported_reasoning_levels,
default_reasoning_level,
input_modalities: input_modalities.clone(),
supported_reasoning_levels: supported_reasoning_levels.clone(),
default_reasoning_level: default_reasoning_level.clone(),
hidden,
});
};
if let Some(model_alias) = model_alias {
if keep_original && seen.insert(normalize_id(slug)) {
models.push(make_runtime_model(slug, display_name));
}
if seen.insert(normalize_id(&model_alias)) {
models.push(make_runtime_model(&model_alias, Some(slug.to_string())));
}
} else if seen.insert(normalize_id(slug)) {
models.push(make_runtime_model(slug, display_name));
}
}
Ok(models)
}
@@ -311,6 +325,7 @@ fn prepare_catalog_with_sources(
if let Some(template) = sources.templates.get(&key) {
let mut value = template.value.clone();
value.insert("slug".to_string(), Value::String(runtime.slug.clone()));
enable_fast_mode(&mut value);
entries.push(CatalogEntry {
value,
template_order: Some(template.order),
@@ -319,6 +334,7 @@ fn prepare_catalog_with_sources(
let mut value = sources.fallback.clone();
apply_runtime_metadata(&mut value, runtime);
disable_fallback_capabilities(&mut value);
enable_fast_mode(&mut value);
entries.push(CatalogEntry {
value,
template_order: None,
@@ -372,6 +388,8 @@ fn prepare_catalog_with_sources(
alias: optional_map_string(&entry.value, "display_name").filter(|display| {
!display.eq_ignore_ascii_case(&string_value(&entry.value, "slug"))
}),
is_alias: false,
context_window: positive_u64_value(entry.value.get("context_window")),
})
.collect::<Vec<_>>();
let values = entries
@@ -492,17 +510,27 @@ fn disable_fallback_capabilities(model: &mut Map<String, Value>) {
"web_search_tool_type".to_string(),
Value::String("text".to_string()),
);
model.insert("service_tiers".to_string(), Value::Array(Vec::new()));
model.insert(
"additional_speed_tiers".to_string(),
Value::Array(Vec::new()),
);
model.insert("default_service_tier".to_string(), Value::Null);
model.insert("upgrade".to_string(), Value::Null);
model.insert("availability_nux".to_string(), Value::Null);
model.remove("minimal_client_version");
}
fn enable_fast_mode(model: &mut Map<String, Value>) {
model.insert(
"service_tiers".to_string(),
serde_json::json!([{
"id": "priority",
"name": "Fast",
"description": "1.5x speed, increased usage"
}]),
);
model.insert(
"additional_speed_tiers".to_string(),
serde_json::json!(["fast"]),
);
}
fn parse_modalities(value: &Value) -> Option<Vec<String>> {
let raw = value
.get("input_modalities")
@@ -808,6 +836,64 @@ mod tests {
assert_eq!(models[1].slug, "Model-B");
}
#[test]
fn runtime_parser_exposes_model_alias_as_a_runtime_slug() {
let models = runtime(serde_json::json!({
"models": [
{
"id": "gpt-5.5",
"alias": "gpt-5.5-xhigh",
"fork": true,
"context_window": 200000
},
{"id": "hidden-source", "alias": "visible-alias"}
]
}));
assert_eq!(
models
.iter()
.map(|model| model.slug.as_str())
.collect::<Vec<_>>(),
vec!["gpt-5.5", "gpt-5.5-xhigh", "visible-alias"]
);
assert_eq!(models[1].display_name.as_deref(), Some("gpt-5.5"));
assert_eq!(models[1].context_window, Some(200_000));
assert_eq!(models[2].display_name.as_deref(), Some("hidden-source"));
let catalog = prepare_catalog_with_sources(&models, &test_sources()).unwrap();
assert_eq!(
catalog
.models
.iter()
.map(|model| model.name.as_str())
.collect::<Vec<_>>(),
vec!["gpt-5.5", "gpt-5.5-xhigh", "visible-alias"]
);
assert_eq!(catalog.models[1].alias.as_deref(), Some("gpt-5.5"));
}
#[test]
fn all_runtime_models_advertise_fast_capabilities() {
let runtime = runtime(serde_json::json!({"models":[
{"id":"B"},
{"id":"remote-model-alias"}
]}));
let catalog = prepare_catalog_with_sources(&runtime, &test_sources()).unwrap();
for model in output_models(&catalog) {
assert_eq!(
model["service_tiers"],
serde_json::json!([{
"id": "priority",
"name": "Fast",
"description": "1.5x speed, increased usage"
}])
);
assert_eq!(model["additional_speed_tiers"], serde_json::json!(["fast"]));
}
}
#[test]
fn known_and_unknown_models_are_composed_from_the_correct_sources() {
let runtime = runtime(serde_json::json!({"models":[
@@ -847,12 +933,23 @@ mod tests {
let model = &output_models(&catalog)[0];
let template = &sources.templates["a"].value;
for (key, expected) in template {
if key != "slug" {
if !matches!(
key.as_str(),
"slug" | "service_tiers" | "additional_speed_tiers"
) {
assert_eq!(model.get(key), Some(expected), "changed field {key}");
}
}
assert_eq!(model["slug"], "a");
assert_eq!(model["nested"]["unknown"], true);
assert_eq!(
model["service_tiers"],
serde_json::json!([{
"id": "priority",
"name": "Fast",
"description": "1.5x speed, increased usage"
}])
);
assert_eq!(model["additional_speed_tiers"], serde_json::json!(["fast"]));
}
@@ -885,8 +982,15 @@ mod tests {
"You are Codex, a model-neutral coding agent."
);
assert_eq!(model["supports_search_tool"], false);
assert_eq!(model["service_tiers"], serde_json::json!([]));
assert_eq!(model["additional_speed_tiers"], serde_json::json!([]));
assert_eq!(
model["service_tiers"],
serde_json::json!([{
"id": "priority",
"name": "Fast",
"description": "1.5x speed, increased usage"
}])
);
assert_eq!(model["additional_speed_tiers"], serde_json::json!(["fast"]));
}
#[test]
+6242 -258
View File
File diff suppressed because it is too large. Load diff
+2
View File
@@ -2206,6 +2206,8 @@ mod tests {
routing_session_affinity: false,
routing_session_affinity_ttl: String::new(),
codex_session_repair_on_launch: false,
claude_code_working_directory: String::new(),
claude_code_working_directory_prompt_disabled: false,
};
let record = normalize_usage_record(
serde_json::json!({
+3 -3
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "EasyCLIProxyAPI",
"version": "0.2.9",
"version": "0.2.10",
"identifier": "com.cpa.gui",
"build": {
"beforeDevCommand": "bun run dev",
@@ -14,8 +14,8 @@
{
"label": "main",
"title": "EasyCLIProxyAPI",
"width": 1551,
"height": 893,
"width": 1531,
"height": 891,
"minWidth": 640,
"minHeight": 600
}
+2 -26
View File
@@ -6,9 +6,6 @@ import {
Check,
ChevronUp,
ExternalLink,
FileKey,
Gauge,
GitFork,
History,
House,
Languages,
@@ -24,12 +21,9 @@ 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';
import { OAuthManagementPage } from './pages/ManagementPages';
import { AgentsPage } from './pages/AgentsPage';
import { ThinkingAliasesPage } from './pages/ThinkingAliasesPage';
import { UsageRecordsPage } from './pages/UsageRecordsPage';
import { languageOptions, useI18n } from './i18n';
import { AppUpdateDialog, AppUpdateProvider, useAppUpdate } from './appUpdate';
@@ -58,17 +52,11 @@ const pages = [
icon: Settings,
component: ConfigPanelPage,
},
{
id: 'thinking-aliases',
labelKey: 'app.nav.thinkingAliases',
icon: GitFork,
component: ThinkingAliasesPage,
},
{
id: 'oauth',
labelKey: 'app.nav.oauth',
icon: LogIn,
component: OAuthLoginPage,
component: OAuthManagementPage,
},
{
id: 'api',
@@ -76,18 +64,6 @@ const pages = [
icon: Network,
component: ApiAccessPage,
},
{
id: 'auth-files',
labelKey: 'app.nav.authFiles',
icon: FileKey,
component: AuthFileManagementPage,
},
{
id: 'quota',
labelKey: 'app.nav.quota',
icon: Gauge,
component: QuotaPage,
},
{
id: 'usage-records',
labelKey: 'app.nav.usageRecords',
+114 -33
View File
@@ -11,7 +11,7 @@ export const jaOverrides = {
'app.nav.home': 'ホーム',
'app.nav.versions': 'バージョン管理',
'app.nav.kernel': 'コア',
'app.nav.config': '設定',
'app.nav.config': '詳細設定',
'app.nav.thinkingAliases': 'モデルエイリアス',
'app.nav.oauth': 'OAuth',
'app.nav.api': 'API 接続',
@@ -59,6 +59,7 @@ export const jaOverrides = {
'quota.confirm.expiry': '最も早い有効期限:{time}',
'quota.confirm.warning': '「確認」を押した場合のみ実行されます。「キャンセル」ではリセット回数を消費しません。',
'oauth.title': 'OAuth ログイン',
'oauth.tabs.label': 'OAuth 管理ページ',
'oauth.loginSuccess': '{provider} へのログインに成功しました',
'oauth.priorityApplyFailed': 'OAuth ログインは成功しましたが、新しい認証情報の優先度を 0 に設定できませんでした:{error}',
'oauth.authFailed': '認証に失敗しました',
@@ -126,16 +127,16 @@ export const jaOverrides = {
'usage.stat.requests': '総リクエスト数',
'usage.stat.requestMeta': '成功 {success} · 失敗 {failed}',
'usage.stat.tokens': '合計 Token',
'usage.stat.tokenMeta': '入力 {input} · 出力 {output}',
'usage.stat.tokenMeta': '入 {input} · 出 {output}',
'usage.stat.successRate': '成功率',
'usage.stat.reasoningMeta': '推論 {tokens} Token',
'usage.stat.reasoningMeta': '推論 {tokens}',
'usage.stat.averageLatency': '平均レイテンシ',
'usage.stat.tps': 'TPS',
'usage.stat.performanceMeta': '{rpm} RPM · 平均レイテンシ {latency} ms',
'usage.stat.performanceMeta': 'RPM {rpm} · {latency} ms',
'usage.stat.cacheHitRate': 'キャッシュヒット率',
'usage.stat.cacheHitMeta': 'ヒット {hit} / 入力 {input} Token',
'usage.stat.cacheHitMeta': 'キャッシュ {hit}/{input}',
'usage.stat.estimatedCost': '推定コスト',
'usage.stat.costMeta': '{priced} / {total} 件を計算済み · 参考値',
'usage.stat.costMeta': '計算 {priced}/{total} · 推定',
'usage.pricing.coverage': '{priced} / {total} 件を計算済み · 利用可能なモデル料金 {saved} 件',
'usage.pricing.search': 'モデルを検索',
'usage.pricing.add': '手動で追加',
@@ -202,11 +203,14 @@ export const jaOverrides = {
'aliases.error.selectModel': '先に元のモデルを選択してください',
'aliases.error.emptyAlias': 'エイリアスモデル名を入力してください',
'aliases.error.emptyEffort': '推論レベルを入力してください',
'aliases.error.emptyOptions': '推論レベルを設定するか、Fast を有効にしてください',
'aliases.error.unsupportedEffort': 'このモデルでは推論レベルを設定できないため、Fast オプションのみ使用できます',
'aliases.created': '{alias} を作成し、推論レベルを {effort} に固定しました',
'aliases.deleteConfirm': 'エイリアスモデル「{alias}」を削除しますか?\n対応する推論レベルの上書きルールも削除されます。',
'aliases.createdCombined': '{alias} を作成し、推論レベル {effort} と Fast を有効にしました',
'aliases.deleteConfirm': 'エイリアスモデル「{alias}」を削除しますか?\n対応する推論レベルと Fast の上書きルールも削除されます。',
'aliases.deleted': '{alias} を削除しました',
'aliases.create.title': '推論エイリアスを作成',
'aliases.create.description': 'Codex OAuth、Codex API、OpenAI 互換接続に対応',
'aliases.create.title': 'モデルエイリアスを作成',
'aliases.create.description': '推論レベルと Fast 速度階層は個別にも同時にも設定できます',
'aliases.originalModel': '元のモデル',
'aliases.loadingModels': 'モデルを読み込んでいます',
'aliases.searchModel': '元のモデルを検索して選択',
@@ -216,7 +220,14 @@ export const jaOverrides = {
'aliases.sourceLabel': 'ソース:{source}',
'aliases.sourceHint': '同名モデルは Codex OAuth、Codex API、OpenAI 互換ソースごとに表示されます',
'aliases.effort.title': '推論レベル',
'aliases.effort.description': '一般的なレベルを選択するか、上流が対応するカスタム値を入力します',
'aliases.effort.description': '個別に設定することも、Fast と同時に使用することもできます',
'aliases.effort.none': '設定しない',
'aliases.effort.noneHint': '推論レベルを上書きせず、ほかの有効なオプションのみ使用します',
'aliases.effort.unsupported': 'このモデルには利用可能な推論レベルがないため、Fast のみ有効にしました。',
'aliases.fast.title': '速度オプション',
'aliases.fast.description': 'Fast リクエスト階層を固定します(service_tier=priority)',
'aliases.fast.enabled': '有効:推論レベルと同時に適用されます',
'aliases.fast.disabled': '無効',
'aliases.customLevel': 'カスタムレベル:{effort}',
'aliases.customLevelHint': 'モデルプロバイダーが対応する別の推論レベルを入力',
'aliases.custom': 'カスタム',
@@ -233,14 +244,23 @@ export const jaOverrides = {
'aliases.creating': '作成中',
'aliases.create': 'エイリアスを作成',
'aliases.createdList.title': '作成済みエイリアス',
'aliases.createdList.description': '各エイリアスには元のモデル、ソース、固定推論レベルが保持されます',
'aliases.createdList.description': '各エイリアスの推論レベルと Fast 状態をまとめて表示します',
'aliases.loadingConfig': '設定を読み込んでいます',
'aliases.empty.title': '推論エイリアスはまだありません',
'aliases.empty.description': '左側でモデルとレベルを選択して作成できます',
'aliases.empty.title': 'モデルエイリアスはまだありません',
'aliases.empty.description': '左側でモデルとエイリアスオプションを選択して作成できます',
'aliases.unboundEffort': 'レベル未設定',
'aliases.delete': '{alias} を削除',
'speedAliases.created': '{alias} を作成し、速度階層を Fast に固定しました',
'speedAliases.deleted': '{alias} を削除しました',
'speedAliases.deleteConfirm': '速度エイリアス「{alias}」を削除しますか?\n対応する Fast 上書きルールも削除されます。',
'speedAliases.fast.title': 'Fast',
'speedAliases.aliasName.example': '例:{model}-fast',
'speedAliases.delete': '{alias} を削除',
'config.routing.roundRobin': 'ラウンドロビン',
'config.routing.fillFirst': '優先充填',
'config.tabs.label': '詳細設定ページ',
'config.tabs.general': '基本設定',
'config.tabs.network': 'ネットワークとルーティング',
'config.error.emptyKey': '認証キーを入力してください',
'config.error.invalidKey': '空白を含まない表示可能な ASCII 文字のみ使用できます',
'config.error.duplicateKey': '同じ認証キーがすでに存在します',
@@ -253,8 +273,6 @@ export const jaOverrides = {
'config.notice.keyDeleted': '認証キーを削除しました',
'config.notice.keyCopied': '認証キーをコピーしました',
'config.notice.keyCopyFailed': '認証キーをコピーできませんでした',
'config.notice.pluginsEnabled': 'プラグインシステムを有効にしました',
'config.notice.pluginsDisabled': 'プラグインシステムを無効にしました',
'config.notice.routingUpdated': 'ルーティング方式を更新しました',
'config.notice.networkUpdated': 'ネットワークとセッションルーティング設定を保存しました',
'config.notice.networkRestarted': 'ネットワーク設定を保存し、コアを再起動しました',
@@ -263,6 +281,26 @@ export const jaOverrides = {
'config.notice.sessionAffinityEnabled': 'セッションアフィニティを有効にしました',
'config.notice.sessionAffinityDisabled': 'セッションアフィニティを無効にしました',
'config.notice.sessionTtlUpdated': 'セッションアフィニティ TTL を保存しました',
'config.webuiKey.title': 'WebUI キー',
'config.webuiKey.configured': '設定済み',
'config.webuiKey.unconfigured': '未設定',
'config.webuiKey.heading': '管理 API のアクセス認証情報',
'config.webuiKey.description': 'CPA WebUI へのログインと管理 API の呼び出しに使用します。上の API 認証キーとは用途が異なります。',
'config.webuiKey.securityHint': '現在のキーは表示されません。保存後、他の WebUI では新しいキーで再ログインしてください。',
'config.webuiKey.newKey': '新しい WebUI キー',
'config.webuiKey.confirmKey': '新しいキーを確認',
'config.webuiKey.placeholder': '新しい平文キーを入力',
'config.webuiKey.confirmPlaceholder': '新しいキーをもう一度入力',
'config.webuiKey.generate': '安全なキーを生成',
'config.webuiKey.save': 'キーを保存',
'config.webuiKey.open': 'WebUI を開く',
'config.webuiKey.error.empty': 'WebUI キーを入力してください',
'config.webuiKey.error.legacyDefault': '旧版の既定キー 123456 は使用できません',
'config.webuiKey.error.tooLong': 'WebUI キーは 512 文字以内で入力してください',
'config.webuiKey.error.invalid': 'WebUI キーに制御文字は使用できません',
'config.webuiKey.error.mismatch': '入力した WebUI キーが一致しません',
'config.webuiKey.error.openFailed': 'WebUI を開けませんでした:{error}',
'config.webuiKey.notice.updated': 'WebUI キーを更新しました',
'config.keys.title': '認証キー',
'config.keys.count': 'キー数',
'config.keys.add': '認証キーを追加',
@@ -276,14 +314,14 @@ export const jaOverrides = {
'config.keys.delete': '認証キーを削除',
'config.keys.deleteNth': '{number} 番目の認証キーを削除',
'config.keys.empty': '認証キーがありません',
'config.plugins.title': 'プラグインシステム',
'common.loading': '読み込み中',
'common.unavailable': '利用不可',
'common.enabled': '有効',
'common.disabled': '無効',
'config.plugins.enable': 'プラグインシステムを有効にする',
'config.routing.title': 'ルーティング方式',
'config.network.title': 'ネットワークとセッションルーティング',
'config.network.networkSection': 'ネットワーク設定',
'config.network.routingSection': 'セッションルーティング',
'config.network.saving': '保存中',
'config.network.saved': '保存済み',
'config.network.unsaved': '未保存の変更あり',
@@ -401,13 +439,13 @@ export const jaOverrides = {
'agents.model.loading': 'モデルを読み込んでいます',
'agents.model.loadFailed': 'モデルの取得に失敗しました',
'agents.model.select': 'モデルを選択',
'agents.model.none': '選択可能なモデルなし',
'agents.model.none': 'モデル一覧は空です',
'agents.model.search': 'モデル名またはエイリアスを検索',
'agents.model.clearSearch': '検索を消去',
'agents.model.refresh': 'モデルを更新',
'agents.model.fetching': 'モデルを取得しています',
'agents.model.noMatch': '一致するモデルがありません',
'agents.model.unavailable': '現在利用可能なモデルはありません',
'agents.model.unavailable': 'モデル一覧は空です',
'agents.model.tryKeywords': '別のキーワードをお試しください',
'agents.model.connectFirst': '先に利用可能なモデルを接続してから更新してください',
'agents.model.available': '利用可能なモデル',
@@ -416,6 +454,7 @@ export const jaOverrides = {
'agents.error.modelsLoading': 'モデル一覧を読み込み中です。しばらくしてから再試行してください',
'agents.error.noModels': '選択可能なモデルがないため、設定変更を適用できません',
'agents.error.selectionGone': '選択中のモデルは利用可能一覧にありません。更新後に選び直してください',
'agents.error.mappingSelectionGone': 'Claude Desktop のモデルマッピングに利用できないモデルがあります。モデルを更新して選び直してください',
'agents.error.applyFirst': '選択したモデルはまだ適用されていません。先に「設定変更を適用」をクリックしてください',
'agents.status.detecting': '検出中',
'agents.status.unsupported': 'プラットフォーム非対応',
@@ -441,7 +480,7 @@ export const jaOverrides = {
'agents.sessions.databaseMissing': 'セッションデータベースが見つかりません',
'agents.sessions.repairTitle': '過去のセッションを復元',
'agents.sessions.repairDescription': '他の Provider のセッションを現在のセッション Provider に同期します。',
'agents.sessions.autoRepair': 'Codex 起動前に過去のセッションを自動復元',
'agents.sessions.autoRepair': '過去のセッションを自動復元',
'agents.sessions.settingLoading': '読み込み中',
'agents.sessions.settingOn': 'オン',
'agents.sessions.settingOff': 'オフ',
@@ -511,22 +550,43 @@ export const jaOverrides = {
'agents.model.firstSelection': '利用可能なモデル {count} 件。初回は先頭を選択します',
'agents.modify.title': '設定変更',
'agents.modify.oauthConfiguration': 'OAuth 設定方式',
'agents.claudeDesktopMapping.title': 'Claude Desktop モデル → 上流モデル',
'agents.claudeDesktopMapping.description': '各 Claude Desktop モデルが実際に呼び出す上流モデルを選択します。',
'agents.claudeCodeMapping.title': 'Claude Code モデル → 上流モデル',
'agents.claudeCodeMapping.description': '各タスク役割に対して、起動時に設定する上流モデルを選択します。',
'agents.claudeDesktopMapping.customMapping': 'カスタムマッピング',
'agents.claudeDesktopMapping.customMappingHint': '有効にするとエイリアスモデルのみ、無効にするとエイリアス未設定の元モデルのみ表示します。',
'agents.claudeDesktopMapping.opus': 'Claude Opus 5 のマッピング先',
'agents.claudeDesktopMapping.sonnet': 'Claude Sonnet 4.6 のマッピング先',
'agents.claudeDesktopMapping.haiku': 'Claude Haiku 4.5 のマッピング先',
'agents.claudeCodeLaunch.noDirectory': '未選択',
'agents.claudeCodeLaunch.chooseDirectory': 'ディレクトリを選択',
'agents.claudeCodeLaunch.selectedDirectory': '選択した作業ディレクトリ',
'agents.claudeCodeLaunch.choosing': '選択中',
'agents.claudeCodeLaunch.dialogTitle': 'Claude Code の作業ディレクトリを選択',
'agents.claudeCodeLaunch.dialogDescription': '環境変数を設定して Claude Code を起動する前に、作業ディレクトリを選択してください。',
'agents.claudeCodeLaunch.workingDirectory': '作業ディレクトリ',
'agents.claudeCodeLaunch.browse': '参照',
'agents.claudeCodeLaunch.neverAskAgain': '今後この確認を表示しない',
'agents.claudeCodeLaunch.directoryRequired': '先に作業ディレクトリを選択してください',
'agents.claudeCodeLaunch.launch': 'Claude Code を起動',
'agents.oauthLoginRequired.title': 'Codex へのログインが必要です',
'agents.oauthLoginRequired.enableDescription': '有効な Codex のログイン認証情報が見つからないため、OAuth 設定方式を有効にできません。Codex アプリで ChatGPT アカウントにログインしてください。Codex アプリでログインを開始できない場合は、先に',
'agents.oauthLoginRequired.enableClearConfiguration': '設定を消去',
'agents.oauthLoginRequired.enableDescriptionSuffix': 'を使用してください。',
'agents.oauthLoginRequired.applyDescription': '有効な Codex のログイン認証情報が見つからないため、OAuth 設定を適用できません。Codex で ChatGPT アカウントにログインしてから、設定を適用してください。',
'agents.oauthLoginRequired.launchDescription': 'OAuth 設定方式が有効ですが、有効な Codex のログイン認証情報が見つかりません。起動する前に Codex で ChatGPT アカウントにログインしてください。',
'agents.oauthLoginRequired.confirm': '了解しました',
'agents.modify.apply': '設定変更を適用',
'agents.modify.close': '設定変更を終了',
'agents.modify.default': 'デフォルト設定',
'agents.modify.clear': '設定を消去',
'agents.modify.invalid': '現在の設定を解析できません。「設定変更を適用」は最小の有効な設定を再構築し、「デフォルト設定」はファイル全体をリセットします。',
'agents.modify.applied': 'アプリが管理するフィールドは適用済みで、それ以外のユーザー設定は保持されています。',
'agents.footer.changed': 'モデル選択が変更されました。設定変更を適用すると新しいモデルが使用されます。',
'agents.footer.applied': '設定変更は適用済みです。アプリは関連フィールドだけを管理します。',
'agents.footer.invalidManaged': '設定が無効です。変更を適用して修復するか、デフォルト設定にリセットしてください。',
'agents.footer.unsupported': '現在のシステムではこのクライアントを設定できません',
'agents.footer.installFirst': 'クライアントをインストールしてから再検出してください',
'agents.footer.noCommand': '設定ファイルは検出しましたが、クライアントコマンドが見つかりません',
'agents.footer.invalidConfig': '元の設定形式が無効なため、安全に変更できません',
'agents.launchMethods': 'Codex の起動方法',
'agents.launch.enableFirst': '先に設定変更を適用してください',
'agents.launch.applyFirst': '先に選択中のモデルを適用してください',
'agents.launch.starting': '起動中',
'agents.launch.startCli': 'CLI を起動',
'agents.launch.startApp': 'App を起動',
'agents.launch.start': '{target} を起動',
'agents.launch.unavailable': '起動できません',
'agents.launch.closeChatgpt': 'ChatGPT App を終了',
@@ -577,12 +637,33 @@ export const jaOverrides = {
'apiAccess.defaultUrl': '既定のアドレスを使用',
'apiAccess.models.summary': 'モデル {count} 件 · {models}',
'apiAccess.priorityValue': '優先度 {priority}',
'apiAccess.runtimeCredential': '実行時認証情報 {index}…',
'apiAccess.enable': '接続を有効化',
'apiAccess.disable': '接続を無効化',
'apiAccess.status.disabled': '無効',
'apiAccess.status.enabled': '有効',
'apiAccess.toggleAria': '{remark} 接続の{action}スイッチ',
'apiAccess.health.action': 'ヘルスチェック',
'apiAccess.health.title': 'モデルヘルスチェック',
'apiAccess.health.description': '{provider}',
'apiAccess.health.warning': '各モデルへ最小限のストリーミングリクエストを 1 回送信し、最初の出力文字を受信した時点で停止します。少額の料金が発生する場合があります。',
'apiAccess.health.search': 'モデル名またはエイリアスを検索',
'apiAccess.health.loadingModels': 'モデル一覧を読み込んでいます…',
'apiAccess.health.modelLoadFailed': 'モデル一覧を更新できませんでした:{error}',
'apiAccess.health.noMatch': '一致するモデルがありません',
'apiAccess.health.notChecked': '未確認',
'apiAccess.health.checkOne': '確認',
'apiAccess.health.retryOne': '再確認',
'apiAccess.health.checkAll': 'すべてのモデルを確認',
'apiAccess.health.checkingProgress': '確認中 {checked}/{total}',
'apiAccess.health.summary': '確認済み {checked}/{total} · 正常 {healthy} · 失敗 {failed}',
'apiAccess.health.checking': '確認中…',
'apiAccess.health.healthy': '正常',
'apiAccess.health.failed': '失敗',
'apiAccess.health.noModel': 'ヘルスチェックに使用できるモデルがありません',
'apiAccess.health.timeout': 'リクエストがタイムアウトしました',
'apiAccess.health.missingDirectKey': '初字遅延を測定するには、直接使用できる平文の API キーが必要です',
'apiAccess.health.firstTokenInline': '初字 {latency} ms',
'apiAccess.health.firstTokenLatencyResult': '初字遅延:{latency} ms',
'common.edit': '編集',
'apiAccess.error.enterBaseUrl': '先に Base URL を入力してください',
'apiAccess.error.noAvailableModels': '利用可能なモデルが見つかりません',
@@ -790,9 +871,9 @@ export const jaOverrides = {
'kernel.dialog.install': 'コアのインストール',
'kernel.dialog.phase': '現在の段階',
'kernel.dialog.unknownProgress': '進捗不明',
'kernel.access.openaiDescription': 'OpenAI SDK、OpenCode などの互換クライアント向け',
'kernel.access.claudeDescription': 'Claude SDK および Claude Code 向け',
'kernel.access.geminiDescription': 'Gemini ネイティブ互換クライアント向け',
'kernel.access.openaiDescription': 'OpenAI互換形式',
'kernel.access.claudeDescription': 'Anthropic互換形式',
'kernel.access.geminiDescription': 'Gemini互換形式',
'authFiles.unnamed': '無名の認証ファイル',
'management.notConfigured': '未設定',
'management.error.upstream': '上流リクエストに失敗しました',
+231 -69
View File
@@ -14,7 +14,7 @@ export const zhCN = {
'app.nav.home': '首页',
'app.nav.versions': '版本管理',
'app.nav.kernel': '内核',
'app.nav.config': '配置',
'app.nav.config': '高级设置',
'app.nav.thinkingAliases': '模型别名',
'app.nav.oauth': 'OAuth',
'app.nav.api': 'API 接入',
@@ -62,6 +62,7 @@ export const zhCN = {
'quota.confirm.expiry': '最早过期:{time}',
'quota.confirm.warning': '只有点击“确定”后才会执行;点击“取消”不会消耗重置机会。',
'oauth.title': 'OAuth 登录',
'oauth.tabs.label': 'OAuth 管理子页面',
'oauth.loginSuccess': '{provider} 登录成功',
'oauth.priorityApplyFailed': 'OAuth 登录成功,但无法将新凭证的优先级设为 0:{error}',
'oauth.authFailed': '认证失败',
@@ -127,18 +128,18 @@ export const zhCN = {
'usage.filter.to': '至',
'usage.loading': '正在读取本地 SQLite 使用记录',
'usage.stat.requests': '请求总数',
'usage.stat.requestMeta': '{success} 成功 · {failed} 失败',
'usage.stat.requestMeta': '成功 {success} · 失败 {failed}',
'usage.stat.tokens': 'Token 总量',
'usage.stat.tokenMeta': '输入 {input} · 输出 {output}',
'usage.stat.tokenMeta': '入 {input} · 出 {output}',
'usage.stat.successRate': '成功率',
'usage.stat.reasoningMeta': '思考 {tokens} Token',
'usage.stat.reasoningMeta': '思考 {tokens}',
'usage.stat.averageLatency': '平均延迟',
'usage.stat.tps': 'TPS',
'usage.stat.performanceMeta': '{rpm} RPM · 平均延迟 {latency} ms',
'usage.stat.performanceMeta': 'RPM {rpm} · {latency} ms',
'usage.stat.cacheHitRate': '缓存命中率',
'usage.stat.cacheHitMeta': '命中 {hit} / 输入 {input} Token',
'usage.stat.cacheHitMeta': '缓存 {hit}/{input}',
'usage.stat.estimatedCost': '预估成本',
'usage.stat.costMeta': '已计价 {priced} / {total} 次请求 · 仅供参考',
'usage.stat.costMeta': '计价 {priced}/{total} · 估算',
'usage.pricing.coverage': '已计价 {priced} / {total} 次请求 · 可用 {saved} 个模型价格',
'usage.pricing.search': '搜索模型',
'usage.pricing.add': '手动添加',
@@ -205,11 +206,14 @@ export const zhCN = {
'aliases.error.selectModel': '请先选择原模型',
'aliases.error.emptyAlias': '别名模型不能为空',
'aliases.error.emptyEffort': '思考强度不能为空',
'aliases.error.emptyOptions': '请至少设置思考强度或启用 Fast',
'aliases.error.unsupportedEffort': '该模型不支持设置思考强度,只能使用 Fast 选项',
'aliases.created': '已创建 {alias},思考强度固定为 {effort}',
'aliases.deleteConfirm': '确定删除别名模型“{alias}”吗?\n将同时移除对应的思考强度覆盖规则。',
'aliases.createdCombined': '已创建 {alias},思考强度为 {effort},同时启用 Fast',
'aliases.deleteConfirm': '确定删除别名模型“{alias}”吗?\n将同时移除对应的思考强度和 Fast 覆盖规则。',
'aliases.deleted': '已删除 {alias}',
'aliases.create.title': '创建思考别名',
'aliases.create.description': '支持 Codex OAuth、Codex API 和 OpenAI 兼容接入',
'aliases.create.title': '创建模型别名',
'aliases.create.description': '思考强度和 Fast 速度层级可独立设置,也可以同时启用',
'aliases.originalModel': '原模型',
'aliases.loadingModels': '正在读取模型',
'aliases.searchModel': '搜索并选择原模型',
@@ -219,7 +223,14 @@ export const zhCN = {
'aliases.sourceLabel': '来源:{source}',
'aliases.sourceHint': '同名模型会按 Codex OAuth、Codex API 和 OpenAI 兼容来源分别列出',
'aliases.effort.title': '思考强度',
'aliases.effort.description': '选择常用等级,或输入上游支持的自定义值',
'aliases.effort.description': '可单独设置,也可以与 Fast 同时启用',
'aliases.effort.none': '不设置',
'aliases.effort.noneHint': '不覆盖思考强度,仅使用其他已启用的选项',
'aliases.effort.unsupported': '这个模型没有可用的思考强度,已切换为仅启用 Fast。',
'aliases.fast.title': '速度选项',
'aliases.fast.description': '固定使用 Fast 请求层级(service_tier=priority)',
'aliases.fast.enabled': '已启用,可与思考强度同时生效',
'aliases.fast.disabled': '未启用',
'aliases.customLevel': '自定义等级:{effort}',
'aliases.customLevelHint': '输入模型提供商支持的其他思考等级',
'aliases.custom': '自定义',
@@ -236,14 +247,23 @@ export const zhCN = {
'aliases.creating': '创建中',
'aliases.create': '创建别名',
'aliases.createdList.title': '已创建别名',
'aliases.createdList.description': '每个别名都保留原模型、来源和固定思考强度',
'aliases.createdList.description': '每个别名会同时显示已设置的思考强度和 Fast 状态',
'aliases.loadingConfig': '读取配置中',
'aliases.empty.title': '尚未创建思考别名',
'aliases.empty.description': '从左侧选择模型和强度即可创建',
'aliases.empty.title': '尚未创建模型别名',
'aliases.empty.description': '从左侧选择模型和别名选项即可创建',
'aliases.unboundEffort': '未绑定强度',
'aliases.delete': '删除 {alias}',
'speedAliases.created': '已创建 {alias},速度层级固定为 Fast',
'speedAliases.deleted': '已删除 {alias}',
'speedAliases.deleteConfirm': '确定删除速度别名模型“{alias}”吗?\n将同时移除对应的 Fast 覆盖规则。',
'speedAliases.fast.title': 'Fast',
'speedAliases.aliasName.example': '例如 {model}-fast',
'speedAliases.delete': '删除 {alias}',
'config.routing.roundRobin': '轮询',
'config.routing.fillFirst': '优先填充',
'config.tabs.label': '高级设置子页面',
'config.tabs.general': '基础设置',
'config.tabs.network': '网络与路由',
'config.error.emptyKey': '鉴权密钥不能为空',
'config.error.invalidKey': '只能使用 ASCII 可见字符,且不能包含空格',
'config.error.duplicateKey': '该鉴权密钥已经存在',
@@ -256,8 +276,6 @@ export const zhCN = {
'config.notice.keyDeleted': '鉴权密钥已删除',
'config.notice.keyCopied': '鉴权密钥已复制',
'config.notice.keyCopyFailed': '复制鉴权密钥失败',
'config.notice.pluginsEnabled': '插件系统已启用',
'config.notice.pluginsDisabled': '插件系统已停用',
'config.notice.routingUpdated': '路由策略已更新',
'config.notice.networkUpdated': '网络与会话路由设置已保存',
'config.notice.networkRestarted': '网络设置已保存,内核已重启',
@@ -266,6 +284,26 @@ export const zhCN = {
'config.notice.sessionAffinityEnabled': '会话粘性路由已启用',
'config.notice.sessionAffinityDisabled': '会话粘性路由已停用',
'config.notice.sessionTtlUpdated': '会话粘性 TTL 已保存',
'config.webuiKey.title': 'WebUI 密钥',
'config.webuiKey.configured': '已配置',
'config.webuiKey.unconfigured': '未配置',
'config.webuiKey.heading': '管理接口访问凭据',
'config.webuiKey.description': '用于登录 CPA WebUI 和调用管理接口,与上方 API 鉴权密钥用途不同。',
'config.webuiKey.securityHint': '现有密钥不会回显;保存后,其他 WebUI 需要使用新密钥重新登录。',
'config.webuiKey.newKey': '新 WebUI 密钥',
'config.webuiKey.confirmKey': '确认新密钥',
'config.webuiKey.placeholder': '输入新的明文密钥',
'config.webuiKey.confirmPlaceholder': '再次输入新密钥',
'config.webuiKey.generate': '生成安全密钥',
'config.webuiKey.save': '保存密钥',
'config.webuiKey.open': '打开 WebUI',
'config.webuiKey.error.empty': 'WebUI 密钥不能为空',
'config.webuiKey.error.legacyDefault': '不能继续使用旧版默认密钥 123456',
'config.webuiKey.error.tooLong': 'WebUI 密钥不能超过 512 个字符',
'config.webuiKey.error.invalid': 'WebUI 密钥不能包含控制字符',
'config.webuiKey.error.mismatch': '两次输入的 WebUI 密钥不一致',
'config.webuiKey.error.openFailed': '无法打开 WebUI:{error}',
'config.webuiKey.notice.updated': 'WebUI 密钥已更新',
'config.keys.title': '鉴权密钥',
'config.keys.count': '密钥数量',
'config.keys.add': '新增鉴权密钥',
@@ -279,14 +317,14 @@ export const zhCN = {
'config.keys.delete': '删除鉴权密钥',
'config.keys.deleteNth': '删除第 {number} 个鉴权密钥',
'config.keys.empty': '暂无鉴权密钥',
'config.plugins.title': '插件系统',
'common.loading': '读取中',
'common.unavailable': '不可用',
'common.enabled': '已启用',
'common.disabled': '已停用',
'config.plugins.enable': '启用插件系统',
'config.routing.title': '路由策略',
'config.network.title': '网络与会话路由',
'config.network.networkSection': '网络设置',
'config.network.routingSection': '会话路由',
'config.network.saving': '正在保存',
'config.network.saved': '已保存',
'config.network.unsaved': '有未保存修改',
@@ -294,7 +332,7 @@ export const zhCN = {
'config.network.port': '端口',
'config.network.portHint': '范围 1–65535;内核运行时修改会自动重启',
'config.network.allowLan': '允许局域网',
'config.network.allowLanHint': '允许其他局域网设备访问本机代理',
'config.network.allowLanHint': '启用后将监听0.0.0.0',
'config.network.proxyUrl': '代理 URL',
'config.network.proxyPlaceholder': '例如 socks5://127.0.0.1:7890',
'config.network.proxyHint': '全局上游代理;留空表示不使用',
@@ -404,13 +442,13 @@ export const zhCN = {
'agents.model.loading': '正在读取模型',
'agents.model.loadFailed': '获取模型失败',
'agents.model.select': '选择模型',
'agents.model.none': '无可选模型',
'agents.model.none': '模型列表为空',
'agents.model.search': '搜索模型名称或别名',
'agents.model.clearSearch': '清空搜索',
'agents.model.refresh': '刷新模型',
'agents.model.fetching': '正在获取模型',
'agents.model.noMatch': '没有匹配的模型',
'agents.model.unavailable': '暂时没有可用模型',
'agents.model.unavailable': '模型列表为空',
'agents.model.tryKeywords': '尝试其他关键词',
'agents.model.connectFirst': '请先接入可用模型后刷新',
'agents.model.available': '可用模型',
@@ -419,6 +457,7 @@ export const zhCN = {
'agents.error.modelsLoading': '模型列表仍在加载,请稍后再试',
'agents.error.noModels': '当前没有可选模型,无法应用配置修改',
'agents.error.selectionGone': '当前选择已不在可用模型列表中,请刷新模型后重新选择',
'agents.error.mappingSelectionGone': 'Claude Desktop 模型映射包含不可用模型,请刷新模型后重新选择',
'agents.error.applyFirst': '模型选择尚未应用,请先点击“应用配置修改”',
'agents.status.detecting': '检测中',
'agents.status.unsupported': '平台不支持',
@@ -444,7 +483,7 @@ export const zhCN = {
'agents.sessions.databaseMissing': '未发现会话数据库',
'agents.sessions.repairTitle': '恢复历史会话',
'agents.sessions.repairDescription': '将其他渠道的会话同步到当前会话',
'agents.sessions.autoRepair': '启动 Codex 前自动恢复历史会话',
'agents.sessions.autoRepair': '自动恢复历史会话',
'agents.sessions.settingLoading': '读取中',
'agents.sessions.settingOn': '已开启',
'agents.sessions.settingOff': '已关闭',
@@ -509,27 +548,48 @@ export const zhCN = {
'agents.notFetched': '未获取',
'agents.useModel': '使用模型',
'agents.pending': '尚未应用',
'agents.claudeDesktopMapping.title': 'Claude Desktop 模型 → 上游模型',
'agents.claudeDesktopMapping.description': '选择每个 Claude Desktop 模型实际调用的上游模型。',
'agents.claudeCodeMapping.title': 'Claude Code 模型 → 上游模型',
'agents.claudeCodeMapping.description': '为不同任务角色选择启动时注入的上游模型。',
'agents.claudeDesktopMapping.customMapping': '自定义映射',
'agents.claudeDesktopMapping.customMappingHint': '开启后只显示别名模型;关闭后只显示未设置别名的原始模型。',
'agents.claudeDesktopMapping.opus': 'Claude Opus 5 映射到',
'agents.claudeDesktopMapping.sonnet': 'Claude Sonnet 4.6 映射到',
'agents.claudeDesktopMapping.haiku': 'Claude Haiku 4.5 映射到',
'agents.claudeCodeLaunch.noDirectory': '尚未选择',
'agents.claudeCodeLaunch.chooseDirectory': '选择目录',
'agents.claudeCodeLaunch.selectedDirectory': '已选择的工作目录',
'agents.claudeCodeLaunch.choosing': '选择中',
'agents.claudeCodeLaunch.dialogTitle': '选择 Claude Code 工作目录',
'agents.claudeCodeLaunch.dialogDescription': '需要先选择工作目录,然后才能设置环境变量并启动 Claude Code。',
'agents.claudeCodeLaunch.workingDirectory': '工作目录',
'agents.claudeCodeLaunch.browse': '浏览',
'agents.claudeCodeLaunch.neverAskAgain': '永久关闭此提示',
'agents.claudeCodeLaunch.directoryRequired': '请先选择工作目录',
'agents.claudeCodeLaunch.launch': '启动 Claude Code',
'agents.model.readingAvailable': '正在读取可用模型',
'agents.model.current': '当前已应用:{model}',
'agents.model.firstSelection': '{count} 个可用模型,首次默认选择第一项',
'agents.modify.title': '配置修改',
'agents.modify.oauthConfiguration': 'Oauth配置方式',
'agents.oauthLoginRequired.title': '需要登录 Codex',
'agents.oauthLoginRequired.enableDescription': '未检测到有效的 Codex 登录凭据,无法开启 OAuth 配置模式。请先在 Codex 应用中登录 ChatGPT 账号,如果 Codex 应用无法触发登录,请先使用',
'agents.oauthLoginRequired.enableClearConfiguration': '清空配置',
'agents.oauthLoginRequired.enableDescriptionSuffix': '功能。',
'agents.oauthLoginRequired.applyDescription': '无法应用 OAuth 配置:未检测到有效的 Codex 登录凭据。请先在 Codex 中登录 ChatGPT 账号,然后再应用配置。',
'agents.oauthLoginRequired.launchDescription': '已开启 OAuth 配置模式,但未检测到有效的 Codex 登录凭据。请先在 Codex 中登录 ChatGPT 账号,然后再启动。',
'agents.oauthLoginRequired.confirm': '我知道了',
'agents.modify.apply': '应用配置修改',
'agents.modify.close': '关闭配置修改',
'agents.modify.default': '默认配置',
'agents.modify.clear': '清空配置',
'agents.modify.invalid': '当前配置无法解析。点击“应用配置修改”会重建最小合法配置;“默认配置”会重置整个文件。',
'agents.modify.applied': '软件负责的配置字段已应用,其他用户内容保持不变。',
'agents.footer.changed': '模型选择已变化;应用配置修改后将使用新模型。',
'agents.footer.applied': '配置修改已应用;软件只管理相关字段。',
'agents.footer.invalidManaged': '配置当前无效,请应用修改进行修复,或重置为默认配置。',
'agents.footer.unsupported': '当前系统无法配置该客户端',
'agents.footer.installFirst': '请先安装客户端并重新检测',
'agents.footer.noCommand': '检测到配置文件,但没有找到客户端命令',
'agents.footer.invalidConfig': '原配置格式异常,无法安全修改',
'agents.launchMethods': 'Codex 启动方式',
'agents.launch.enableFirst': '请先应用配置修改',
'agents.launch.applyFirst': '请先应用当前模型选择',
'agents.launch.starting': '启动中',
'agents.launch.startCli': '启动 CLI',
'agents.launch.startApp': '启动 App',
'agents.launch.start': '启动 {target}',
'agents.launch.unavailable': '无法启动',
'agents.launch.closeChatgpt': '关闭 ChatGPT App',
@@ -580,12 +640,33 @@ export const zhCN = {
'apiAccess.defaultUrl': '使用默认地址',
'apiAccess.models.summary': '模型 {count} 个 · {models}',
'apiAccess.priorityValue': '优先级 {priority}',
'apiAccess.runtimeCredential': '运行时凭据 {index}…',
'apiAccess.enable': '启用接入',
'apiAccess.disable': '停用接入',
'apiAccess.status.disabled': '已停用',
'apiAccess.status.enabled': '已启用',
'apiAccess.toggleAria': '{remark} 接入{action}开关',
'apiAccess.health.action': '健康检测',
'apiAccess.health.title': '模型健康检测',
'apiAccess.health.description': '{provider}',
'apiAccess.health.warning': '检测会对每个模型发送一次最小流式请求,收到模型首字后立即停止,并可能产生少量费用。',
'apiAccess.health.search': '搜索模型名称或别名',
'apiAccess.health.loadingModels': '正在获取模型列表…',
'apiAccess.health.modelLoadFailed': '模型列表刷新失败:{error}',
'apiAccess.health.noMatch': '没有匹配的模型',
'apiAccess.health.notChecked': '未检测',
'apiAccess.health.checkOne': '检测',
'apiAccess.health.retryOne': '重新检测',
'apiAccess.health.checkAll': '检测全部模型',
'apiAccess.health.checkingProgress': '检测中 {checked}/{total}',
'apiAccess.health.summary': '已检测 {checked}/{total} · 健康 {healthy} · 失败 {failed}',
'apiAccess.health.checking': '检测中…',
'apiAccess.health.healthy': '健康',
'apiAccess.health.failed': '失败',
'apiAccess.health.noModel': '没有可用于健康检测的模型',
'apiAccess.health.timeout': '请求已超时',
'apiAccess.health.missingDirectKey': '缺少可直接使用的明文 API 密钥,无法测量首字延迟',
'apiAccess.health.firstTokenInline': '首字 {latency} ms',
'apiAccess.health.firstTokenLatencyResult': '首字延迟:{latency} ms',
'common.edit': '编辑',
'apiAccess.error.enterBaseUrl': '请先填写 Base URL',
'apiAccess.error.noAvailableModels': '未发现可用模型',
@@ -793,9 +874,9 @@ export const zhCN = {
'kernel.dialog.install': '内核安装',
'kernel.dialog.phase': '当前阶段',
'kernel.dialog.unknownProgress': '进度未知',
'kernel.access.openaiDescription': '适用于 OpenAI SDK、OpenCode 等兼容客户端',
'kernel.access.claudeDescription': '适用于 Claude SDK 与 Claude Code',
'kernel.access.geminiDescription': '适用于 Gemini 原生兼容客户端',
'kernel.access.openaiDescription': 'OpenAI兼容格式',
'kernel.access.claudeDescription': 'Anthropic兼容格式',
'kernel.access.geminiDescription': 'Gemini兼容格式',
'authFiles.unnamed': '未命名认证文件',
'management.notConfigured': '未配置',
'management.error.upstream': '上游请求失败',
@@ -861,7 +942,7 @@ export const en: Record<MessageKey, string> = {
'app.nav.home': 'Home',
'app.nav.versions': 'Version Management',
'app.nav.kernel': 'Core',
'app.nav.config': 'Settings',
'app.nav.config': 'Advanced Settings',
'app.nav.thinkingAliases': 'Model Aliases',
'app.nav.oauth': 'OAuth',
'app.nav.api': 'API Access',
@@ -909,6 +990,7 @@ export const en: Record<MessageKey, string> = {
'quota.confirm.expiry': 'Earliest expiry: {time}',
'quota.confirm.warning': 'The reset runs only after you confirm. Canceling will not consume a reset credit.',
'oauth.title': 'OAuth Sign-In',
'oauth.tabs.label': 'OAuth management sections',
'oauth.loginSuccess': '{provider} sign-in succeeded',
'oauth.priorityApplyFailed': 'OAuth sign-in succeeded, but the new credential priority could not be set to 0: {error}',
'oauth.authFailed': 'Authentication failed',
@@ -974,18 +1056,18 @@ export const en: Record<MessageKey, string> = {
'usage.filter.to': 'to',
'usage.loading': 'Loading local SQLite usage history',
'usage.stat.requests': 'Total Requests',
'usage.stat.requestMeta': '{success} succeeded · {failed} failed',
'usage.stat.requestMeta': 'OK {success} · Fail {failed}',
'usage.stat.tokens': 'Total Tokens',
'usage.stat.tokenMeta': 'Input {input} · Output {output}',
'usage.stat.tokenMeta': 'In {input} · Out {output}',
'usage.stat.successRate': 'Success Rate',
'usage.stat.reasoningMeta': 'Reasoning {tokens} tokens',
'usage.stat.reasoningMeta': 'Reasoning {tokens}',
'usage.stat.averageLatency': 'Average Latency',
'usage.stat.tps': 'TPS',
'usage.stat.performanceMeta': '{rpm} RPM · Avg latency {latency} ms',
'usage.stat.performanceMeta': 'RPM {rpm} · {latency} ms',
'usage.stat.cacheHitRate': 'Cache Hit Rate',
'usage.stat.cacheHitMeta': '{hit} hit / {input} input tokens',
'usage.stat.cacheHitMeta': 'Cache {hit}/{input}',
'usage.stat.estimatedCost': 'Estimated Cost',
'usage.stat.costMeta': '{priced} / {total} requests priced · Estimate only',
'usage.stat.costMeta': 'Priced {priced}/{total} · Est.',
'usage.pricing.coverage': '{priced} / {total} requests priced · {saved} model prices available',
'usage.pricing.search': 'Search model',
'usage.pricing.add': 'Add Manually',
@@ -1052,11 +1134,14 @@ export const en: Record<MessageKey, string> = {
'aliases.error.selectModel': 'Select an original model first',
'aliases.error.emptyAlias': 'The model alias cannot be empty',
'aliases.error.emptyEffort': 'The reasoning effort cannot be empty',
'aliases.error.emptyOptions': 'Set a reasoning effort or enable Fast',
'aliases.error.unsupportedEffort': 'This model does not support a reasoning-effort override; only Fast can be used',
'aliases.created': 'Created {alias} with reasoning effort fixed to {effort}',
'aliases.deleteConfirm': 'Delete the model alias “{alias}”?\nIts reasoning-effort override will also be removed.',
'aliases.createdCombined': 'Created {alias} with reasoning effort {effort} and Fast enabled',
'aliases.deleteConfirm': 'Delete the model alias “{alias}”?\nIts reasoning-effort and Fast overrides will also be removed.',
'aliases.deleted': 'Deleted {alias}',
'aliases.create.title': 'Create Reasoning Alias',
'aliases.create.description': 'Supports Codex OAuth, Codex API, and OpenAI-compatible connections',
'aliases.create.title': 'Create Model Alias',
'aliases.create.description': 'Reasoning effort and the Fast service tier can be configured independently or together',
'aliases.originalModel': 'Original Model',
'aliases.loadingModels': 'Loading models',
'aliases.searchModel': 'Search and select an original model',
@@ -1066,7 +1151,14 @@ export const en: Record<MessageKey, string> = {
'aliases.sourceLabel': 'Source: {source}',
'aliases.sourceHint': 'Models with the same name are listed separately for Codex OAuth, Codex API, and OpenAI-compatible sources',
'aliases.effort.title': 'Reasoning Effort',
'aliases.effort.description': 'Choose a common level or enter a custom value supported by the upstream provider',
'aliases.effort.description': 'Configure it independently or use it together with Fast',
'aliases.effort.none': 'Not set',
'aliases.effort.noneHint': 'Do not override reasoning effort; use another enabled option only',
'aliases.effort.unsupported': 'This model has no available reasoning efforts, so only Fast has been enabled.',
'aliases.fast.title': 'Speed Option',
'aliases.fast.description': 'Always use the Fast request tier (service_tier=priority)',
'aliases.fast.enabled': 'Enabled; works together with reasoning effort',
'aliases.fast.disabled': 'Disabled',
'aliases.customLevel': 'Custom level: {effort}',
'aliases.customLevelHint': 'Enter another reasoning level supported by the model provider',
'aliases.custom': 'Custom',
@@ -1083,14 +1175,23 @@ export const en: Record<MessageKey, string> = {
'aliases.creating': 'Creating',
'aliases.create': 'Create Alias',
'aliases.createdList.title': 'Created Aliases',
'aliases.createdList.description': 'Each alias retains its original model, source, and fixed reasoning effort',
'aliases.createdList.description': 'Each alias shows its reasoning effort and Fast status together',
'aliases.loadingConfig': 'Loading configuration',
'aliases.empty.title': 'No reasoning aliases yet',
'aliases.empty.description': 'Select a model and effort on the left to create one',
'aliases.empty.title': 'No model aliases yet',
'aliases.empty.description': 'Select a model and alias option on the left to create one',
'aliases.unboundEffort': 'No effort bound',
'aliases.delete': 'Delete {alias}',
'speedAliases.created': 'Created {alias} with the Fast service tier fixed',
'speedAliases.deleted': 'Deleted {alias}',
'speedAliases.deleteConfirm': 'Delete the speed alias “{alias}”?\nIts Fast service-tier override will also be removed.',
'speedAliases.fast.title': 'Fast',
'speedAliases.aliasName.example': 'For example: {model}-fast',
'speedAliases.delete': 'Delete {alias}',
'config.routing.roundRobin': 'Round Robin',
'config.routing.fillFirst': 'Fill First',
'config.tabs.label': 'Advanced settings sections',
'config.tabs.general': 'General Settings',
'config.tabs.network': 'Network & Routing',
'config.error.emptyKey': 'The authentication key cannot be empty',
'config.error.invalidKey': 'Use visible ASCII characters only, without spaces',
'config.error.duplicateKey': 'This authentication key already exists',
@@ -1103,8 +1204,6 @@ export const en: Record<MessageKey, string> = {
'config.notice.keyDeleted': 'Authentication key deleted',
'config.notice.keyCopied': 'Authentication key copied',
'config.notice.keyCopyFailed': 'Failed to copy the authentication key',
'config.notice.pluginsEnabled': 'Plugin system enabled',
'config.notice.pluginsDisabled': 'Plugin system disabled',
'config.notice.routingUpdated': 'Routing strategy updated',
'config.notice.networkUpdated': 'Network and session routing settings saved',
'config.notice.networkRestarted': 'Network settings saved and the core restarted',
@@ -1113,6 +1212,26 @@ export const en: Record<MessageKey, string> = {
'config.notice.sessionAffinityEnabled': 'Session affinity enabled',
'config.notice.sessionAffinityDisabled': 'Session affinity disabled',
'config.notice.sessionTtlUpdated': 'Session affinity TTL saved',
'config.webuiKey.title': 'WebUI Key',
'config.webuiKey.configured': 'Configured',
'config.webuiKey.unconfigured': 'Not configured',
'config.webuiKey.heading': 'Management access credential',
'config.webuiKey.description': 'Used to sign in to the CPA WebUI and call the Management API. It is different from the API authentication keys above.',
'config.webuiKey.securityHint': 'The current key is never displayed. Other WebUIs must sign in again with the new key after it is saved.',
'config.webuiKey.newKey': 'New WebUI key',
'config.webuiKey.confirmKey': 'Confirm new key',
'config.webuiKey.placeholder': 'Enter a new plaintext key',
'config.webuiKey.confirmPlaceholder': 'Enter the new key again',
'config.webuiKey.generate': 'Generate secure key',
'config.webuiKey.save': 'Save key',
'config.webuiKey.open': 'Open WebUI',
'config.webuiKey.error.empty': 'The WebUI key cannot be empty',
'config.webuiKey.error.legacyDefault': 'The legacy default key 123456 is no longer allowed',
'config.webuiKey.error.tooLong': 'The WebUI key cannot exceed 512 characters',
'config.webuiKey.error.invalid': 'The WebUI key cannot contain control characters',
'config.webuiKey.error.mismatch': 'The WebUI keys do not match',
'config.webuiKey.error.openFailed': 'Could not open the WebUI: {error}',
'config.webuiKey.notice.updated': 'WebUI key updated',
'config.keys.title': 'Authentication Keys',
'config.keys.count': 'Key count',
'config.keys.add': 'Add Authentication Key',
@@ -1126,14 +1245,14 @@ export const en: Record<MessageKey, string> = {
'config.keys.delete': 'Delete authentication key',
'config.keys.deleteNth': 'Delete authentication key {number}',
'config.keys.empty': 'No authentication keys',
'config.plugins.title': 'Plugin System',
'common.loading': 'Loading',
'common.unavailable': 'Unavailable',
'common.enabled': 'Enabled',
'common.disabled': 'Disabled',
'config.plugins.enable': 'Enable plugin system',
'config.routing.title': 'Routing Strategy',
'config.network.title': 'Network & Session Routing',
'config.network.networkSection': 'Network Settings',
'config.network.routingSection': 'Session Routing',
'config.network.saving': 'Saving',
'config.network.saved': 'Saved',
'config.network.unsaved': 'Unsaved changes',
@@ -1251,13 +1370,13 @@ export const en: Record<MessageKey, string> = {
'agents.model.loading': 'Loading models',
'agents.model.loadFailed': 'Failed to load models',
'agents.model.select': 'Select a model',
'agents.model.none': 'No models available',
'agents.model.none': 'Model list is empty',
'agents.model.search': 'Search model names or aliases',
'agents.model.clearSearch': 'Clear search',
'agents.model.refresh': 'Refresh models',
'agents.model.fetching': 'Fetching models',
'agents.model.noMatch': 'No matching models',
'agents.model.unavailable': 'No models are currently available',
'agents.model.unavailable': 'Model list is empty',
'agents.model.tryKeywords': 'Try different keywords',
'agents.model.connectFirst': 'Connect an available model, then refresh',
'agents.model.available': 'Available model',
@@ -1266,6 +1385,7 @@ export const en: Record<MessageKey, string> = {
'agents.error.modelsLoading': 'The model list is still loading. Try again shortly.',
'agents.error.noModels': 'No models are available, so configuration changes cannot be applied',
'agents.error.selectionGone': 'The selected model is no longer available. Refresh and select it again.',
'agents.error.mappingSelectionGone': 'A Claude Desktop mapping is no longer available. Refresh the models and select it again.',
'agents.error.applyFirst': 'The selected model has not been applied. Click “Apply Configuration Changes” first.',
'agents.status.detecting': 'Detecting',
'agents.status.unsupported': 'Unsupported Platform',
@@ -1291,7 +1411,7 @@ export const en: Record<MessageKey, string> = {
'agents.sessions.databaseMissing': 'No session database found',
'agents.sessions.repairTitle': 'Restore Historical Sessions',
'agents.sessions.repairDescription': 'Sync sessions from other providers to the current session provider.',
'agents.sessions.autoRepair': 'Restore historical sessions before launching Codex',
'agents.sessions.autoRepair': 'Automatically restore historical sessions',
'agents.sessions.settingLoading': 'Loading',
'agents.sessions.settingOn': 'On',
'agents.sessions.settingOff': 'Off',
@@ -1356,27 +1476,48 @@ export const en: Record<MessageKey, string> = {
'agents.notFetched': 'Not available',
'agents.useModel': 'Model',
'agents.pending': 'Not Applied',
'agents.claudeDesktopMapping.title': 'Claude Desktop Model → Upstream Model',
'agents.claudeDesktopMapping.description': 'Choose the upstream model each Claude Desktop model actually calls.',
'agents.claudeCodeMapping.title': 'Claude Code Model → Upstream Model',
'agents.claudeCodeMapping.description': 'Choose the upstream model injected at launch for each task role.',
'agents.claudeDesktopMapping.customMapping': 'Custom Mapping',
'agents.claudeDesktopMapping.customMappingHint': 'When enabled, only aliased models are shown; when disabled, only original models without aliases are shown.',
'agents.claudeDesktopMapping.opus': 'Map Claude Opus 5 to',
'agents.claudeDesktopMapping.sonnet': 'Map Claude Sonnet 4.6 to',
'agents.claudeDesktopMapping.haiku': 'Map Claude Haiku 4.5 to',
'agents.claudeCodeLaunch.noDirectory': 'Not selected',
'agents.claudeCodeLaunch.chooseDirectory': 'Choose Directory',
'agents.claudeCodeLaunch.selectedDirectory': 'Selected working directory',
'agents.claudeCodeLaunch.choosing': 'Choosing',
'agents.claudeCodeLaunch.dialogTitle': 'Choose a Claude Code Working Directory',
'agents.claudeCodeLaunch.dialogDescription': 'Select a working directory before setting the environment variables and starting Claude Code.',
'agents.claudeCodeLaunch.workingDirectory': 'Working directory',
'agents.claudeCodeLaunch.browse': 'Browse',
'agents.claudeCodeLaunch.neverAskAgain': 'Never show this prompt again',
'agents.claudeCodeLaunch.directoryRequired': 'Select a working directory first',
'agents.claudeCodeLaunch.launch': 'Start Claude Code',
'agents.model.readingAvailable': 'Loading available models',
'agents.model.current': 'Currently applied: {model}',
'agents.model.firstSelection': '{count} models available; the first is selected by default',
'agents.modify.title': 'Configuration Changes',
'agents.modify.oauthConfiguration': 'OAuth configuration mode',
'agents.oauthLoginRequired.title': 'Codex sign-in required',
'agents.oauthLoginRequired.enableDescription': 'No valid Codex sign-in credentials were found, so OAuth configuration mode cannot be enabled. Sign in to your ChatGPT account in the Codex app. If the Codex app cannot trigger sign-in, use',
'agents.oauthLoginRequired.enableClearConfiguration': 'Clear Configuration',
'agents.oauthLoginRequired.enableDescriptionSuffix': 'first.',
'agents.oauthLoginRequired.applyDescription': 'OAuth configuration cannot be applied because no valid Codex sign-in credentials were found. Sign in to your ChatGPT account in Codex, then apply the configuration.',
'agents.oauthLoginRequired.launchDescription': 'OAuth configuration mode is enabled, but no valid Codex sign-in credentials were found. Sign in to your ChatGPT account in Codex before launching it.',
'agents.oauthLoginRequired.confirm': 'Got it',
'agents.modify.apply': 'Apply Configuration Changes',
'agents.modify.close': 'Close Configuration Changes',
'agents.modify.default': 'Default Configuration',
'agents.modify.clear': 'Clear Configuration',
'agents.modify.invalid': 'The current configuration cannot be parsed. Apply Configuration Changes rebuilds a minimal valid configuration; Default Configuration resets the whole file.',
'agents.modify.applied': 'The fields managed by the app are applied; all other user content remains unchanged.',
'agents.footer.changed': 'The model selection changed. Apply the configuration to use the new model.',
'agents.footer.applied': 'Configuration changes are applied; only the relevant fields are managed by the app.',
'agents.footer.invalidManaged': 'The configuration is invalid. Apply changes to repair it or reset it to the default configuration.',
'agents.footer.unsupported': 'This client cannot be configured on the current system',
'agents.footer.installFirst': 'Install the client, then detect again',
'agents.footer.noCommand': 'A configuration file was found, but no client command is available',
'agents.footer.invalidConfig': 'The original configuration is invalid and cannot be modified safely',
'agents.launchMethods': 'Codex launch methods',
'agents.launch.enableFirst': 'Apply the configuration changes first',
'agents.launch.applyFirst': 'Apply the selected model first',
'agents.launch.starting': 'Starting',
'agents.launch.startCli': 'Start CLI',
'agents.launch.startApp': 'Start App',
'agents.launch.start': 'Start {target}',
'agents.launch.unavailable': 'Cannot Start',
'agents.launch.closeChatgpt': 'Close ChatGPT App',
@@ -1427,12 +1568,33 @@ export const en: Record<MessageKey, string> = {
'apiAccess.defaultUrl': 'Using default address',
'apiAccess.models.summary': '{count} models · {models}',
'apiAccess.priorityValue': 'Priority {priority}',
'apiAccess.runtimeCredential': 'Runtime credential {index}…',
'apiAccess.enable': 'Enable connection',
'apiAccess.disable': 'Disable connection',
'apiAccess.status.disabled': 'Disabled',
'apiAccess.status.enabled': 'Enabled',
'apiAccess.toggleAria': '{remark} connection {action} switch',
'apiAccess.health.action': 'Health Check',
'apiAccess.health.title': 'Model Health Check',
'apiAccess.health.description': '{provider}',
'apiAccess.health.warning': 'The check sends one minimal streaming request to each model, stops at the first output token, and may incur a small charge.',
'apiAccess.health.search': 'Search model names or aliases',
'apiAccess.health.loadingModels': 'Loading the model list…',
'apiAccess.health.modelLoadFailed': 'Could not refresh the model list: {error}',
'apiAccess.health.noMatch': 'No matching models',
'apiAccess.health.notChecked': 'Not checked',
'apiAccess.health.checkOne': 'Check',
'apiAccess.health.retryOne': 'Check Again',
'apiAccess.health.checkAll': 'Check All Models',
'apiAccess.health.checkingProgress': 'Checking {checked}/{total}',
'apiAccess.health.summary': 'Checked {checked}/{total} · Healthy {healthy} · Failed {failed}',
'apiAccess.health.checking': 'Checking…',
'apiAccess.health.healthy': 'Healthy',
'apiAccess.health.failed': 'Failed',
'apiAccess.health.noModel': 'No model is available for a health check',
'apiAccess.health.timeout': 'The request timed out',
'apiAccess.health.missingDirectKey': 'A directly usable plaintext API key is required to measure first-token latency',
'apiAccess.health.firstTokenInline': 'TTFT {latency} ms',
'apiAccess.health.firstTokenLatencyResult': 'Time to first token: {latency} ms',
'common.edit': 'Edit',
'apiAccess.error.enterBaseUrl': 'Enter a Base URL first',
'apiAccess.error.noAvailableModels': 'No available models found',
@@ -1640,9 +1802,9 @@ export const en: Record<MessageKey, string> = {
'kernel.dialog.install': 'Core Installation',
'kernel.dialog.phase': 'Current Phase',
'kernel.dialog.unknownProgress': 'Progress Unknown',
'kernel.access.openaiDescription': 'For OpenAI SDK, OpenCode, and other compatible clients',
'kernel.access.claudeDescription': 'For Claude SDK and Claude Code',
'kernel.access.geminiDescription': 'For native Gemini-compatible clients',
'kernel.access.openaiDescription': 'OpenAI-compatible format',
'kernel.access.claudeDescription': 'Anthropic-compatible format',
'kernel.access.geminiDescription': 'Gemini-compatible format',
'authFiles.unnamed': 'Unnamed credential file',
'management.notConfigured': 'Not configured',
'management.error.upstream': 'Upstream request failed',
+3
View File
@@ -1,4 +1,7 @@
const phraseReplacements: ReadonlyArray<readonly [string, string]> = [
['兼容格式', '相容格式'],
['历史', '歷史'],
['高级设置', '進階設定'],
['繁体中文', '繁體中文'],
['简体中文', '簡體中文'],
['智能体', '智慧代理'],
+7
View File
@@ -0,0 +1,7 @@
export const oauthSubpages = [
{ id: 'login', labelKey: 'oauth.title' },
{ id: 'authFiles', labelKey: 'authFiles.title' },
{ id: 'quota', labelKey: 'quota.title' },
] as const;
export type OAuthSubpage = (typeof oauthSubpages)[number]['id'];
+575 -146
View File
@@ -10,8 +10,10 @@ import {
} from 'react';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { open } from '@tauri-apps/plugin-dialog';
import {
AlertTriangle,
AppWindow,
BadgeCheck,
Bot,
Check,
@@ -21,7 +23,7 @@ import {
Power,
RefreshCw,
Search,
Sparkles,
Terminal,
Trash2,
X,
} from 'lucide-react';
@@ -33,7 +35,9 @@ import opencodeIcon from '../assets/icons/opencode.svg';
import {
agentModelAlias,
filterAgentModels,
filterAgentModelsByAlias,
findAgentModel,
resolveAgentModelForAliasMode,
resolveAgentModelSelection,
} from '../services/agentModelPicker';
import type { ModelOption } from '../services/modelService';
@@ -67,6 +71,10 @@ type AgentConfigStatus = {
modificationState: AgentModificationState;
backupAvailable: boolean;
appliedModel: string | null;
claudeCodeModelMappings: ClaudeModelMappings | null;
claudeDesktopModelMappings: ClaudeModelMappings | null;
claudeCodeWorkingDirectory: string | null;
claudeCodeWorkingDirectoryPromptDisabled: boolean;
warnings: string[];
error: string | null;
};
@@ -90,6 +98,42 @@ type ChatGptCloseResult = {
closedProcesses: number;
};
type OAuthLoginRequiredAction = 'enable' | 'apply' | 'launch';
type ClaudeModelMappings = {
opus: string;
sonnet: string;
haiku: string;
};
const CODEX_OAUTH_LOGIN_REQUIRED_ERROR = 'CODEX_OAUTH_LOGIN_REQUIRED';
const createClaudeModelMappings = (model: string): ClaudeModelMappings => ({
opus: model,
sonnet: model,
haiku: model,
});
const sameClaudeModelMappings = (
left: ClaudeModelMappings,
right: ClaudeModelMappings,
) => left.opus === right.opus && left.sonnet === right.sonnet && left.haiku === right.haiku;
const claudeMappingRoles = [
{
key: 'opus',
labelKey: 'agents.claudeDesktopMapping.opus',
},
{
key: 'sonnet',
labelKey: 'agents.claudeDesktopMapping.sonnet',
},
{
key: 'haiku',
labelKey: 'agents.claudeDesktopMapping.haiku',
},
] as const;
type AgentDefinition = {
id: AgentClientId;
name: string;
@@ -269,7 +313,9 @@ function AgentModelPicker({
() => visibleModels.map((model) => ({ name: model.name, alias: model.alias ?? '' })),
[visibleModels],
);
const selectedAlias = agentModelAlias(models, value);
const selectedModel = findAgentModel(models, value);
const selectedName = selectedModel?.name ?? '';
const selectedAlias = selectedName ? agentModelAlias(models, selectedName) : '';
const updateDropdownLayout = useCallback(() => {
const root = rootRef.current;
@@ -382,8 +428,8 @@ function AgentModelPicker({
}}
>
<span>
<strong title={value || undefined}>
{value || (loading ? t('agents.model.loading') : error ? t('agents.model.loadFailed') : models.length ? t('agents.model.select') : t('agents.model.none'))}
<strong title={selectedName || undefined}>
{selectedName || (loading ? t('agents.model.loading') : error ? t('agents.model.loadFailed') : models.length ? t('agents.model.select') : t('agents.model.none'))}
</strong>
{selectedAlias ? <small title={selectedAlias}>{selectedAlias}</small> : null}
</span>
@@ -481,10 +527,19 @@ export function AgentsPage() {
const [modelByClient, setModelByClient] = useState<Partial<Record<AgentClientId, string>>>(
readAgentModelSelections,
);
const [launchTargetByClient, setLaunchTargetByClient] = useState<Partial<Record<AgentClientId, string>>>({});
const [claudeModelMappingsDraft, setClaudeModelMappingsDraft] = useState<ClaudeModelMappings>(
createClaudeModelMappings(''),
);
const [claudeCustomMapping, setClaudeCustomMapping] = useState(false);
const [claudeCodeLaunchDialogOpen, setClaudeCodeLaunchDialogOpen] = useState(false);
const [claudeCodeLaunchDirectory, setClaudeCodeLaunchDirectory] = useState('');
const [claudeCodeSuppressDirectoryPrompt, setClaudeCodeSuppressDirectoryPrompt] = useState(false);
const [claudeCodeDirectoryError, setClaudeCodeDirectoryError] = useState('');
const [loading, setLoading] = useState(true);
const [modelLoading, setModelLoading] = useState(false);
const [busyAction, setBusyAction] = useState<'apply' | 'default' | 'clear' | 'launch' | 'close-app' | null>(null);
const [busyAction, setBusyAction] = useState<
'apply' | 'close-config' | 'default' | 'clear' | 'launch' | 'launch-cli' | 'launch-app' | 'close-app' | 'oauth-check' | 'directory' | null
>(null);
const busy = busyAction !== null;
const [detectionError, setDetectionError] = useState('');
const [modelError, setModelError] = useState('');
@@ -499,8 +554,10 @@ export function AgentsPage() {
const [closeAppError, setCloseAppError] = useState('');
const [closeAppNotice, setCloseAppNotice] = useState('');
const [closeAppConfirmOpen, setCloseAppConfirmOpen] = useState(false);
const [oauthLoginRequiredAction, setOauthLoginRequiredAction] = useState<OAuthLoginRequiredAction | null>(null);
const [oauthConfigurationDraft, setOauthConfigurationDraft] = useState<boolean | null>(null);
const modelRequestRef = useRef(0);
const claudeModelMappingsDirtyRef = useRef(false);
const loadStatuses = useCallback(async (forceRefresh = false) => {
const command = forceRefresh
@@ -596,22 +653,12 @@ export function AgentsPage() {
setCloseAppError('');
setCloseAppNotice('');
setCloseAppConfirmOpen(false);
setOauthLoginRequiredAction(null);
setClaudeCodeLaunchDialogOpen(false);
setClaudeCodeDirectoryError('');
claudeModelMappingsDirtyRef.current = false;
}, [selected]);
useEffect(() => {
setLaunchTargetByClient((current) => agentDefinitions.reduce<Partial<Record<AgentClientId, string>>>(
(next, definition) => {
const targets = statuses.find((status) => status.id === definition.id)?.launchTargets ?? [];
const previous = current[definition.id] ?? '';
next[definition.id] = targets.some((target) => target.id === previous)
? previous
: targets[0]?.id ?? '';
return next;
},
{},
));
}, [statuses]);
const activeDefinition = agentDefinitions.find((agent) => agent.id === selected)
?? agentDefinitions[0];
const activeStatus = statuses.find((status) => status.id === selected) ?? null;
@@ -621,33 +668,87 @@ export function AgentsPage() {
const savedSelectedModel = modelByClient[selected] ?? '';
const selectedModelOption = findAgentModel(models, savedSelectedModel);
const selectedModel = selectedModelOption?.name ?? '';
const isClaudeModelMappingClient = selected === 'claude-code' || selected === 'claude-desktop';
const claudeMappingModels = useMemo(
() => filterAgentModelsByAlias(models, claudeCustomMapping),
[claudeCustomMapping, models],
);
useEffect(() => {
if (!isClaudeModelMappingClient || !selectedModel) return;
const appliedMappings = selected === 'claude-code'
? activeStatus?.claudeCodeModelMappings
: activeStatus?.claudeDesktopModelMappings;
if (!claudeModelMappingsDirtyRef.current && appliedMappings) {
const appliedModels = claudeMappingRoles
.map((role) => findAgentModel(models, appliedMappings[role.key]))
.filter((model): model is ModelOption => model !== null);
if (appliedModels.length === claudeMappingRoles.length) {
setClaudeCustomMapping(appliedModels.every((model) => Boolean(model.isAlias)));
}
}
setClaudeModelMappingsDraft((current) => {
const source = claudeModelMappingsDirtyRef.current
? current
: appliedMappings ?? current;
const next: ClaudeModelMappings = {
opus: findAgentModel(models, source.opus)?.name ?? selectedModel,
sonnet: findAgentModel(models, source.sonnet)?.name ?? selectedModel,
haiku: findAgentModel(models, source.haiku)?.name ?? selectedModel,
};
return sameClaudeModelMappings(current, next) ? current : next;
});
}, [
activeStatus?.claudeCodeModelMappings,
activeStatus?.claudeDesktopModelMappings,
isClaudeModelMappingClient,
models,
selected,
selectedModel,
]);
const activeLaunchTargets = activeStatus?.launchTargets ?? [];
const selectedLaunchTargetId = launchTargetByClient[selected] ?? activeLaunchTargets[0]?.id ?? '';
const selectedLaunchTarget = activeLaunchTargets.find(
(target) => target.id === selectedLaunchTargetId,
) ?? activeLaunchTargets[0] ?? null;
const defaultLaunchTarget = activeLaunchTargets[0] ?? null;
const cliLaunchTarget = activeLaunchTargets.find((target) => target.id === 'cli') ?? null;
const appLaunchTarget = activeLaunchTargets.find((target) => target.id === 'app') ?? null;
const appliedModel = activeStatus?.appliedModel ?? activeStatus?.currentModel ?? '';
const modelDraftChanged = Boolean(
const modelDraftChanged = !isClaudeModelMappingClient && Boolean(
selectedModel.trim()
&& appliedModel.trim()
&& selectedModel.trim() !== appliedModel.trim(),
);
const appliedClaudeModelMappings = (selected === 'claude-code'
? activeStatus?.claudeCodeModelMappings
: activeStatus?.claudeDesktopModelMappings)
?? createClaudeModelMappings(appliedModel);
const claudeMappingsReady = !isClaudeModelMappingClient
|| claudeMappingRoles.every((role) =>
Boolean(findAgentModel(models, claudeModelMappingsDraft[role.key])),
);
const claudeMappingDraftChanged = isClaudeModelMappingClient
&& activeStatus?.modificationState === 'applied'
&& !sameClaudeModelMappings(
claudeModelMappingsDraft,
appliedClaudeModelMappings,
);
const oauthConfigurationChanged = selected === 'codex'
&& oauthConfiguration !== Boolean(activeStatus?.oauthConfiguration);
const draftChanged = modelDraftChanged || oauthConfigurationChanged;
const draftChanged = modelDraftChanged || claudeMappingDraftChanged || oauthConfigurationChanged;
const canEnable = Boolean(
activeStatus?.supportedPlatform
&& activeStatus.installed
&& !modelLoading
&& selectedModelOption,
&& (isClaudeModelMappingClient
? claudeMappingsReady
: selectedModelOption),
);
const canLaunch = Boolean(
const launchEnabled = Boolean(
activeStatus?.supportedPlatform
&& activeStatus.installed
&& selectedLaunchTarget
&& (selected === 'codex'
|| (activeStatus.modificationEnabled && activeStatus.modificationState === 'applied')),
);
const canLaunchTarget = (target: AgentLaunchTarget | null) => launchEnabled && Boolean(target);
const modelHint = modelSelectionError
|| modelError
|| (modelLoading
@@ -659,23 +760,7 @@ export function AgentsPage() {
: t('agents.model.firstSelection', { count: models.length }));
const modificationDescription = activeStatus?.modificationState === 'invalid'
? t('agents.modify.invalid')
: activeStatus?.modificationState === 'applied'
? t('agents.modify.applied')
: '';
const footerMessage = activeStatus?.modificationState === 'applied'
? draftChanged
? t('agents.footer.changed')
: t('agents.footer.applied')
: activeStatus?.modificationState === 'invalid'
? t('agents.footer.invalidManaged')
: !activeStatus?.supportedPlatform
? t('agents.footer.unsupported')
: !activeStatus.installed
? t('agents.footer.installFirst')
: activeStatus.launchTargets.length === 0
? t('agents.footer.noCommand')
: '';
: '';
const refreshModels = () => {
void loadModels(selected);
};
@@ -700,6 +785,33 @@ export function AgentsPage() {
});
};
const selectClaudeModelMapping = (
role: keyof ClaudeModelMappings,
value: string,
) => {
const model = findAgentModel(models, value);
if (!model) return;
claudeModelMappingsDirtyRef.current = true;
setModelSelectionError('');
setClaudeModelMappingsDraft((current) => ({ ...current, [role]: model.name }));
};
const changeClaudeCustomMapping = (enabled: boolean) => {
setClaudeCustomMapping(enabled);
setModelSelectionError('');
setClaudeModelMappingsDraft((current) => {
const next: ClaudeModelMappings = {
opus: resolveAgentModelForAliasMode(models, current.opus, enabled),
sonnet: resolveAgentModelForAliasMode(models, current.sonnet, enabled),
haiku: resolveAgentModelForAliasMode(models, current.haiku, enabled),
};
if (!sameClaudeModelMappings(current, next)) {
claudeModelMappingsDirtyRef.current = true;
}
return next;
});
};
const requireSelectedModel = () => {
if (modelLoading) {
setModelSelectionError(t('agents.error.modelsLoading'));
@@ -718,9 +830,55 @@ export function AgentsPage() {
return model.name;
};
const requireClaudeModelMappings = (): ClaudeModelMappings | null => {
if (!isClaudeModelMappingClient) return null;
const resolved = {} as ClaudeModelMappings;
for (const role of claudeMappingRoles) {
const model = findAgentModel(models, claudeModelMappingsDraft[role.key]);
if (!model) {
setModelSelectionError(t('agents.error.mappingSelectionGone'));
return null;
}
resolved[role.key] = model.name;
}
return resolved;
};
const handleOAuthLoginError = (requestError: unknown, action: OAuthLoginRequiredAction) => {
const message = String(requestError);
if (message.includes(CODEX_OAUTH_LOGIN_REQUIRED_ERROR)) {
setOauthLoginRequiredAction(action);
return true;
}
return false;
};
const changeOauthConfiguration = async (enabled: boolean) => {
if (!enabled) {
setOauthConfigurationDraft(false);
return;
}
setBusyAction('oauth-check');
try {
await invoke('check_codex_oauth_login');
setOauthConfigurationDraft(true);
} catch (requestError) {
if (!handleOAuthLoginError(requestError, 'enable')) {
setConfigurationError(String(requestError));
}
} finally {
setBusyAction(null);
}
};
const applyConfigurationChanges = async () => {
setConfigurationError('');
const model = requireSelectedModel();
const claudeModelMappings = requireClaudeModelMappings();
if (isClaudeModelMappingClient && !claudeModelMappings) return;
const model = isClaudeModelMappingClient
? claudeModelMappings?.sonnet ?? null
: requireSelectedModel();
if (!model) return;
setBusyAction('apply');
try {
@@ -728,9 +886,28 @@ export function AgentsPage() {
client: selected,
model,
oauthConfiguration,
claudeCodeModelMappings: selected === 'claude-code' ? claudeModelMappings : null,
claudeDesktopModelMappings: selected === 'claude-desktop' ? claudeModelMappings : null,
});
await reloadStatusesAfterAction();
setOauthConfigurationDraft(null);
claudeModelMappingsDirtyRef.current = false;
} catch (requestError) {
if (!handleOAuthLoginError(requestError, 'apply')) {
setConfigurationError(String(requestError));
}
} finally {
setBusyAction(null);
}
};
const closeConfigurationChanges = async () => {
setConfigurationError('');
setBusyAction('close-config');
try {
await invoke<AgentConfigActionResult>('close_agent_config_modification', { client: selected });
await reloadStatusesAfterAction();
setOauthConfigurationDraft(null);
} catch (requestError) {
setConfigurationError(String(requestError));
} finally {
@@ -739,7 +916,11 @@ export function AgentsPage() {
};
const resetConfigurationToDefault = async () => {
const model = requireSelectedModel();
const claudeModelMappings = requireClaudeModelMappings();
if (isClaudeModelMappingClient && !claudeModelMappings) return;
const model = isClaudeModelMappingClient
? claudeModelMappings?.sonnet ?? null
: requireSelectedModel();
if (!model) return;
setBusyAction('default');
setDefaultError('');
@@ -748,12 +929,17 @@ export function AgentsPage() {
client: selected,
model,
oauthConfiguration,
claudeCodeModelMappings: selected === 'claude-code' ? claudeModelMappings : null,
claudeDesktopModelMappings: selected === 'claude-desktop' ? claudeModelMappings : null,
});
setDefaultConfirmOpen(false);
await reloadStatusesAfterAction();
setOauthConfigurationDraft(null);
claudeModelMappingsDirtyRef.current = false;
} catch (requestError) {
setDefaultError(String(requestError));
if (!handleOAuthLoginError(requestError, 'apply')) {
setDefaultError(String(requestError));
}
} finally {
setBusyAction(null);
}
@@ -776,21 +962,104 @@ export function AgentsPage() {
}
};
const launchAgent = async () => {
setBusyAction('launch');
setLaunchError('');
const openClaudeCodeLaunchDialog = () => {
setClaudeCodeLaunchDirectory(activeStatus?.claudeCodeWorkingDirectory ?? '');
setClaudeCodeSuppressDirectoryPrompt(false);
setClaudeCodeDirectoryError('');
setClaudeCodeLaunchDialogOpen(true);
};
const chooseClaudeCodeWorkingDirectory = async () => {
setBusyAction('directory');
setClaudeCodeDirectoryError('');
try {
if (selected !== 'codex' && draftChanged) {
throw new Error(t('agents.error.applyFirst'));
const selectedDirectory = await open({
directory: true,
multiple: false,
defaultPath: claudeCodeLaunchDirectory || activeStatus?.claudeCodeWorkingDirectory || undefined,
title: t('agents.claudeCodeLaunch.dialogTitle'),
});
if (typeof selectedDirectory === 'string') {
setClaudeCodeLaunchDirectory(selectedDirectory);
}
await invoke('launch_agent', { client: selected, target: selectedLaunchTarget?.id });
} catch (requestError) {
setLaunchError(String(requestError));
setClaudeCodeDirectoryError(String(requestError));
} finally {
setBusyAction(null);
}
};
const invokeAgentLaunch = async (
target: AgentLaunchTarget,
workingDirectory: string | null = null,
suppressWorkingDirectoryPrompt: boolean | null = null,
) => {
if (!target) return;
const launchAction = selected === 'codex'
? target.id === 'cli' ? 'launch-cli' : 'launch-app'
: 'launch';
setBusyAction(launchAction);
setLaunchError('');
try {
if (draftChanged) {
throw new Error(t('agents.error.applyFirst'));
}
await invoke('launch_agent', {
client: selected,
target: target.id,
workingDirectory,
suppressWorkingDirectoryPrompt,
});
if (selected === 'claude-code' && workingDirectory) {
setClaudeCodeLaunchDialogOpen(false);
await reloadStatusesAfterAction();
}
} catch (requestError) {
const message = String(requestError);
if (
selected === 'claude-code'
&& (message.includes('CLAUDE_CODE_WORKING_DIRECTORY_REQUIRED')
|| message.includes('CLAUDE_CODE_WORKING_DIRECTORY_INVALID'))
) {
openClaudeCodeLaunchDialog();
setClaudeCodeDirectoryError(
message.includes('CLAUDE_CODE_WORKING_DIRECTORY_INVALID') ? message : '',
);
} else if (selected === 'claude-code' && workingDirectory) {
setClaudeCodeDirectoryError(message);
} else if (!handleOAuthLoginError(requestError, 'launch')) {
setLaunchError(message);
}
} finally {
setBusyAction(null);
}
};
const launchAgent = async (target: AgentLaunchTarget | null) => {
if (!target) return;
if (selected === 'claude-code' && !activeStatus?.claudeCodeWorkingDirectoryPromptDisabled) {
openClaudeCodeLaunchDialog();
return;
}
await invokeAgentLaunch(target);
};
const launchClaudeCodeFromDialog = async () => {
const target = defaultLaunchTarget;
if (!target) return;
const workingDirectory = claudeCodeLaunchDirectory.trim();
if (!workingDirectory) {
setClaudeCodeDirectoryError(t('agents.claudeCodeLaunch.directoryRequired'));
return;
}
setClaudeCodeDirectoryError('');
await invokeAgentLaunch(
target,
workingDirectory,
claudeCodeSuppressDirectoryPrompt,
);
};
const closeChatGptApp = async () => {
setBusyAction('close-app');
setCloseAppError('');
@@ -841,6 +1110,13 @@ export function AgentsPage() {
const availableSubpages = agentSubpages.filter(
(subpage) => !subpage.clients || subpage.clients.includes(selected),
);
const oauthLoginRequiredDescription = oauthLoginRequiredAction === 'enable' ? (
<>
{t('agents.oauthLoginRequired.enableDescription')}
<strong>{t('agents.oauthLoginRequired.enableClearConfiguration')}</strong>
{t('agents.oauthLoginRequired.enableDescriptionSuffix')}
</>
) : oauthLoginRequiredAction ? t(`agents.oauthLoginRequired.${oauthLoginRequiredAction}Description`) : '';
return (
<section className="page management-page agents-page">
@@ -941,30 +1217,81 @@ export function AgentsPage() {
</div>
) : null}
<section className="agent-core-setting-section agent-model-section">
<div className="agent-section-heading">
<div><strong>{t('agents.useModel')}</strong></div>
{modelDraftChanged ? <span className="agent-pending-badge">{t('agents.pending')}</span> : null}
</div>
<AgentModelPicker
models={models}
value={selectedModel}
loading={modelLoading}
error={modelError}
disabled={busy || !activeStatus?.installed || !activeStatus.supportedPlatform}
onChange={selectModel}
onRefresh={refreshModels}
/>
{modelHint ? (
<span
className={`agent-model-hint ${modelSelectionError || modelError ? 'error' : ''}`}
role={modelSelectionError || modelError ? 'alert' : undefined}
aria-live="polite"
>
{modelHint}
</span>
) : null}
</section>
{!isClaudeModelMappingClient ? (
<section className="agent-core-setting-section agent-model-section">
<div className="agent-section-heading">
<div><strong>{t('agents.useModel')}</strong></div>
{modelDraftChanged ? <span className="agent-pending-badge">{t('agents.pending')}</span> : null}
</div>
<AgentModelPicker
models={models}
value={selectedModel}
loading={modelLoading}
error={modelError}
disabled={busy || !activeStatus?.installed || !activeStatus.supportedPlatform}
onChange={selectModel}
onRefresh={refreshModels}
/>
{modelHint ? (
<span
className={`agent-model-hint ${modelSelectionError || modelError ? 'error' : ''}`}
role={modelSelectionError || modelError ? 'alert' : undefined}
aria-live="polite"
>
{modelHint}
</span>
) : null}
</section>
) : null}
{isClaudeModelMappingClient ? (
<section className="agent-core-setting-section agent-claude-desktop-mapping">
<div className="agent-section-heading">
<div>
<strong>{t(selected === 'claude-code'
? 'agents.claudeCodeMapping.title'
: 'agents.claudeDesktopMapping.title')}</strong>
<span>{t(selected === 'claude-code'
? 'agents.claudeCodeMapping.description'
: 'agents.claudeDesktopMapping.description')}</span>
</div>
<div className="agent-section-heading-actions">
<label
className="agent-claude-desktop-mapping-filter"
title={t('agents.claudeDesktopMapping.customMappingHint')}
>
<span>{t('agents.claudeDesktopMapping.customMapping')}</span>
<span className="switch-control">
<input
type="checkbox"
checked={claudeCustomMapping}
onChange={(event) => changeClaudeCustomMapping(event.currentTarget.checked)}
disabled={busy || modelLoading}
/>
<span className="switch-track" />
</span>
</label>
{claudeMappingDraftChanged ? <span className="agent-pending-badge">{t('agents.pending')}</span> : null}
</div>
</div>
<div className="agent-claude-desktop-mapping-grid">
{claudeMappingRoles.map((role) => (
<div className="agent-claude-desktop-mapping-row" key={role.key}>
<strong>{t(role.labelKey)}</strong>
<AgentModelPicker
models={claudeMappingModels}
value={claudeModelMappingsDraft[role.key]}
loading={modelLoading}
error={modelError}
disabled={busy || !activeStatus?.installed || !activeStatus.supportedPlatform}
onChange={(value) => selectClaudeModelMapping(role.key, value)}
onRefresh={refreshModels}
/>
</div>
))}
</div>
</section>
) : null}
<section className={`agent-core-setting-section agent-modification-actions ${activeStatus?.modificationState === 'applied' ? 'enabled' : ''}`}>
<div className="agent-section-heading">
@@ -975,36 +1302,45 @@ export function AgentsPage() {
</div>
<div className="agent-modification-control">
{selected === 'codex' ? (
<label
className="agent-oauth-configuration"
title={t('agents.modify.oauthConfiguration')}
>
<span>{t('agents.modify.oauthConfiguration')}</span>
<span className="switch-control">
<input
type="checkbox"
role="switch"
checked={oauthConfiguration}
onChange={(event) => setOauthConfigurationDraft(event.currentTarget.checked)}
disabled={busy}
aria-label={t('agents.modify.oauthConfiguration')}
/>
<span className="switch-track" />
</span>
</label>
<div className="agent-codex-options">
<CodexSessionAutoRestoreCard />
<label
className="agent-oauth-configuration"
title={t('agents.modify.oauthConfiguration')}
>
<span>{t('agents.modify.oauthConfiguration')}</span>
<span className="switch-control">
<input
type="checkbox"
role="switch"
checked={oauthConfiguration}
onChange={(event) => void changeOauthConfiguration(event.currentTarget.checked)}
disabled={busy}
aria-label={t('agents.modify.oauthConfiguration')}
/>
<span className="switch-track" />
</span>
</label>
</div>
) : null}
<div className={`agent-modification-buttons ${selected === 'codex' ? 'codex' : ''}`}>
<button
type="button"
className="primary-button"
onClick={() => void applyConfigurationChanges()}
onClick={() => void (activeStatus?.modificationState === 'applied'
? closeConfigurationChanges()
: applyConfigurationChanges())}
disabled={
busy
|| !canEnable
|| (activeStatus?.modificationState === 'applied' ? false : !canEnable)
}
>
{busyAction === 'apply' ? <LoaderCircle size={16} className="spin" /> : <Sparkles size={16} />}
{t('agents.modify.apply')}
{busyAction === 'apply' || busyAction === 'close-config'
? <LoaderCircle size={16} className="spin" />
: null}
{activeStatus?.modificationState === 'applied'
? t('agents.modify.close')
: t('agents.modify.apply')}
</button>
<button
type="button"
@@ -1012,7 +1348,9 @@ export function AgentsPage() {
onClick={openDefaultConfirmation}
disabled={busy || !canEnable}
>
{busyAction === 'default' ? <LoaderCircle size={16} className="spin" /> : <RefreshCw size={16} />}
{busyAction === 'default'
? <LoaderCircle size={16} className="spin" />
: <RefreshCw size={16} />}
{t('agents.modify.default')}
</button>
{selected === 'codex' ? (
@@ -1040,56 +1378,54 @@ export function AgentsPage() {
</div>
</section>
{selected === 'codex' ? <CodexSessionAutoRestoreCard /> : null}
<div className="agent-config-footer">
{footerMessage ? (
<div className="agent-config-summary">
{activeStatus?.modificationState === 'applied' ? <Check size={16} /> : <Sparkles size={16} />}
<span>{footerMessage}</span>
</div>
) : null}
<div className="agent-launch-control">
<div className="agent-launch-actions">
{activeLaunchTargets.length > 1 ? (
<div className="agent-launch-targets" aria-label={t('agents.launchMethods')}>
{activeLaunchTargets.map((target) => (
<button
type="button"
className={target.id === selectedLaunchTarget?.id ? 'active' : ''}
key={target.id}
onClick={() => setLaunchTargetByClient((current) => ({
...current,
[selected]: target.id,
}))}
disabled={busy}
title={target.detail}
>
{target.label.replace('Codex ', '')}
</button>
))}
</div>
) : null}
<button
type="button"
className="primary-button"
onClick={() => void launchAgent()}
disabled={
busy
|| !canLaunch
|| (selected !== 'codex' && draftChanged)
}
title={selected === 'codex'
? selectedLaunchTarget?.detail
: draftChanged
{selected === 'codex' ? (
<>
<button
type="button"
className="secondary-button agent-launch-button"
onClick={() => void launchAgent(cliLaunchTarget)}
disabled={busy || !canLaunchTarget(cliLaunchTarget) || draftChanged}
title={draftChanged
? t('agents.launch.applyFirst')
: cliLaunchTarget?.detail ?? t('agents.launch.unavailable')}
>
{busyAction === 'launch-cli' ? <LoaderCircle size={16} className="spin" /> : <Terminal size={16} />}
{busyAction === 'launch-cli' ? t('agents.launch.starting') : t('agents.launch.startCli')}
</button>
<button
type="button"
className="primary-button agent-launch-button"
onClick={() => void launchAgent(appLaunchTarget)}
disabled={busy || !canLaunchTarget(appLaunchTarget) || draftChanged}
title={draftChanged
? t('agents.launch.applyFirst')
: appLaunchTarget?.detail ?? t('agents.launch.unavailable')}
>
{busyAction === 'launch-app' ? <LoaderCircle size={16} className="spin" /> : <AppWindow size={16} />}
{busyAction === 'launch-app' ? t('agents.launch.starting') : t('agents.launch.startApp')}
</button>
</>
) : (
<button
type="button"
className="primary-button agent-launch-button"
onClick={() => void launchAgent(defaultLaunchTarget)}
disabled={busy || !canLaunchTarget(defaultLaunchTarget) || draftChanged}
title={draftChanged
? t('agents.launch.applyFirst')
: activeStatus?.modificationState === 'applied'
? selectedLaunchTarget?.detail
? defaultLaunchTarget?.detail
: t('agents.launch.enableFirst')}
>
{busyAction === 'launch' ? <LoaderCircle size={16} className="spin" /> : <Play size={16} />}
{busyAction === 'launch' ? t('agents.launch.starting') : selectedLaunchTarget ? t('agents.launch.start', { target: selectedLaunchTarget.label }) : t('agents.launch.unavailable')}
</button>
>
{busyAction === 'launch'
? <LoaderCircle size={16} className="spin" />
: <Play size={16} />}
{busyAction === 'launch' ? t('agents.launch.starting') : defaultLaunchTarget ? t('agents.launch.start', { target: defaultLaunchTarget.label }) : t('agents.launch.unavailable')}
</button>
)}
{selected === 'codex' ? (
<button
type="button"
@@ -1130,6 +1466,85 @@ export function AgentsPage() {
</section>
</div>
{claudeCodeLaunchDialogOpen ? (
<div className="config-dialog-backdrop" onMouseDown={(event) => {
if (event.currentTarget === event.target && !busy) {
setClaudeCodeLaunchDialogOpen(false);
}
}}>
<section
className="config-dialog agent-claude-code-launch-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="agent-claude-code-launch-title"
>
<div className="config-dialog-heading">
<div>
<h2 id="agent-claude-code-launch-title">
{t('agents.claudeCodeLaunch.dialogTitle')}
</h2>
</div>
</div>
<p>{t('agents.claudeCodeLaunch.dialogDescription')}</p>
<div className="config-dialog-field">
<span>{t('agents.claudeCodeLaunch.workingDirectory')}</span>
<button
type="button"
className="agent-claude-code-directory-picker"
onClick={() => void chooseClaudeCodeWorkingDirectory()}
disabled={busy}
autoFocus
>
<span>
<small>{claudeCodeLaunchDirectory
? t('agents.claudeCodeLaunch.selectedDirectory')
: t('agents.claudeCodeLaunch.noDirectory')}</small>
<strong title={claudeCodeLaunchDirectory || undefined}>
{claudeCodeLaunchDirectory || t('agents.claudeCodeLaunch.chooseDirectory')}
</strong>
</span>
<b>{busyAction === 'directory'
? t('agents.claudeCodeLaunch.choosing')
: t('agents.claudeCodeLaunch.browse')}</b>
</button>
</div>
<label className="agent-claude-code-suppress-prompt">
<input
type="checkbox"
checked={claudeCodeSuppressDirectoryPrompt}
onChange={(event) => setClaudeCodeSuppressDirectoryPrompt(event.currentTarget.checked)}
disabled={busy}
/>
<span>{t('agents.claudeCodeLaunch.neverAskAgain')}</span>
</label>
{claudeCodeDirectoryError ? (
<span className="agent-inline-message error" role="alert" aria-live="polite">
{claudeCodeDirectoryError}
</span>
) : null}
<div className="config-dialog-actions two-actions">
<button
type="button"
className="secondary-button"
onClick={() => setClaudeCodeLaunchDialogOpen(false)}
disabled={busy}
>
{t('common.cancel')}
</button>
<button
type="button"
className="primary-button"
onClick={() => void launchClaudeCodeFromDialog()}
disabled={busy || !claudeCodeLaunchDirectory.trim()}
>
{busyAction === 'launch' ? <LoaderCircle size={16} className="spin" /> : null}
{t('agents.claudeCodeLaunch.launch')}
</button>
</div>
</section>
</div>
) : null}
{defaultConfirmOpen ? (
<div className="config-dialog-backdrop">
<section className="config-dialog agent-restore-dialog" role="alertdialog" aria-modal="true" aria-labelledby="agent-default-title">
@@ -1200,6 +1615,20 @@ export function AgentsPage() {
</section>
</div>
) : null}
{oauthLoginRequiredAction ? (
<div className="config-dialog-backdrop">
<section className="config-dialog agent-restore-dialog" role="alertdialog" aria-modal="true" aria-labelledby="agent-oauth-login-required-title">
<div className="config-dialog-heading">
<div><AlertTriangle size={19} /><h2 id="agent-oauth-login-required-title">{t('agents.oauthLoginRequired.title')}</h2></div>
</div>
<p>{oauthLoginRequiredDescription}</p>
<div className="config-dialog-actions single-action">
<button type="button" className="primary-button" onClick={() => setOauthLoginRequiredAction(null)}>{t('agents.oauthLoginRequired.confirm')}</button>
</div>
</section>
</div>
) : null}
</section>
);
}
+252 -4
View File
@@ -32,6 +32,14 @@ import {
type ModelOption,
type ModelProvider,
} from '../services/modelService';
import {
checkProviderModelHealth,
checkProviderModelsHealth,
mergeProviderHealthModels,
PROVIDER_HEALTH_TIMEOUT_MS,
type ProviderHealthCheckOptions,
type ProviderModelHealthResult,
} from '../services/providerHealthCheck';
import { modelMatchesRule } from '../services/oauthModels';
import { getCurrentLocale, translate, useI18n } from '../i18n';
@@ -192,6 +200,29 @@ const rowFromRecord = (
const providerRemarkIdentity = (section: ProviderSection, apiKeys: string[]) =>
`${section}\u0000${apiKeys.join('\u0000')}`;
const providerHealthIdentity = (row: ProviderRow) => [
row.section,
row.index,
row.name,
row.baseUrl,
row.authIndex,
readString(row.record, 'test-model', 'testModel'),
row.apiKeys.join('\u0000'),
row.models.map((model) => model.name).join('\u0000'),
].join('\u0001');
const providerModelType = (section: ProviderSection): ModelProvider => {
if (section === 'gemini-api-key') return 'gemini';
if (section === 'claude-api-key') return 'claude';
if (section === 'codex-api-key') return 'codex';
return 'openai';
};
const providerHeadersFromRecord = (record: Record<string, unknown>) =>
isRecord(record.headers)
? Object.fromEntries(Object.entries(record.headers).map(([key, value]) => [key, String(value)]))
: {};
export const stripResponseFields = (record: Record<string, unknown>) => {
const next = { ...record };
delete next['auth-index'];
@@ -622,6 +653,7 @@ export function ApiAccessPage() {
const [editingRow, setEditingRow] = useState<ProviderRow | null>(null);
const [dialogDraft, setDialogDraft] = useState<ProviderDraft>(emptyProviderDraft);
const [apiAccessRemarks, setApiAccessRemarks] = useState<Record<string, string>>({});
const [healthDialogRow, setHealthDialogRow] = useState<ProviderRow | null>(null);
const activeDefinition = definitionFor(activeCategory);
const activeSection = activeDefinition.section;
@@ -990,11 +1022,20 @@ export function ApiAccessPage() {
<span className="provider-row-url" title={row.baseUrl || undefined}>{row.baseUrl || t('apiAccess.defaultUrl')}</span>
{row.models.length > 0 ? <span className="provider-row-models" title={row.models.map((model) => model.name).join(', ')}>{t('apiAccess.models.summary', { count: row.models.length, models: row.models.slice(0, 3).map((model) => model.name).join(', ') })}</span> : null}
</div>
<div className="provider-row-meta">
{row.priority === null ? null : <span>{t('apiAccess.priorityValue', { priority: row.priority })}</span>}
{row.authIndex ? <span title={row.authIndex}>{t('apiAccess.runtimeCredential', { index: row.authIndex.slice(0, 8) })}</span> : null}
</div>
{row.priority === null ? null : (
<div className="provider-row-meta">
<span>{t('apiAccess.priorityValue', { priority: row.priority })}</span>
</div>
)}
<div className="provider-row-actions">
<button
type="button"
className="secondary-button provider-health-button"
onClick={() => setHealthDialogRow(row)}
disabled={busy}
>
{t('apiAccess.health.action')}
</button>
<label className="provider-enabled-control" title={row.disabled ? t('apiAccess.enable') : t('apiAccess.disable')}>
<span>{row.disabled ? t('apiAccess.status.disabled') : t('apiAccess.status.enabled')}</span>
<span className="switch-control">
@@ -1032,10 +1073,217 @@ export function ApiAccessPage() {
onSave={saveProvider}
/>
) : null}
{healthDialogRow ? (
<ProviderHealthDialog
key={providerHealthIdentity(healthDialogRow)}
row={healthDialogRow}
onClose={() => setHealthDialogRow(null)}
/>
) : null}
</section>
);
}
type ProviderHealthDialogProps = {
row: ProviderRow;
onClose: () => void;
};
type ProviderModelHealthState = { status: 'checking' } | ProviderModelHealthResult;
function ProviderHealthDialog({ row, onClose }: ProviderHealthDialogProps) {
const { t } = useI18n();
const testModel = readString(row.record, 'test-model', 'testModel');
const configuredModels = useMemo(
() => mergeProviderHealthModels([], row.models, testModel),
[row.models, testModel],
);
const [models, setModels] = useState<ModelOption[]>(configuredModels);
const [modelLoading, setModelLoading] = useState(true);
const [modelError, setModelError] = useState('');
const [search, setSearch] = useState('');
const [results, setResults] = useState<Record<string, ProviderModelHealthState>>({});
const [checkingAll, setCheckingAll] = useState(false);
const healthOptions = useMemo<ProviderHealthCheckOptions>(() => ({
provider: providerModelType(row.section),
baseUrl: row.baseUrl,
apiKeys: row.apiKeys,
authIndex: row.authIndex,
customHeaders: providerHeadersFromRecord(row.record),
timeoutMs: PROVIDER_HEALTH_TIMEOUT_MS,
}), [row]);
useEffect(() => {
let disposed = false;
setModelLoading(true);
setModelError('');
void fetchModels(
healthOptions.provider,
healthOptions.baseUrl,
row.apiKeys.find((key) => key.trim()) ?? '',
healthOptions.authIndex,
healthOptions.customHeaders,
healthOptions.timeoutMs,
).then((discovered) => {
if (!disposed) setModels(mergeProviderHealthModels(discovered, row.models, testModel));
}).catch((requestError) => {
if (!disposed) setModelError(String(requestError).replace(/^Error:\s*/i, ''));
}).finally(() => {
if (!disposed) setModelLoading(false);
});
return () => {
disposed = true;
};
}, [healthOptions, row.apiKeys, row.models, testModel]);
const visibleModels = useMemo(() => {
const query = search.trim().toLowerCase();
if (!query) return models;
return models.filter((model) =>
`${model.name} ${model.alias ?? ''}`.toLowerCase().includes(query),
);
}, [models, search]);
const resultValues = Object.values(results);
const checkedCount = resultValues.filter((result) => result.status !== 'checking').length;
const healthyCount = resultValues.filter((result) => result.status === 'healthy').length;
const failedCount = resultValues.filter((result) => result.status === 'failed').length;
const hasCheckingModel = resultValues.some((result) => result.status === 'checking');
const saveResult = (result: ProviderModelHealthResult) => {
setResults((current) => ({
...current,
[result.model.toLowerCase()]: result,
}));
};
const checkOneModel = async (model: ModelOption) => {
const key = model.name.toLowerCase();
setResults((current) => ({ ...current, [key]: { status: 'checking' } }));
saveResult(await checkProviderModelHealth(healthOptions, model.name));
};
const checkAllModels = async () => {
if (models.length === 0) return;
setCheckingAll(true);
setResults(Object.fromEntries(models.map((model) => [
model.name.toLowerCase(),
{ status: 'checking' } satisfies ProviderModelHealthState,
])));
try {
await checkProviderModelsHealth(healthOptions, models, saveResult);
} finally {
setCheckingAll(false);
}
};
const statusLabel = (state: ProviderModelHealthState | undefined) => {
if (!state) return t('apiAccess.health.notChecked');
if (state.status === 'checking') return t('apiAccess.health.checking');
return state.status === 'healthy'
? t('apiAccess.health.healthy')
: t('apiAccess.health.failed');
};
return (
<div className="model-discovery-backdrop" onMouseDown={(event) => event.currentTarget === event.target && onClose()}>
<section className="model-discovery-dialog provider-health-dialog" role="dialog" aria-modal="true" aria-labelledby="provider-health-title">
<div className="model-discovery-header">
<div>
<h2 id="provider-health-title">{t('apiAccess.health.title')}</h2>
<span>{t('apiAccess.health.description', { provider: row.remark || row.name })}</span>
</div>
<button type="button" className="icon-button quiet" onClick={onClose} title={t('common.close')}>
<X size={18} />
</button>
</div>
<div className="model-discovery-search">
<Search size={16} aria-hidden="true" />
<input value={search} onChange={(event) => setSearch(event.currentTarget.value)} placeholder={t('apiAccess.health.search')} />
</div>
<div className="provider-health-overview">
<p>{t('apiAccess.health.warning')}</p>
<span>{t('apiAccess.health.summary', {
checked: checkedCount,
total: models.length,
healthy: healthyCount,
failed: failedCount,
})}</span>
{modelError ? (
<small className="error" title={modelError}>{t('apiAccess.health.modelLoadFailed', { error: modelError })}</small>
) : modelLoading ? (
<small>{t('apiAccess.health.loadingModels')}</small>
) : null}
</div>
<div className="model-discovery-list provider-health-model-list">
{modelLoading && models.length === 0 ? (
<div className="model-discovery-message"><LoaderCircle size={20} className="spin" /><strong>{t('apiAccess.health.loadingModels')}</strong></div>
) : visibleModels.length === 0 ? (
<div className="model-discovery-message"><strong>{models.length ? t('apiAccess.health.noMatch') : t('apiAccess.health.noModel')}</strong></div>
) : visibleModels.map((model) => {
const state = results[model.name.toLowerCase()];
const checked = state && state.status !== 'checking' ? state : null;
const error = checked?.status === 'failed'
? checked.errorCode === 'missing-direct-key'
? t('apiAccess.health.missingDirectKey')
: checked.timedOut
? t('apiAccess.health.timeout')
: checked.error || t('apiAccess.health.failed')
: '';
return (
<div className={`provider-health-model-row ${checked?.status === 'failed' ? 'failed' : ''}`} key={model.name}>
<div className="provider-health-model-name">
<strong title={model.name}>{model.name}</strong>
{error
? <small className="error" title={error}>{error}</small>
: model.alias
? <small title={model.alias}>{model.alias}</small>
: null}
</div>
<span
className={`state-pill provider-health-pill ${state?.status === 'checking' ? 'checking' : checked?.status === 'healthy' ? 'success' : checked?.status === 'failed' ? 'error' : ''}`}
title={checked?.firstTokenLatencyMs === undefined ? error || undefined : t('apiAccess.health.firstTokenLatencyResult', { latency: checked.firstTokenLatencyMs })}
>
{statusLabel(state)}
{checked?.firstTokenLatencyMs === undefined
? ''
: ` · ${t('apiAccess.health.firstTokenInline', { latency: checked.firstTokenLatencyMs })}`}
</span>
<button
type="button"
className="secondary-button compact-button provider-health-single-button"
onClick={() => void checkOneModel(model)}
disabled={modelLoading || checkingAll || state?.status === 'checking'}
>
{checked ? t('apiAccess.health.retryOne') : t('apiAccess.health.checkOne')}
</button>
</div>
);
})}
</div>
<div className="model-discovery-actions provider-health-actions">
<button type="button" className="secondary-button compact-button" onClick={onClose}>{t('common.close')}</button>
<button
type="button"
className="primary-button compact-button"
onClick={() => void checkAllModels()}
disabled={modelLoading || models.length === 0 || hasCheckingModel}
>
{checkingAll
? t('apiAccess.health.checkingProgress', { checked: checkedCount, total: models.length })
: t('apiAccess.health.checkAll')}
</button>
</div>
</section>
</div>
);
}
type ApiProviderDialogProps = {
activeCategory: ProviderCategory;
editingRow: ProviderRow | null;
+410 -161
View File
@@ -13,21 +13,23 @@ import {
Network,
Pencil,
Plus,
Plug,
RefreshCw,
Route,
ShieldCheck,
Sparkles,
Trash2,
X,
} from 'lucide-react';
import { useCoreRuntime, type CoreStatus } from '../coreRuntime';
import { useI18n } from '../i18n';
import { webUiManagementUrl } from '../services/clientAccess';
import { ThinkingAliasesPage } from './ThinkingAliasesPage';
type CoreConfigSettings = {
apiKeys: CoreApiKey[];
managementSecretConfigured: boolean;
port: number;
allowLan: boolean;
pluginsEnabled: boolean;
routingStrategy: string;
proxyUrl: string;
routingSessionAffinity: boolean;
@@ -43,11 +45,12 @@ type ConfigAction =
| 'add-key'
| 'update-key'
| 'delete-key'
| 'plugins'
| 'management-secret'
| 'routing'
| 'network'
| null;
type NoticeTone = 'success' | 'error';
type ConfigSubpage = 'general' | 'network' | 'aliases';
const ROUTING_OPTIONS = [
{ value: 'round-robin', labelKey: 'config.routing.roundRobin' },
@@ -68,14 +71,20 @@ export function ConfigPanelPage() {
const [newApiKeyRemark, setNewApiKeyRemark] = useState('');
const [showApiKey, setShowApiKey] = useState(false);
const [formError, setFormError] = useState('');
const [managementSecretDraft, setManagementSecretDraft] = useState('');
const [managementSecretConfirm, setManagementSecretConfirm] = useState('');
const [showManagementSecret, setShowManagementSecret] = useState(false);
const [managementSecretError, setManagementSecretError] = useState('');
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
const [notice, setNotice] = useState<{ message: string; tone: NoticeTone } | null>(null);
const [activeSubpage, setActiveSubpage] = useState<ConfigSubpage>('general');
const [portDraft, setPortDraft] = useState('8317');
const [allowLanDraft, setAllowLanDraft] = useState(false);
const [proxyUrlDraft, setProxyUrlDraft] = useState('');
const [sessionAffinityDraft, setSessionAffinityDraft] = useState(false);
const [sessionTtlDraft, setSessionTtlDraft] = useState('');
const [portError, setPortError] = useState('');
const [savedStatusVisible, setSavedStatusVisible] = useState(false);
const noticeTimerRef = useRef<number | null>(null);
const copyTimerRef = useRef<number | null>(null);
@@ -197,6 +206,55 @@ export function ConfigPanelPage() {
setFormError('');
};
const generateManagementSecret = () => {
const bytes = new Uint8Array(24);
crypto.getRandomValues(bytes);
const value = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
const secret = `webui-${value}`;
setManagementSecretDraft(secret);
setManagementSecretConfirm(secret);
setShowManagementSecret(true);
setManagementSecretError('');
};
const saveManagementSecret = async (event: FormEvent) => {
event.preventDefault();
const secretKey = managementSecretDraft.trim();
if (!secretKey) {
setManagementSecretError(t('config.webuiKey.error.empty'));
return;
}
if (secretKey === '123456') {
setManagementSecretError(t('config.webuiKey.error.legacyDefault'));
return;
}
if (secretKey.length > 512) {
setManagementSecretError(t('config.webuiKey.error.tooLong'));
return;
}
if (/[\u0000-\u001f\u007f-\u009f]/.test(secretKey)) {
setManagementSecretError(t('config.webuiKey.error.invalid'));
return;
}
if (secretKey !== managementSecretConfirm.trim()) {
setManagementSecretError(t('config.webuiKey.error.mismatch'));
return;
}
const saved = await runMutation(
'management-secret',
'set_core_management_secret_key',
{ secretKey },
t('config.webuiKey.notice.updated'),
);
if (saved) {
setManagementSecretDraft('');
setManagementSecretConfirm('');
setShowManagementSecret(false);
setManagementSecretError('');
}
};
const submitApiKey = async (event: FormEvent) => {
event.preventDefault();
const apiKey = newApiKey.trim();
@@ -265,25 +323,28 @@ export function ConfigPanelPage() {
}
};
const togglePlugins = async (enabled: boolean) => {
await runMutation(
'plugins',
'set_core_plugins_enabled',
{ enabled },
enabled ? t('config.notice.pluginsEnabled') : t('config.notice.pluginsDisabled'),
);
const openWebUi = async () => {
try {
await invoke('open_external_url', {
url: webUiManagementUrl(settings?.port ?? 8317),
});
} catch (error) {
showNotice(t('config.webuiKey.error.openFailed', { error: String(error) }), 'error');
}
};
const changeRoutingStrategy = async (strategy: string) => {
if (strategy === settings?.routingStrategy) {
return;
}
await runMutation(
setSavedStatusVisible(false);
const saved = await runMutation(
'routing',
'set_core_routing_strategy',
{ strategy },
t('config.notice.routingUpdated'),
);
if (saved) setSavedStatusVisible(true);
};
const saveNetworkRoutingSettings = async () => {
@@ -299,6 +360,7 @@ export function ConfigPanelPage() {
const routingSessionAffinityTtl = sessionTtlDraft.trim();
const networkChanged = port !== settings.port || allowLanDraft !== settings.allowLan;
setPortError('');
setSavedStatusVisible(false);
setBusyAction('network');
try {
const result = await invoke<CoreConfigSettings>('save_network_routing_settings', {
@@ -312,6 +374,7 @@ export function ConfigPanelPage() {
});
applySettings(result);
setLoadError('');
setSavedStatusVisible(true);
if (networkChanged && coreStatus?.running) {
try {
@@ -344,14 +407,76 @@ export function ConfigPanelPage() {
|| sessionAffinityDraft !== settings?.routingSessionAffinity
|| sessionTtlDraft.trim() !== settings?.routingSessionAffinityTtl
);
const networkStatusLabel = loading
? t('common.loading')
: settings === null
? t('common.unavailable')
: busyAction === 'network' || busyAction === 'routing'
? t('config.network.saving')
: networkRoutingDirty
? t('config.network.unsaved')
: savedStatusVisible
? t('config.network.saved')
: '';
const networkStatusIsSaved = !loading
&& settings !== null
&& busyAction === null
&& !networkRoutingDirty
&& savedStatusVisible;
const selectedDeleteKey =
deleteIndex === null ? '' : settings?.apiKeys[deleteIndex]?.apiKey || '';
const deletingLastKey = deleteIndex !== null && settings?.apiKeys.length === 1;
const keyMutationBusy = busyAction === 'add-key' || busyAction === 'update-key';
const managementSecretBusy = busyAction === 'management-secret';
return (
<section className="page config-page">
<div className="config-workspace-grid">
<div className="agent-subpage-tabs config-subpage-tabs" role="tablist" aria-label={t('config.tabs.label')}>
<button
type="button"
id="config-subpage-tab-general"
role="tab"
className={activeSubpage === 'general' ? 'active' : ''}
aria-selected={activeSubpage === 'general'}
aria-controls="config-subpage-panel-general"
tabIndex={activeSubpage === 'general' ? 0 : -1}
onClick={() => setActiveSubpage('general')}
>
{t('config.tabs.general')}
</button>
<button
type="button"
id="config-subpage-tab-network"
role="tab"
className={activeSubpage === 'network' ? 'active' : ''}
aria-selected={activeSubpage === 'network'}
aria-controls="config-subpage-panel-network"
tabIndex={activeSubpage === 'network' ? 0 : -1}
onClick={() => setActiveSubpage('network')}
>
{t('config.tabs.network')}
</button>
<button
type="button"
id="config-subpage-tab-aliases"
role="tab"
className={activeSubpage === 'aliases' ? 'active' : ''}
aria-selected={activeSubpage === 'aliases'}
aria-controls="config-subpage-panel-aliases"
tabIndex={activeSubpage === 'aliases' ? 0 : -1}
onClick={() => setActiveSubpage('aliases')}
>
{t('app.nav.thinkingAliases')}
</button>
</div>
{activeSubpage === 'general' ? (
<div
className="config-subpage-panel"
id="config-subpage-panel-general"
role="tabpanel"
aria-labelledby="config-subpage-tab-general"
>
<section className="panel config-keys-panel">
<div className="config-panel-heading">
<div className="config-heading-title">
@@ -453,72 +578,133 @@ export function ConfigPanelPage() {
)}
</div>
</section>
<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>{t('config.plugins.title')}</h2>
</div>
<span className={`state-pill ${settings?.pluginsEnabled ? 'success' : ''}`}>
{loading
? t('common.loading')
: settings === null
? t('common.unavailable')
: settings.pluginsEnabled
? t('common.enabled')
: t('common.disabled')}
</span>
<section className="panel config-management-panel">
<div className="config-panel-heading">
<div className="config-heading-title">
<ShieldCheck size={18} aria-hidden="true" />
<h2>{t('config.webuiKey.title')}</h2>
</div>
<div className="config-single-control">
<span>{t('config.plugins.enable')}</span>
<label className="switch-control" title={t('config.plugins.enable')}>
<input
type="checkbox"
aria-label={t('config.plugins.enable')}
checked={Boolean(settings?.pluginsEnabled)}
disabled={controlsDisabled}
onChange={(event) => void togglePlugins(event.currentTarget.checked)}
/>
<span className="switch-track" />
</label>
</div>
</section>
<section className="panel config-setting-panel">
<div className="config-panel-heading">
<div className="config-heading-title">
<Route size={18} aria-hidden="true" />
<h2>{t('config.routing.title')}</h2>
</div>
<span className="state-pill" title={settings?.routingStrategy || undefined}>
{loading
? t('common.loading')
: settings === null
? t('common.unavailable')
: routingStrategyLabel(settings.routingStrategy, t)}
</span>
</div>
<div className="routing-segmented" role="group" aria-label={t('config.routing.title')}>
{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}
<div className="config-heading-actions">
{!loading && settings ? (
<span
className={`state-pill ${settings.managementSecretConfigured ? 'success' : ''}`}
>
{t(option.labelKey)}
</button>
))}
{settings.managementSecretConfigured
? t('config.webuiKey.configured')
: t('config.webuiKey.unconfigured')}
</span>
) : null}
<button
type="button"
className="secondary-button compact-button"
disabled={loading || settings === null}
onClick={() => void openWebUi()}
title={settings ? webUiManagementUrl(settings.port) : undefined}
>
{t('config.webuiKey.open')}
</button>
</div>
</section>
</div>
</div>
</div>
<div className="config-management-content">
<div className="config-management-description">
<strong>{t('config.webuiKey.heading')}</strong>
<p>{t('config.webuiKey.description')}</p>
<small>{t('config.webuiKey.securityHint')}</small>
</div>
<form
className="config-management-form"
onSubmit={(event) => void saveManagementSecret(event)}
>
<label className="config-management-field">
<span>{t('config.webuiKey.newKey')}</span>
<div className="config-secret-input">
<input
type={showManagementSecret ? 'text' : 'password'}
autoComplete="new-password"
maxLength={512}
value={managementSecretDraft}
disabled={controlsDisabled}
aria-invalid={Boolean(managementSecretError)}
placeholder={t('config.webuiKey.placeholder')}
onChange={(event) => {
setManagementSecretDraft(event.currentTarget.value);
setManagementSecretError('');
}}
/>
<button
type="button"
className="icon-button quiet"
disabled={controlsDisabled}
onClick={() => setShowManagementSecret((value) => !value)}
title={showManagementSecret ? t('config.keys.hide') : t('config.keys.show')}
aria-label={showManagementSecret ? t('config.keys.hide') : t('config.keys.show')}
>
{showManagementSecret ? (
<EyeOff size={16} aria-hidden="true" />
) : (
<Eye size={16} aria-hidden="true" />
)}
</button>
</div>
</label>
<label className="config-management-field">
<span>{t('config.webuiKey.confirmKey')}</span>
<input
className="config-dialog-text-input"
type={showManagementSecret ? 'text' : 'password'}
autoComplete="new-password"
maxLength={512}
value={managementSecretConfirm}
disabled={controlsDisabled}
aria-invalid={Boolean(managementSecretError)}
placeholder={t('config.webuiKey.confirmPlaceholder')}
onChange={(event) => {
setManagementSecretConfirm(event.currentTarget.value);
setManagementSecretError('');
}}
/>
</label>
<div className="config-management-form-footer">
<span
className={`config-management-error ${managementSecretError ? 'visible' : ''}`}
role="alert"
>
{managementSecretError || ' '}
</span>
<div className="config-management-actions">
<button
type="button"
className="secondary-button compact-button"
disabled={controlsDisabled}
onClick={generateManagementSecret}
>
{t('config.webuiKey.generate')}
</button>
<button
type="submit"
className="primary-button compact-button"
disabled={controlsDisabled || !managementSecretDraft.trim()}
>
<Check size={16} aria-hidden="true" />
{managementSecretBusy ? t('common.saving') : t('config.webuiKey.save')}
</button>
</div>
</div>
</form>
</div>
</section>
</div>
) : activeSubpage === 'network' ? (
<div
className="config-subpage-panel config-network-subpage"
id="config-subpage-panel-network"
role="tabpanel"
aria-labelledby="config-subpage-tab-network"
>
<section className="panel config-network-panel">
<div className="config-panel-heading">
<div className="config-heading-title">
@@ -526,17 +712,11 @@ export function ConfigPanelPage() {
<h2>{t('config.network.title')}</h2>
</div>
<div className="config-heading-actions">
<span className={`state-pill ${settings !== null && !networkRoutingDirty ? 'success' : ''}`}>
{loading
? t('common.loading')
: settings === null
? t('common.unavailable')
: busyAction === 'network'
? t('config.network.saving')
: networkRoutingDirty
? t('config.network.unsaved')
: t('config.network.saved')}
</span>
{networkStatusLabel ? (
<span className={`state-pill ${networkStatusIsSaved ? 'success' : ''}`}>
{networkStatusLabel}
</span>
) : null}
<button
type="button"
className="primary-button compact-button"
@@ -551,8 +731,14 @@ export function ConfigPanelPage() {
</div>
</div>
<div className="config-network-grid">
<label className="config-network-field config-network-port-field">
<div className="config-network-sections">
<section className="config-network-section" aria-labelledby="config-network-section-title">
<div className="config-network-section-heading">
<Network size={16} aria-hidden="true" />
<h3 id="config-network-section-title">{t('config.network.networkSection')}</h3>
</div>
<div className="config-network-grid">
<label className="config-network-field config-network-port-field">
<span>{t('config.network.port')}</span>
<input
className={`config-network-input ${portError ? 'error' : ''}`}
@@ -565,6 +751,7 @@ export function ConfigPanelPage() {
aria-invalid={Boolean(portError)}
title={portError || t('config.network.portHint')}
onChange={(event) => {
setSavedStatusVisible(false);
setPortDraft(event.currentTarget.value.replace(/\D/g, '').slice(0, 5));
setPortError('');
}}
@@ -577,87 +764,149 @@ export function ConfigPanelPage() {
}}
/>
<small>{t('config.network.portHint')}</small>
</label>
</label>
<div className="config-network-field config-network-toggle">
<div>
<span>{t('config.network.allowLan')}</span>
<small>{t('config.network.allowLanHint')}</small>
<div className="config-network-field config-network-toggle">
<div>
<span>{t('config.network.allowLan')}</span>
<small>{t('config.network.allowLanHint')}</small>
</div>
<label className="switch-control" title={t('config.network.allowLan')}>
<input
type="checkbox"
aria-label={t('config.network.allowLan')}
checked={allowLanDraft}
disabled={controlsDisabled}
onChange={(event) => {
setSavedStatusVisible(false);
setAllowLanDraft(event.currentTarget.checked);
}}
/>
<span className="switch-track" />
</label>
</div>
<label className="config-network-field">
<span className="config-network-label">
<Link2 size={15} aria-hidden="true" />
{t('config.network.proxyUrl')}
</span>
<input
className="config-network-input"
type="text"
value={proxyUrlDraft}
disabled={controlsDisabled}
placeholder={t('config.network.proxyPlaceholder')}
onChange={(event) => {
setSavedStatusVisible(false);
setProxyUrlDraft(event.currentTarget.value);
}}
onKeyDown={(event) => {
if (event.key === 'Escape' && settings) {
setProxyUrlDraft(settings.proxyUrl);
event.currentTarget.blur();
}
}}
/>
<small>{t('config.network.proxyHint')}</small>
</label>
</div>
<label className="switch-control" title={t('config.network.allowLan')}>
<input
type="checkbox"
aria-label={t('config.network.allowLan')}
checked={allowLanDraft}
disabled={controlsDisabled}
onChange={(event) => setAllowLanDraft(event.currentTarget.checked)}
/>
<span className="switch-track" />
</label>
</div>
</section>
<label className="config-network-field">
<span className="config-network-label">
<Link2 size={15} aria-hidden="true" />
{t('config.network.proxyUrl')}
</span>
<input
className="config-network-input"
type="text"
value={proxyUrlDraft}
disabled={controlsDisabled}
placeholder={t('config.network.proxyPlaceholder')}
onChange={(event) => setProxyUrlDraft(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === 'Escape' && settings) {
setProxyUrlDraft(settings.proxyUrl);
event.currentTarget.blur();
}
}}
/>
<small>{t('config.network.proxyHint')}</small>
</label>
<div className="config-network-field config-network-toggle">
<div>
<span>{t('config.network.sessionAffinity')}</span>
<small>{t('config.network.sessionAffinityHint')}</small>
<section className="config-network-section" aria-labelledby="config-routing-section-title">
<div className="config-network-section-heading">
<Route size={16} aria-hidden="true" />
<h3 id="config-routing-section-title">{t('config.network.routingSection')}</h3>
</div>
<label className="switch-control" title={t('config.network.sessionAffinity')}>
<input
type="checkbox"
aria-label={t('config.network.sessionAffinity')}
checked={sessionAffinityDraft}
disabled={controlsDisabled}
onChange={(event) => setSessionAffinityDraft(event.currentTarget.checked)}
/>
<span className="switch-track" />
</label>
</div>
<div className="config-network-grid">
<div className="config-network-field config-network-toggle">
<div>
<span>{t('config.network.sessionAffinity')}</span>
<small>{t('config.network.sessionAffinityHint')}</small>
</div>
<label className="switch-control" title={t('config.network.sessionAffinity')}>
<input
type="checkbox"
aria-label={t('config.network.sessionAffinity')}
checked={sessionAffinityDraft}
disabled={controlsDisabled}
onChange={(event) => {
setSavedStatusVisible(false);
setSessionAffinityDraft(event.currentTarget.checked);
}}
/>
<span className="switch-track" />
</label>
</div>
<label className="config-network-field">
<span className="config-network-label">
<Clock3 size={15} aria-hidden="true" />
{t('config.network.sessionTtl')}
</span>
<input
className="config-network-input"
type="text"
value={sessionTtlDraft}
disabled={controlsDisabled}
placeholder="1h"
onChange={(event) => setSessionTtlDraft(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === 'Escape' && settings) {
setSessionTtlDraft(settings.routingSessionAffinityTtl);
event.currentTarget.blur();
}
}}
/>
<small>{t('config.network.sessionTtlHint')}</small>
</label>
<label className="config-network-field">
<span className="config-network-label">
<Clock3 size={15} aria-hidden="true" />
{t('config.network.sessionTtl')}
</span>
<input
className="config-network-input"
type="text"
value={sessionTtlDraft}
disabled={controlsDisabled}
placeholder="1h"
onChange={(event) => {
setSavedStatusVisible(false);
setSessionTtlDraft(event.currentTarget.value);
}}
onKeyDown={(event) => {
if (event.key === 'Escape' && settings) {
setSessionTtlDraft(settings.routingSessionAffinityTtl);
event.currentTarget.blur();
}
}}
/>
<small>{t('config.network.sessionTtlHint')}</small>
</label>
<div className="config-network-field config-network-routing-field">
<span className="config-network-label">
<Route size={15} aria-hidden="true" />
{t('config.routing.title')}
</span>
<div className="routing-segmented" role="group" aria-label={t('config.routing.title')}>
{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}
>
{t(option.labelKey)}
</button>
))}
</div>
<small title={settings?.routingStrategy || undefined}>
{loading
? t('common.loading')
: settings === null
? t('common.unavailable')
: routingStrategyLabel(settings.routingStrategy, t)}
</small>
</div>
</div>
</section>
</div>
</section>
</div>
) : (
<div
className="config-subpage-panel"
id="config-subpage-panel-aliases"
role="tabpanel"
aria-labelledby="config-subpage-tab-aliases"
>
<ThinkingAliasesPage />
</div>
)}
{addDialogOpen ? (
<div className="config-dialog-backdrop" onMouseDown={(event) => {
+48
View File
@@ -13,12 +13,15 @@ import codexIcon from '../assets/icons/codex.svg';
import grokIcon from '../assets/icons/grok.svg';
import kimiIcon from '../assets/icons/kimi-light.svg';
import { useI18n } from '../i18n';
import { oauthSubpages, type OAuthSubpage } from '../oauthNavigation';
import {
changedOAuthAuthFileNames,
snapshotAuthFiles,
type AuthFileSnapshot,
} from '../services/authFiles';
import { managementApi, responseList } from '../services/managementApi';
import { AuthFileManagementPage } from './AuthFileManagementPage';
import { QuotaPage } from './QuotaPage';
type OAuthProviderId = 'codex' | 'claude' | 'antigravity' | 'kimi' | 'xai';
type OAuthFlowStatus = 'idle' | 'waiting' | 'success' | 'error';
@@ -65,6 +68,51 @@ 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 OAuthManagementPage() {
const { t } = useI18n();
const [activeSubpage, setActiveSubpage] = useState<OAuthSubpage>('login');
return (
<section className="page oauth-management-page">
<div
className="agent-subpage-tabs oauth-subpage-tabs"
role="tablist"
aria-label={t('oauth.tabs.label')}
>
{oauthSubpages.map((subpage) => {
const active = activeSubpage === subpage.id;
return (
<button
type="button"
id={`oauth-subpage-tab-${subpage.id}`}
key={subpage.id}
role="tab"
className={active ? 'active' : ''}
aria-selected={active}
aria-controls={`oauth-subpage-panel-${subpage.id}`}
tabIndex={active ? 0 : -1}
onClick={() => setActiveSubpage(subpage.id)}
>
{t(subpage.labelKey)}
</button>
);
})}
</div>
<div
className="oauth-subpage-panel"
id={`oauth-subpage-panel-${activeSubpage}`}
role="tabpanel"
aria-labelledby={`oauth-subpage-tab-${activeSubpage}`}
>
{activeSubpage === 'login' ? <OAuthLoginPage /> : null}
{activeSubpage === 'authFiles' ? <AuthFileManagementPage /> : null}
{activeSubpage === 'quota' ? <QuotaPage /> : null}
</div>
</section>
);
}
export function OAuthLoginPage() {
const { t } = useI18n();
const [states, setStates] = useState<Partial<Record<OAuthProviderId, OAuthProviderState>>>({});
+190 -32
View File
@@ -16,6 +16,7 @@ import {
Search,
Trash2,
X,
Zap,
} from 'lucide-react';
import { getCurrentLocale, translate, useI18n } from '../i18n';
@@ -29,6 +30,18 @@ type ThinkingAliasEntry = {
kind: string;
};
type SpeedAliasEntry = {
sourceModel: string;
alias: string;
serviceTier: string;
provider: string;
kind: string;
};
type AliasListEntry = ThinkingAliasEntry & {
serviceTier: string | null;
};
type ThinkingAliasSource = {
id: string;
model: string;
@@ -38,6 +51,10 @@ type ThinkingAliasSource = {
protocol: string;
};
type ModelAliasSource = ThinkingAliasSource & {
supportsReasoning: boolean;
};
const effortOptions = [
{ value: 'low', label: 'Low', hintKey: 'aliases.effort.low' },
{ value: 'medium', label: 'Medium', hintKey: 'aliases.effort.medium' },
@@ -46,6 +63,49 @@ const effortOptions = [
{ value: 'max', label: 'Max', hintKey: 'aliases.effort.max' },
] as const satisfies ReadonlyArray<{ value: PresetThinkingEffort; label: string; hintKey: string }>;
export const combineModelAliasEntries = (
thinkingEntries: ThinkingAliasEntry[],
speedEntries: SpeedAliasEntry[],
): AliasListEntry[] => {
const entries = new Map<string, AliasListEntry>();
const entryKey = (entry: Pick<ThinkingAliasEntry, 'kind' | 'provider' | 'sourceModel' | 'alias'>) => (
[entry.kind, entry.provider, entry.sourceModel, entry.alias]
.map((value) => value.toLocaleLowerCase())
.join('\u0000')
);
thinkingEntries.forEach((entry) => {
entries.set(entryKey(entry), { ...entry, serviceTier: null });
});
speedEntries.forEach((entry) => {
const key = entryKey(entry);
const current = entries.get(key);
entries.set(key, current
? { ...current, serviceTier: entry.serviceTier }
: { ...entry, effort: null, serviceTier: entry.serviceTier });
});
return [...entries.values()].sort((left, right) => (
left.provider.localeCompare(right.provider)
|| left.alias.localeCompare(right.alias)
));
};
export const combineModelAliasSources = (
thinkingSources: ThinkingAliasSource[],
speedSources: ThinkingAliasSource[],
): ModelAliasSource[] => {
const reasoningSourceIds = new Set(thinkingSources.map((source) => source.id));
const sources = new Map<string, ModelAliasSource>();
[...speedSources, ...thinkingSources].forEach((source) => {
sources.set(source.id, {
...source,
supportsReasoning: reasoningSourceIds.has(source.id),
});
});
return [...sources.values()];
};
export const thinkingAliasSourceKindLabel = (kind: string) => {
if (kind === 'codex-oauth') return 'Codex OAuth';
if (kind === 'codex-api') return 'Codex API';
@@ -65,10 +125,13 @@ const thinkingAliasSourceDetail = (source: ThinkingAliasSource) => (
export function ThinkingAliasesPage() {
const { t } = useI18n();
const [entries, setEntries] = useState<ThinkingAliasEntry[]>([]);
const [sources, setSources] = useState<ThinkingAliasSource[]>([]);
const [thinkingEntries, setThinkingEntries] = useState<ThinkingAliasEntry[]>([]);
const [speedEntries, setSpeedEntries] = useState<SpeedAliasEntry[]>([]);
const [thinkingSources, setThinkingSources] = useState<ThinkingAliasSource[]>([]);
const [speedSources, setSpeedSources] = useState<ThinkingAliasSource[]>([]);
const [selectedSourceId, setSelectedSourceId] = useState('');
const [effort, setEffort] = useState('xhigh');
const [fastEnabled, setFastEnabled] = useState(false);
const [customEffortOpen, setCustomEffortOpen] = useState(false);
const [alias, setAlias] = useState('');
const [search, setSearch] = useState('');
@@ -85,14 +148,20 @@ export function ThinkingAliasesPage() {
setLoading(true);
setError('');
try {
const [nextEntries, nextSources] = await Promise.all([
const [nextThinkingEntries, nextThinkingSources, nextSpeedEntries, nextSpeedSources] = await Promise.all([
invoke<ThinkingAliasEntry[]>('get_thinking_aliases'),
invoke<ThinkingAliasSource[]>('get_thinking_alias_sources'),
invoke<SpeedAliasEntry[]>('get_speed_aliases'),
invoke<ThinkingAliasSource[]>('get_speed_alias_sources'),
]);
setEntries(nextEntries);
setSources(nextSources);
setThinkingEntries(nextThinkingEntries);
setThinkingSources(nextThinkingSources);
setSpeedEntries(nextSpeedEntries);
setSpeedSources(nextSpeedSources);
setSelectedSourceId((current) => (
nextSources.some((source) => source.id === current) ? current : ''
[...nextThinkingSources, ...nextSpeedSources].some((source) => source.id === current)
? current
: ''
));
} catch (requestError) {
setError(String(requestError));
@@ -117,6 +186,21 @@ export function ThinkingAliasesPage() {
return () => document.removeEventListener('pointerdown', closeOnOutsideClick);
}, [modelPickerOpen]);
const sources = useMemo(
() => combineModelAliasSources(thinkingSources, speedSources),
[speedSources, thinkingSources],
);
const entries = useMemo(
() => combineModelAliasEntries(thinkingEntries, speedEntries),
[speedEntries, thinkingEntries],
);
useEffect(() => {
setSelectedSourceId((current) => (
sources.some((source) => source.id === current) ? current : ''
));
}, [sources]);
const selectedSource = useMemo(
() => sources.find((source) => source.id === selectedSourceId) ?? null,
[selectedSourceId, sources],
@@ -148,8 +232,13 @@ export function ThinkingAliasesPage() {
setActiveSourceIndex(0);
}, [search, sources]);
const chooseSource = (source: ThinkingAliasSource) => {
const chooseSource = (source: ModelAliasSource) => {
setSelectedSourceId(source.id);
if (!source.supportsReasoning) {
setEffort('');
setFastEnabled(true);
setCustomEffortOpen(false);
}
setModelPickerOpen(false);
setSearch('');
};
@@ -186,7 +275,8 @@ export function ThinkingAliasesPage() {
const normalizedEffort = effort.trim().toLowerCase();
const customEffortSelected = Boolean(
normalizedEffort && !effortOptions.some((option) => option.value === normalizedEffort),
normalizedEffort
&& !effortOptions.some((option) => option.value === normalizedEffort),
);
const createAlias = async () => {
@@ -199,8 +289,12 @@ export function ThinkingAliasesPage() {
setError(t('aliases.error.emptyAlias'));
return;
}
if (!normalizedEffort) {
setError(t('aliases.error.emptyEffort'));
if (!normalizedEffort && !fastEnabled) {
setError(t('aliases.error.emptyOptions'));
return;
}
if (normalizedEffort && !selectedSource.supportsReasoning) {
setError(t('aliases.error.unsupportedEffort'));
return;
}
setBusyAlias(normalizedAlias);
@@ -208,14 +302,26 @@ export function ThinkingAliasesPage() {
setError('');
setNotice('');
try {
const nextEntries = await invoke<ThinkingAliasEntry[]>('create_thinking_alias', {
sourceId: selectedSource.id,
alias: normalizedAlias,
effort: normalizedEffort,
});
setEntries(nextEntries);
setNotice(t('aliases.created', { alias: normalizedAlias, effort: normalizedEffort }));
if (normalizedEffort) {
await invoke<ThinkingAliasEntry[]>('create_thinking_alias', {
sourceId: selectedSource.id,
alias: normalizedAlias,
effort: normalizedEffort,
fast: fastEnabled,
});
setNotice(t(fastEnabled ? 'aliases.createdCombined' : 'aliases.created', {
alias: normalizedAlias,
effort: normalizedEffort,
}));
} else {
await invoke<SpeedAliasEntry[]>('create_speed_alias', {
sourceId: selectedSource.id,
alias: normalizedAlias,
});
setNotice(t('speedAliases.created', { alias: normalizedAlias }));
}
setAlias('');
await load();
} catch (requestError) {
setError(String(requestError));
} finally {
@@ -224,7 +330,7 @@ export function ThinkingAliasesPage() {
}
};
const deleteAlias = async (entry: ThinkingAliasEntry) => {
const deleteAlias = async (entry: AliasListEntry) => {
if (!window.confirm(
t('aliases.deleteConfirm', { alias: entry.alias }),
)) return;
@@ -233,11 +339,17 @@ export function ThinkingAliasesPage() {
setError('');
setNotice('');
try {
const nextEntries = await invoke<ThinkingAliasEntry[]>('delete_thinking_alias', {
alias: entry.alias,
});
setEntries(nextEntries);
if (entry.effort) {
await invoke<ThinkingAliasEntry[]>('delete_thinking_alias', {
alias: entry.alias,
});
} else {
await invoke<SpeedAliasEntry[]>('delete_speed_alias', {
alias: entry.alias,
});
}
setNotice(t('aliases.deleted', { alias: entry.alias }));
await load();
} catch (requestError) {
setError(String(requestError));
} finally {
@@ -363,6 +475,18 @@ export function ThinkingAliasesPage() {
<span>{t('aliases.effort.description')}</span>
</div>
<div className="thinking-effort-options">
<button
type="button"
className={!normalizedEffort ? 'active none' : 'none'}
onClick={() => {
chooseEffort('');
setCustomEffortOpen(false);
}}
disabled={Boolean(busyAlias)}
title={t('aliases.effort.noneHint')}
>
{t('aliases.effort.none')}
</button>
{effortOptions.map((option) => (
<button
type="button"
@@ -372,7 +496,7 @@ export function ThinkingAliasesPage() {
chooseEffort(option.value);
setCustomEffortOpen(false);
}}
disabled={Boolean(busyAlias)}
disabled={Boolean(busyAlias) || Boolean(selectedSource && !selectedSource.supportsReasoning)}
title={t(option.hintKey)}
>
{option.label}
@@ -385,7 +509,7 @@ export function ThinkingAliasesPage() {
if (!customEffortSelected) chooseEffort('');
setCustomEffortOpen(true);
}}
disabled={Boolean(busyAlias)}
disabled={Boolean(busyAlias) || Boolean(selectedSource && !selectedSource.supportsReasoning)}
title={customEffortSelected
? t('aliases.customLevel', { effort: normalizedEffort })
: t('aliases.customLevelHint')}
@@ -415,6 +539,26 @@ export function ThinkingAliasesPage() {
</button>
<small>{t('aliases.customHelp')}</small>
</div> : null}
{selectedSource && !selectedSource.supportsReasoning ? (
<small className="thinking-model-hint">{t('aliases.effort.unsupported')}</small>
) : null}
</div>
<div className="thinking-alias-field">
<div className="thinking-field-heading">
<strong>{t('aliases.fast.title')}</strong>
<span>{t('aliases.fast.description')}</span>
</div>
<button
type="button"
className={`thinking-speed-toggle${fastEnabled ? ' active' : ''}`}
aria-pressed={fastEnabled}
onClick={() => setFastEnabled((current) => !current)}
disabled={Boolean(busyAlias)}
>
<span>Fast</span>
<small>{fastEnabled ? t('aliases.fast.enabled') : t('aliases.fast.disabled')}</small>
</button>
</div>
<div className="thinking-alias-section-divider" aria-hidden="true" />
@@ -429,17 +573,24 @@ export function ThinkingAliasesPage() {
value={alias}
onChange={(event) => setAlias(event.currentTarget.value)}
placeholder={selectedSource
? t('aliases.aliasName.example', { model: selectedSource.model, effort: normalizedEffort || 'high' })
? normalizedEffort
? t('aliases.aliasName.example', { model: selectedSource.model, effort: normalizedEffort })
: t('speedAliases.aliasName.example', { model: selectedSource.model })
: t('aliases.aliasName.selectFirst')}
disabled={Boolean(busyAlias)}
/>
</div>
<div className="thinking-alias-preview">
<BrainCircuit size={18} />
{normalizedEffort ? <BrainCircuit size={18} /> : <Zap size={18} />}
<div>
<span>{selectedSource?.model || t('aliases.notSelected')} <ArrowRight size={13} /> {alias || t('aliases.enterAlias')}</span>
<code>{selectedSource?.protocol === 'openai' ? 'reasoning_effort' : 'reasoning.effort'} = {normalizedEffort || t('aliases.notSet')}</code>
<code>{[
normalizedEffort
? `${selectedSource?.protocol === 'openai' ? 'reasoning_effort' : 'reasoning.effort'} = ${normalizedEffort}`
: '',
fastEnabled ? 'service_tier = priority' : '',
].filter(Boolean).join(' · ') || t('aliases.notSet')}</code>
</div>
</div>
@@ -447,9 +598,11 @@ export function ThinkingAliasesPage() {
type="button"
className="primary-button thinking-alias-create"
onClick={() => void createAlias()}
disabled={loading || Boolean(busyAlias) || !selectedSource || !effort.trim() || !alias.trim()}
disabled={loading || Boolean(busyAlias) || !selectedSource || (!normalizedEffort && !fastEnabled) || !alias.trim()}
>
{busyAction === 'create' ? <LoaderCircle size={16} className="spin" /> : <GitFork size={16} />}
{busyAction === 'create'
? <LoaderCircle size={16} className="spin" />
: <GitFork size={16} />}
{busyAction === 'create' ? t('aliases.creating') : t('aliases.create')}
</button>
</section>
@@ -487,9 +640,14 @@ export function ThinkingAliasesPage() {
<ArrowRight size={14} />
<strong title={entry.alias}>{entry.alias}</strong>
</div>
<span className={`thinking-effort-badge ${entry.effort ? '' : 'missing'}`}>
{entry.effort ?? t('aliases.unboundEffort')}
</span>
<div className="thinking-alias-badges">
{entry.effort ? (
<span className="thinking-effort-badge">{entry.effort}</span>
) : null}
{entry.serviceTier ? (
<span className="thinking-effort-badge fast">{t('speedAliases.fast.title')}</span>
) : null}
</div>
<button
type="button"
className="icon-button danger"
+4 -6
View File
@@ -3,6 +3,7 @@ import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { Activity, BarChart3, CircleDollarSign, Clock3, Database, List, Pencil, RefreshCw, ShieldCheck, Sparkles, Trash2, TriangleAlert, X } from 'lucide-react';
import { getCurrentLocale, useI18n } from '../i18n';
import { formatUsageNumber } from '../services/usageNumber';
type UsageTab = 'overview' | 'analysis' | 'events' | 'pricing';
type UsageRange = '4h' | '24h' | 'today' | '7d' | '30d' | 'all' | 'custom';
@@ -190,10 +191,7 @@ const rangeQuery = (range: UsageRange, customStart: string, customEnd: string):
return { start: new Date(now.getTime() - hours * 3_600_000).toISOString(), end: now.toISOString() };
};
const compactNumber = (value: number) => new Intl.NumberFormat(getCurrentLocale(), {
notation: value >= 10_000 ? 'compact' : 'standard',
maximumFractionDigits: 1,
}).format(Number.isFinite(value) ? value : 0);
const compactNumber = (value: number) => formatUsageNumber(value, getCurrentLocale());
const formatUsd = (value: number) => {
const amount = Number.isFinite(value) ? value : 0;
@@ -416,7 +414,7 @@ export function UsageRecordsPage() {
function OverviewView({ overview }: { overview: UsageOverview }) {
const { t } = useI18n();
const cards = [
{ icon: Activity, label: t('usage.stat.requests'), value: compactNumber(overview.totalRequests), meta: t('usage.stat.requestMeta', { success: overview.successCount, failed: overview.failureCount }) },
{ icon: Activity, label: t('usage.stat.requests'), value: compactNumber(overview.totalRequests), meta: t('usage.stat.requestMeta', { success: compactNumber(overview.successCount), failed: compactNumber(overview.failureCount) }) },
{ icon: Sparkles, label: t('usage.stat.tokens'), value: compactNumber(overview.totalTokens), meta: t('usage.stat.tokenMeta', { input: compactNumber(overview.inputTokens), output: compactNumber(overview.outputTokens) }) },
{ icon: ShieldCheck, label: t('usage.stat.successRate'), value: `${overview.successRate.toFixed(1)}%`, meta: t('usage.stat.reasoningMeta', { tokens: compactNumber(overview.reasoningTokens) }) },
{ icon: Clock3, label: t('usage.stat.tps'), value: `${overview.tps.toFixed(1)} TPS`, meta: t('usage.stat.performanceMeta', { rpm: overview.rpm.toFixed(2), latency: Math.round(overview.averageLatencyMs) }) },
@@ -424,7 +422,7 @@ function OverviewView({ overview }: { overview: UsageOverview }) {
{ icon: CircleDollarSign, label: t('usage.stat.estimatedCost'), value: formatUsd(overview.estimatedCost), meta: t('usage.stat.costMeta', { priced: compactNumber(overview.pricedRequests), total: compactNumber(overview.totalRequests) }) },
];
return <div className="usage-overview-layout">
<div className="usage-stat-grid">{cards.map(({ icon: Icon, label, value, meta }) => <article className="panel usage-stat-card" key={label}><span><Icon size={16} />{label}</span><strong>{value}</strong><small>{meta}</small></article>)}</div>
<div className="usage-stat-grid">{cards.map(({ icon: Icon, label, value, meta }) => <article className="panel usage-stat-card" key={label}><span><Icon size={16} />{label}</span><strong>{value}</strong><small title={meta}>{meta}</small></article>)}</div>
<section className="panel usage-trend-panel"><div className="usage-section-heading"><div><strong>{t('usage.trend.title')}</strong><span>{t('usage.trend.description')}</span></div></div>{overview.timeline.length ? <UsageTrend points={overview.timeline} /> : <UsageEmpty />}</section>
<section className="panel usage-health-panel"><div className="usage-section-heading"><div><strong>{t('usage.token.title')}</strong><span>{t('usage.token.description')}</span></div></div><div className="usage-token-breakdown"><TokenMetric label={t('usage.token.input')} value={overview.inputTokens} total={overview.totalTokens} /><TokenMetric label={t('usage.token.output')} value={overview.outputTokens} total={overview.totalTokens} /><TokenMetric label={t('usage.token.reasoning')} value={overview.reasoningTokens} total={overview.totalTokens} /><TokenMetric label={t('usage.token.cacheRead')} value={overview.cacheReadTokens} total={overview.totalTokens} /><TokenMetric label={t('usage.token.cacheCreation')} value={overview.cacheCreationTokens} total={overview.totalTokens} /></div></section>
</div>;
+27
View File
@@ -28,6 +28,33 @@ export function filterAgentModels(models: ModelOption[], search: string): ModelO
.map((item) => item.model);
}
export function filterAgentModelsByAlias(
models: ModelOption[],
aliasesOnly: boolean,
): ModelOption[] {
return models.filter((model) => Boolean(model.isAlias) === aliasesOnly);
}
export function resolveAgentModelForAliasMode(
models: ModelOption[],
current: string,
aliasesOnly: boolean,
): string {
const candidates = filterAgentModelsByAlias(models, aliasesOnly);
const selected = findAgentModel(models, current);
if (selected && Boolean(selected.isAlias) === aliasesOnly) return selected.name;
if (selected && aliasesOnly) {
const alias = candidates.find((candidate) => normalized(candidate.alias ?? '') === normalized(selected.name));
if (alias) return alias.name;
}
if (selected?.isAlias && !aliasesOnly) {
const original = candidates.find((candidate) => normalized(candidate.name) === normalized(selected.alias ?? ''));
if (original) return original.name;
}
return candidates[0]?.name ?? '';
}
export function hasExactAgentModel(models: ModelOption[], value: string): boolean {
const query = normalized(value);
if (!query) return false;
+8 -1
View File
@@ -8,8 +8,15 @@ export type ClientApiProfile = {
lanUrl: string | null;
};
const safeLocalPort = (port: number) =>
Number.isInteger(port) && port >= 1 && port <= 65535 ? port : 8317;
export function webUiManagementUrl(port: number): string {
return `http://127.0.0.1:${safeLocalPort(port)}/management.html#/login`;
}
export function clientApiProfiles(port: number, lanIpv4?: string | null): ClientApiProfile[] {
const safePort = Number.isInteger(port) && port >= 1 && port <= 65535 ? port : 8317;
const safePort = safeLocalPort(port);
const origin = `http://127.0.0.1:${safePort}`;
const lanOrigin = lanIpv4?.trim() ? `http://${lanIpv4.trim()}:${safePort}` : null;
+7 -2
View File
@@ -6,6 +6,7 @@ export type ManagementJson = Record<string, unknown> | unknown[] | string | numb
type ManagementRequestOptions = {
query?: Record<string, string | number | boolean | undefined>;
body?: ManagementJson;
timeoutMs?: number;
};
const normalizeQuery = (
@@ -34,6 +35,7 @@ async function request<T = ManagementJson>(
path,
query: normalizeQuery(options.query),
body: options.body,
timeoutMs: options.timeoutMs,
},
});
}
@@ -41,8 +43,11 @@ async function request<T = ManagementJson>(
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 }),
post: <T = ManagementJson>(
path: string,
body?: ManagementJson,
options: Pick<ManagementRequestOptions, 'timeoutMs'> = {},
) => request<T>('POST', path, { ...options, body }),
put: <T = ManagementJson>(path: string, body?: ManagementJson) =>
request<T>('PUT', path, { body }),
patch: <T = ManagementJson>(path: string, body?: ManagementJson) =>
+6 -3
View File
@@ -6,6 +6,8 @@ const modelText = (key: Parameters<typeof translate>[1]) => translate(getCurrent
export type ModelOption = {
name: string;
alias?: string;
isAlias?: boolean;
contextWindow?: number;
thinking?: Record<string, unknown>;
};
export type ModelProvider = 'gemini' | 'codex' | 'claude' | 'openai';
@@ -90,6 +92,7 @@ export async function fetchModels(
apiKey: string,
authIndex?: string,
customHeaders: Record<string, string> = {},
timeoutMs?: number,
): Promise<ModelOption[]> {
const normalized = baseUrl.trim() ? normalizeBaseUrl(baseUrl) : '';
const candidates = endpointCandidates(provider, normalized);
@@ -130,7 +133,7 @@ export async function fetchModels(
method: 'GET',
url: pageUrl.toString(),
header: Object.keys(headers).length ? headers : undefined,
});
}, { timeoutMs });
const status = Number(response.status_code ?? response.statusCode ?? 0);
if (status < 200 || status >= 300) {
lastError = apiCallErrorMessage(response);
@@ -165,7 +168,7 @@ export async function fetchModels(
const response = await managementApi.post<Record<string, unknown>>('/api-call', {
method: 'GET',
url,
});
}, { timeoutMs });
const status = Number(response.status_code ?? response.statusCode ?? 0);
if (status >= 200 && status < 300) {
const models = normalizeModelList(response.body ?? response.bodyText);
@@ -179,7 +182,7 @@ export async function fetchModels(
const response = await managementApi.post<Record<string, unknown>>('/api-call', {
method: 'GET',
url,
});
}, { timeoutMs });
const status = Number(response.status_code ?? response.statusCode ?? 0);
if (status >= 200 && status < 300) {
const models = normalizeModelList(response.body ?? response.bodyText);
+274
View File
@@ -0,0 +1,274 @@
import { invoke } from '@tauri-apps/api/core';
import { normalizeBaseUrl, type ModelOption, type ModelProvider } from './modelService';
export const PROVIDER_HEALTH_TIMEOUT_MS = 15_000;
export const PROVIDER_HEALTH_CONCURRENCY = 4;
export type ProviderHealthProbe = {
url: string;
header: Record<string, string>;
data: string;
protocol: 'openai-chat' | 'openai-responses' | 'claude' | 'gemini';
};
export type ProviderHealthProbeResult = {
success: boolean;
firstTokenLatencyMs?: number;
error?: string;
timedOut?: boolean;
errorCode?: 'missing-direct-key';
};
export type ProviderModelHealthResult = ProviderHealthProbeResult & {
model: string;
status: 'healthy' | 'failed';
};
export type ProviderHealthCheckOptions = {
provider: ModelProvider;
baseUrl: string;
apiKeys: string[];
authIndex?: string;
customHeaders?: Record<string, string>;
timeoutMs?: number;
};
const defaultBaseUrl = (provider: ModelProvider) => {
if (provider === 'claude') return 'https://api.anthropic.com';
if (provider === 'gemini') return 'https://generativelanguage.googleapis.com';
return '';
};
const endpointRoot = (provider: ModelProvider, baseUrl: string) => {
const normalized = normalizeBaseUrl(baseUrl.trim() || defaultBaseUrl(provider));
return normalized.replace(/\/(?:v1beta|v1)$/i, '');
};
const hasHeader = (headers: Record<string, string>, name: string) =>
Object.keys(headers).some((key) => key.toLowerCase() === name.toLowerCase());
const headerValue = (headers: Record<string, string>, name: string) =>
Object.entries(headers).find(([key]) => key.toLowerCase() === name.toLowerCase())?.[1] ?? '';
const setHeaderIfMissing = (
headers: Record<string, string>,
name: string,
value: string,
) => {
if (!hasHeader(headers, name)) headers[name] = value;
};
export function primaryProviderHealthCredential(apiKeys: string[]): string {
return apiKeys.map((key) => key.trim()).find(Boolean) ?? '';
}
export function mergeProviderHealthModels(
discoveredModels: ModelOption[],
configuredModels: ModelOption[],
testModel = '',
): ModelOption[] {
const models = new Map<string, ModelOption>();
[...discoveredModels, ...configuredModels].forEach((model) => {
const name = model.name.trim();
if (!name) return;
const key = name.toLowerCase();
models.set(key, { ...models.get(key), ...model, name });
});
const normalizedTestModel = testModel.trim();
if (normalizedTestModel && !models.has(normalizedTestModel.toLowerCase())) {
models.set(normalizedTestModel.toLowerCase(), { name: normalizedTestModel });
}
return Array.from(models.values()).sort((left, right) =>
left.name.localeCompare(right.name, undefined, { sensitivity: 'base' }),
);
}
export function buildProviderHealthProbe(
provider: ModelProvider,
baseUrl: string,
model: string,
apiKey: string,
authIndex = '',
customHeaders: Record<string, string> = {},
): ProviderHealthProbe {
const root = endpointRoot(provider, baseUrl);
const headers = { ...customHeaders };
const key = apiKey.trim();
setHeaderIfMissing(headers, 'Content-Type', 'application/json');
if (provider === 'gemini') {
if (key) setHeaderIfMissing(headers, 'x-goog-api-key', key);
else if (authIndex) setHeaderIfMissing(headers, 'x-goog-api-key', '$TOKEN$');
const normalizedModel = model.trim().replace(/^models\//i, '');
return {
url: `${root}/v1beta/models/${encodeURIComponent(normalizedModel)}:generateContent?alt=sse`,
header: headers,
protocol: 'gemini',
data: JSON.stringify({
contents: [{ parts: [{ text: 'hi' }] }],
generationConfig: { maxOutputTokens: 16 },
}),
};
}
if (provider === 'claude') {
const bearerToken = headerValue(headers, 'authorization').match(/^Bearer\s+(.+)$/i)?.[1]?.trim() ?? '';
if (key) setHeaderIfMissing(headers, 'x-api-key', key);
else if (bearerToken) setHeaderIfMissing(headers, 'x-api-key', bearerToken);
else if (authIndex) setHeaderIfMissing(headers, 'x-api-key', '$TOKEN$');
setHeaderIfMissing(headers, 'anthropic-version', '2023-06-01');
return {
url: `${root}/v1/messages`,
header: headers,
protocol: 'claude',
data: JSON.stringify({
model: model.trim(),
max_tokens: 16,
stream: true,
messages: [{ role: 'user', content: 'hi' }],
}),
};
}
if (key) setHeaderIfMissing(headers, 'Authorization', `Bearer ${key}`);
else if (authIndex) setHeaderIfMissing(headers, 'Authorization', 'Bearer $TOKEN$');
if (provider === 'codex') {
return {
url: `${root}/v1/responses`,
header: headers,
protocol: 'openai-responses',
data: JSON.stringify({
model: model.trim(),
input: 'hi',
stream: true,
}),
};
}
return {
url: `${root}/v1/chat/completions`,
header: headers,
protocol: 'openai-chat',
data: JSON.stringify({
model: model.trim(),
messages: [{ role: 'user', content: 'hi' }],
stream: true,
}),
};
}
const isTimeoutError = (message: string) =>
/timed?\s*out|timeout|deadline has elapsed|超时/i.test(message);
const errorMessage = (error: unknown) => {
if (error instanceof Error) return error.message.trim() || error.name;
return String(error).replace(/^Error:\s*/i, '').trim();
};
export async function checkProviderHealthProbe(
provider: ModelProvider,
baseUrl: string,
model: string,
apiKey: string,
authIndex = '',
customHeaders: Record<string, string> = {},
timeoutMs = PROVIDER_HEALTH_TIMEOUT_MS,
): Promise<ProviderHealthProbeResult> {
try {
const probe = buildProviderHealthProbe(
provider,
baseUrl,
model,
apiKey,
authIndex,
customHeaders,
);
if (Object.values(probe.header).some((value) => value.includes('$TOKEN$'))) {
return {
success: false,
errorCode: 'missing-direct-key',
};
}
const response = await invoke<{ firstTokenLatencyMs: number }>('provider_health_probe', {
request: {
protocol: probe.protocol,
timeoutMs,
data: probe.data,
header: probe.header,
url: probe.url,
},
});
return {
success: true,
firstTokenLatencyMs: Math.max(1, Math.round(response.firstTokenLatencyMs)),
};
} catch (error) {
const message = errorMessage(error);
return {
success: false,
error: message,
timedOut: isTimeoutError(message),
};
}
}
export async function checkProviderModelHealth(
options: ProviderHealthCheckOptions,
model: string,
): Promise<ProviderModelHealthResult> {
const result = await checkProviderHealthProbe(
options.provider,
options.baseUrl,
model,
primaryProviderHealthCredential(options.apiKeys),
options.authIndex,
options.customHeaders,
options.timeoutMs,
);
return {
...result,
model,
status: result.success ? 'healthy' : 'failed',
};
}
export async function runProviderModelHealthChecks(
models: ModelOption[],
checkModel: (model: ModelOption, index: number) => Promise<ProviderModelHealthResult>,
onModelChecked?: (result: ProviderModelHealthResult, index: number) => void,
concurrency = PROVIDER_HEALTH_CONCURRENCY,
): Promise<ProviderModelHealthResult[]> {
const results: ProviderModelHealthResult[] = new Array(models.length);
let nextIndex = 0;
const workerCount = Math.min(models.length, Math.max(1, Math.floor(concurrency)));
const runWorker = async () => {
while (nextIndex < models.length) {
const index = nextIndex;
nextIndex += 1;
const model = models[index];
if (!model) continue;
const result = await checkModel(model, index);
results[index] = result;
onModelChecked?.(result, index);
}
};
await Promise.all(Array.from({ length: workerCount }, runWorker));
return results;
}
export async function checkProviderModelsHealth(
options: ProviderHealthCheckOptions,
models: ModelOption[],
onModelChecked?: (result: ProviderModelHealthResult, index: number) => void,
concurrency = PROVIDER_HEALTH_CONCURRENCY,
): Promise<ProviderModelHealthResult[]> {
return runProviderModelHealthChecks(
models,
(model) => checkProviderModelHealth(options, model.name),
onModelChecked,
concurrency,
);
}
+16
View File
@@ -0,0 +1,16 @@
const MILLION = 1_000_000;
const BILLION = 1_000_000_000;
export const formatUsageNumber = (value: number, locale: string) => {
const amount = Number.isFinite(value) ? value : 0;
const absolute = Math.abs(amount);
const roundedMillions = Math.round((absolute / MILLION) * 10) / 10;
const useBillions = absolute >= BILLION || roundedMillions >= 1_000;
const divisor = useBillions ? BILLION : absolute >= MILLION ? MILLION : 1;
const unit = useBillions ? 'B' : absolute >= MILLION ? 'M' : '';
const formatted = new Intl.NumberFormat(locale, {
maximumFractionDigits: divisor === 1 ? 0 : 1,
}).format(amount / divisor);
return `${formatted}${unit}`;
};
+1073 -424
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -5,8 +5,8 @@ export type AppTheme = 'light' | 'dark';
const STORAGE_KEY = 'easy-cli-proxy-api.theme';
const WINDOW_BACKGROUND: Record<AppTheme, string> = {
light: '#faf9f5',
dark: '#101419',
light: '#f6f7f5',
dark: '#111412',
};
async function applyNativeTheme(theme: AppTheme): Promise<void> {
+19
View File
@@ -2,8 +2,10 @@ import { describe, expect, test } from 'bun:test';
import {
agentModelAlias,
filterAgentModels,
filterAgentModelsByAlias,
findAgentModel,
hasExactAgentModel,
resolveAgentModelForAliasMode,
resolveAgentModelSelection,
} from '../src/services/agentModelPicker';
@@ -49,4 +51,21 @@ describe('智能体模型选择器', () => {
expect(findAgentModel(models, 'codex')?.name).toBeUndefined();
expect(findAgentModel(models, 'gpt-5.2-codex')?.name).toBe('gpt-5.2-codex');
});
test('Claude Desktop 自定义映射只显示对应类型的模型', () => {
const mixedModels = [
{ name: 'gpt-original', alias: 'GPT Original', isAlias: false },
{ name: 'gpt-high', alias: 'gpt-original', isAlias: true },
{ name: 'claude-original' },
];
expect(filterAgentModelsByAlias(mixedModels, false).map((model) => model.name))
.toEqual(['gpt-original', 'claude-original']);
expect(filterAgentModelsByAlias(mixedModels, true).map((model) => model.name))
.toEqual(['gpt-high']);
expect(resolveAgentModelForAliasMode(mixedModels, 'gpt-original', true)).toBe('gpt-high');
expect(resolveAgentModelForAliasMode(mixedModels, 'gpt-high', false)).toBe('gpt-original');
expect(resolveAgentModelForAliasMode(mixedModels, 'claude-original', true)).toBe('gpt-high');
expect(resolveAgentModelForAliasMode([], 'gpt-original', true)).toBe('');
});
});
+10 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'bun:test';
import { clientApiProfiles } from '../src/services/clientAccess';
import { clientApiProfiles, webUiManagementUrl } from '../src/services/clientAccess';
describe('客户端 API 接入信息', () => {
it('生成 OpenAI、Claude 和 Gemini 三种正确格式', () => {
@@ -20,4 +20,13 @@ describe('客户端 API 接入信息', () => {
expect(openai.baseUrl).toBe('http://127.0.0.1:8317/v1');
expect(openai.lanUrl).toBeNull();
});
it('使用当前内核端口生成 WebUI 登录地址', () => {
expect(webUiManagementUrl(9527)).toBe(
'http://127.0.0.1:9527/management.html#/login',
);
expect(webUiManagementUrl(0)).toBe(
'http://127.0.0.1:8317/management.html#/login',
);
});
});
+21
View File
@@ -25,6 +25,27 @@ describe('i18n', () => {
.toBe('1.2.3 をインストールしています');
});
it('translates client API compatibility formats consistently', () => {
expect(translate('zh-CN', 'kernel.access.openaiDescription')).toBe('OpenAI兼容格式');
expect(translate('zh-TW', 'kernel.access.claudeDescription')).toBe('Anthropic相容格式');
expect(translate('ja', 'kernel.access.geminiDescription')).toBe('Gemini互換形式');
expect(translate('en', 'kernel.access.openaiDescription')).toBe('OpenAI-compatible format');
});
it('translates direct Codex launch actions', () => {
expect(translate('zh-CN', 'agents.launch.startCli')).toBe('启动 CLI');
expect(translate('zh-TW', 'agents.launch.startApp')).toBe('啟動 App');
expect(translate('ja', 'agents.launch.startCli')).toBe('CLI を起動');
expect(translate('en', 'agents.launch.startApp')).toBe('Start App');
});
it('uses the concise automatic session restoration label', () => {
expect(translate('zh-CN', 'agents.sessions.autoRepair')).toBe('自动恢复历史会话');
expect(translate('zh-TW', 'agents.sessions.autoRepair')).toBe('自動恢復歷史工作階段');
expect(translate('ja', 'agents.sessions.autoRepair')).toBe('過去のセッションを自動復元');
expect(translate('en', 'agents.sessions.autoRepair')).toBe('Automatically restore historical sessions');
});
it('uses each language native name independently of the active locale', () => {
expect(languageOptions).toEqual([
{ value: 'zh-CN', nativeLabel: '简体中文' },
+12
View File
@@ -1,6 +1,7 @@
import { describe, expect, test } from 'bun:test';
import { appUpdateIndicatorState } from '../src/appUpdateModel';
import { canOpenAppPage, isAlwaysAvailablePage } from '../src/navigation';
import { oauthSubpages } from '../src/oauthNavigation';
describe('首页、配置与版本管理导航', () => {
test('内核停止时首页、配置和版本管理始终可进入', () => {
@@ -19,6 +20,17 @@ describe('首页、配置与版本管理导航', () => {
});
});
describe('OAuth 子页面导航', () => {
test('认证文件和额度查询收纳在 OAuth 页面内', () => {
expect(oauthSubpages.map((page) => page.id)).toEqual(['login', 'authFiles', 'quota']);
expect(oauthSubpages.map((page) => page.labelKey)).toEqual([
'oauth.title',
'authFiles.title',
'quota.title',
]);
});
});
describe('软件更新导航提示点', () => {
test('有新版显示橙点,处理中的蓝点优先', () => {
expect(appUpdateIndicatorState(true, false)).toBe('available');
+37 -6
View File
@@ -3,19 +3,22 @@ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, expect, test } from 'bun:test';
import { generatePortableUpdateManifest } from '../scripts/generate-portable-update-manifest.mjs';
import {
generatePortableUpdateManifest,
LEGACY_UPDATE_BRIDGE_VERSION,
} from '../scripts/generate-portable-update-manifest.mjs';
describe('Windows 便携更新清单', () => {
test('URL、大小和哈希与实际上传资产一致', async () => {
const root = await mkdtemp(join(tmpdir(), 'easycli-manifest-test-'));
try {
const payloads = {
amd64: Buffer.from('amd64 portable updater'),
aarch64: Buffer.from('aarch64 portable updater'),
amd64: Buffer.from('amd64 full portable package'),
aarch64: Buffer.from('aarch64 full portable package'),
};
for (const [arch, contents] of Object.entries(payloads)) {
await writeFile(
join(root, `EasyCLIProxyAPI-update-v1.2.3-Windows-${arch}.zip`),
join(root, `EasyCLIProxyAPI-v1.2.3-Windows-${arch}.zip`),
contents,
);
}
@@ -34,7 +37,7 @@ describe('Windows 便携更新清单', () => {
for (const arch of ['amd64', 'aarch64'] as const) {
const asset = manifest.assets[`windows-${arch}`];
expect(asset.url).toBe(
`https://github.com/router-for-me/EasyCLIProxyAPI/releases/download/v1.2.3/EasyCLIProxyAPI-update-v1.2.3-Windows-${arch}.zip`,
`https://github.com/router-for-me/EasyCLIProxyAPI/releases/download/v1.2.3/EasyCLIProxyAPI-v1.2.3-Windows-${arch}.zip`,
);
expect(asset.sizeBytes).toBe(payloads[arch].byteLength);
expect(asset.sha256).toBe(createHash('sha256').update(payloads[arch]).digest('hex'));
@@ -48,7 +51,7 @@ describe('Windows 便携更新清单', () => {
const root = await mkdtemp(join(tmpdir(), 'easycli-manifest-missing-'));
try {
await writeFile(
join(root, 'EasyCLIProxyAPI-update-v1.2.3-Windows-amd64.zip'),
join(root, 'EasyCLIProxyAPI-v1.2.3-Windows-amd64.zip'),
'amd64',
);
await expect(generatePortableUpdateManifest({
@@ -61,4 +64,32 @@ describe('Windows 便携更新清单', () => {
await rm(root, { recursive: true, force: true });
}
});
test('v0.2.10 使用一次性旧版桥接资产,后续版本恢复完整包', async () => {
const root = await mkdtemp(join(tmpdir(), 'easycli-manifest-bridge-'));
try {
for (const arch of ['amd64', 'aarch64']) {
await writeFile(
join(root, `EasyCLIProxyAPI-update-v${LEGACY_UPDATE_BRIDGE_VERSION}-Windows-${arch}.zip`),
`legacy bridge ${arch}`,
);
}
const manifest = await generatePortableUpdateManifest({
directory: root,
output: join(root, 'portable-update-windows.json'),
repository: 'router-for-me/EasyCLIProxyAPI',
tag: `v${LEGACY_UPDATE_BRIDGE_VERSION}`,
publishedAt: '2026-08-02T00:00:00.000Z',
});
expect(manifest.assets['windows-amd64'].url).toEndWith(
'/EasyCLIProxyAPI-update-v0.2.10-Windows-amd64.zip',
);
expect(manifest.assets['windows-aarch64'].url).toEndWith(
'/EasyCLIProxyAPI-update-v0.2.10-Windows-aarch64.zip',
);
} finally {
await rm(root, { recursive: true, force: true });
}
});
});
+125
View File
@@ -0,0 +1,125 @@
import { describe, expect, it } from 'bun:test';
import {
buildProviderHealthProbe,
mergeProviderHealthModels,
primaryProviderHealthCredential,
runProviderModelHealthChecks,
} from '../src/services/providerHealthCheck';
describe('API 接入健康检测', () => {
it('合并发现模型、已配置模型和 test-model,并按名称去重', () => {
const models = mergeProviderHealthModels(
[{ name: 'model-b' }, { name: 'model-a' }],
[{ name: 'MODEL-A', alias: 'model-a-alias' }, { name: 'model-c' }],
'health-model',
);
expect(models).toEqual([
{ name: 'health-model' },
{ name: 'MODEL-A', alias: 'model-a-alias' },
{ name: 'model-b' },
{ name: 'model-c' },
]);
});
it('逐模型检测只使用当前接入的首个有效密钥', () => {
expect(primaryProviderHealthCredential(['', ' first-key ', 'second-key'])).toBe('first-key');
expect(primaryProviderHealthCredential([])).toBe('');
});
it('为 OpenAI 兼容接入构造最小 chat completions 请求并保留自定义头', () => {
const probe = buildProviderHealthProbe(
'openai',
'https://openrouter.example/api/v1',
'gpt-test',
'secret-key',
'',
{ 'X-Team': 'production' },
);
expect(probe.url).toBe('https://openrouter.example/api/v1/chat/completions');
expect(probe.header).toMatchObject({
Authorization: 'Bearer secret-key',
'Content-Type': 'application/json',
'X-Team': 'production',
});
expect(JSON.parse(probe.data)).toEqual({
model: 'gpt-test',
messages: [{ role: 'user', content: 'hi' }],
stream: true,
});
expect(probe.protocol).toBe('openai-chat');
});
it('不会覆盖供应商已有的自定义认证头', () => {
const probe = buildProviderHealthProbe(
'claude',
'',
'claude-test',
'new-key',
'',
{ 'X-Api-Key': 'custom-key', 'Anthropic-Version': 'custom-version' },
);
expect(probe.url).toBe('https://api.anthropic.com/v1/messages');
expect(probe.header['X-Api-Key']).toBe('custom-key');
expect(probe.header['Anthropic-Version']).toBe('custom-version');
expect(probe.header['x-api-key']).toBeUndefined();
expect(probe.header['anthropic-version']).toBeUndefined();
});
it('使用运行时凭据占位符构造 Codex responses 请求', () => {
const probe = buildProviderHealthProbe(
'codex',
'https://codex.example/v1/responses',
'gpt-5-codex',
'',
'runtime-auth-index',
);
expect(probe.url).toBe('https://codex.example/v1/responses');
expect(probe.header.Authorization).toBe('Bearer $TOKEN$');
expect(JSON.parse(probe.data)).toEqual({
model: 'gpt-5-codex',
input: 'hi',
stream: true,
});
expect(probe.protocol).toBe('openai-responses');
});
it('为 Gemini 使用默认地址并移除 models/ 前缀', () => {
const probe = buildProviderHealthProbe(
'gemini',
'',
'models/gemini-2.5-flash',
'gemini-key',
);
expect(probe.url).toBe(
'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?alt=sse',
);
expect(probe.header['x-goog-api-key']).toBe('gemini-key');
expect(probe.protocol).toBe('gemini');
});
it('检测全部时每个模型只请求一次并保持结果顺序', async () => {
const requested: string[] = [];
const results = await runProviderModelHealthChecks([
{ name: 'model-a' },
{ name: 'model-b' },
{ name: 'model-c' },
], async (model) => {
requested.push(model.name);
return {
model: model.name,
status: 'healthy',
success: true,
firstTokenLatencyMs: 120,
};
}, undefined, 2);
expect(requested.sort()).toEqual(['model-a', 'model-b', 'model-c']);
expect(results.map((result) => result.model)).toEqual(['model-a', 'model-b', 'model-c']);
});
});
+68 -1
View File
@@ -1,5 +1,9 @@
import { describe, expect, test } from 'bun:test';
import { thinkingAliasSourceKindLabel } from '../src/pages/ThinkingAliasesPage';
import {
combineModelAliasEntries,
combineModelAliasSources,
thinkingAliasSourceKindLabel,
} from '../src/pages/ThinkingAliasesPage';
describe('思考别名', () => {
test('区分同名模型的接入来源', () => {
@@ -9,3 +13,66 @@ describe('思考别名', () => {
expect(thinkingAliasSourceKindLabel('custom')).toBe('其他来源');
});
});
describe('统一模型别名列表', () => {
test('同时显示思考别名和 Fast 速度别名', () => {
const entries = combineModelAliasEntries(
[{
sourceModel: 'gpt-thinking',
alias: 'gpt-high',
effort: 'high',
provider: 'Provider B',
kind: 'openai-compatible',
}],
[{
sourceModel: 'gpt-fast-source',
alias: 'gpt-fast',
serviceTier: 'priority',
provider: 'Provider A',
kind: 'codex-api',
}],
);
expect(entries.map((entry) => [entry.alias, entry.effort, entry.serviceTier])).toEqual([
['gpt-fast', null, 'priority'],
['gpt-high', 'high', null],
]);
});
test('同一个别名可同时设置思考强度和 Fast', () => {
const identity = {
sourceModel: 'gpt-5.6-sol',
alias: 'gpt-5.6-sol-xhigh-fast',
provider: 'Codex OAuth',
kind: 'codex-oauth',
};
const entries = combineModelAliasEntries(
[{ ...identity, effort: 'xhigh' }],
[{ ...identity, serviceTier: 'priority' }],
);
expect(entries).toHaveLength(1);
expect(entries[0]).toMatchObject({ effort: 'xhigh', serviceTier: 'priority' });
});
test('速度模型与思考模型合并后保留思考能力标记', () => {
const baseSource = {
model: 'gpt-5.6-sol',
displayName: 'GPT-5.6 Sol',
provider: 'Codex OAuth',
kind: 'codex-oauth',
protocol: 'codex',
};
const sources = combineModelAliasSources(
[{ ...baseSource, id: 'reasoning-and-fast' }],
[
{ ...baseSource, id: 'reasoning-and-fast' },
{ ...baseSource, id: 'fast-only', model: 'gpt-speed-only' },
],
);
expect(sources).toHaveLength(2);
expect(sources.find((source) => source.id === 'reasoning-and-fast')?.supportsReasoning).toBe(true);
expect(sources.find((source) => source.id === 'fast-only')?.supportsReasoning).toBe(false);
});
});
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, test } from 'bun:test';
import { formatUsageNumber } from '../src/services/usageNumber';
describe('使用统计数量单位', () => {
test('百万以下保持普通数字格式', () => {
expect(formatUsageNumber(999_999, 'en-US')).toBe('999,999');
expect(formatUsageNumber(Number.NaN, 'en-US')).toBe('0');
});
test('百万级使用 M 并在达到阈值时自动切换为 B', () => {
expect(formatUsageNumber(1_000_000, 'en-US')).toBe('1M');
expect(formatUsageNumber(12_340_000, 'en-US')).toBe('12.3M');
expect(formatUsageNumber(999_949_999, 'en-US')).toBe('999.9M');
expect(formatUsageNumber(999_950_000, 'en-US')).toBe('1B');
expect(formatUsageNumber(1_250_000_000, 'en-US')).toBe('1.3B');
});
});