Merge pull request #78 from router-for-me/dev
Enhance settings, OAuth, and agent configurations with new features
This commit is contained in:
47 files changed
+10168
-1236
No files matched your search
@@ -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: >-
|
||||
|
||||
@@ -7,3 +7,4 @@ cpa-core/
|
||||
|
||||
*.log
|
||||
*.tmp
|
||||
misc/
|
||||
+7
-1
@@ -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 では、次のリリースパッケージをビルドします。
|
||||
|
||||
@@ -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
@@ -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 会构建以下发行包:
|
||||
|
||||
@@ -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)"
|
||||
@@ -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
@@ -1 +1 @@
|
||||
7.2.101
|
||||
7.2.112
|
||||
+1
-1
@@ -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
@@ -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}`,
|
||||
|
||||
@@ -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)'}`);
|
||||
Generated
+145
-2
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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!({
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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',
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
const phraseReplacements: ReadonlyArray<readonly [string, string]> = [
|
||||
['兼容格式', '相容格式'],
|
||||
['历史', '歷史'],
|
||||
['高级设置', '進階設定'],
|
||||
['繁体中文', '繁體中文'],
|
||||
['简体中文', '簡體中文'],
|
||||
['智能体', '智慧代理'],
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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>>>({});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
+2
-2
@@ -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> {
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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: '简体中文' },
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user