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

Enhance network configuration UI and improve API access features
This commit is contained in:
lzt404 authored and GitHub committed 2026-07-27 22:40:46 +08:00
commit af57aa1d2b
17 files changed
+1518 -211

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "cpa-gui",
"private": true,
"version": "0.2.7",
"version": "0.2.8",
"type": "module",
"packageManager": "bun@1.3.14",
"scripts": {
+1 -1
View File
@@ -582,7 +582,7 @@ checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636"
[[package]]
name = "cpa-gui"
version = "0.2.7"
version = "0.2.8"
dependencies = [
"base64 0.22.1",
"chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "cpa-gui"
version = "0.2.7"
version = "0.2.8"
description = "CPA desktop management shell"
authors = ["lzt404"]
edition = "2021"
+728 -9
View File
File diff suppressed because it is too large. Load diff
+109 -19
View File
@@ -98,6 +98,8 @@ pub(crate) struct UsageRecord {
#[serde(default)]
source: String,
#[serde(default)]
source_display: String,
#[serde(default)]
auth_index: String,
#[serde(default)]
failed: bool,
@@ -769,6 +771,7 @@ fn normalize_usage_record(value: Value, config: &GuiConfigFile) -> Result<UsageR
latency_ms: u64_field(object, "latency_ms"),
ttft_ms: optional_u64_field(object, "ttft_ms"),
source: string_field(object, "source").unwrap_or_default(),
source_display: String::new(),
auth_index: string_field(object, "auth_index").unwrap_or_default(),
failed: object
.get("failed")
@@ -959,23 +962,40 @@ fn load_usage_overview(
}
#[tauri::command]
pub(crate) fn get_usage_analysis(query: UsageQuery) -> Result<UsageAnalysis, String> {
pub(crate) fn get_usage_analysis(
query: UsageQuery,
gui_config_state: tauri::State<'_, GuiConfigState>,
) -> Result<UsageAnalysis, String> {
let connection = open_usage_database()?;
load_usage_analysis(&connection, &query)
let config = gui_config_state.snapshot()?;
load_usage_analysis(&connection, &query, &config)
}
fn load_usage_analysis(
connection: &Connection,
query: &UsageQuery,
config: &GuiConfigFile,
) -> Result<UsageAnalysis, String> {
Ok(UsageAnalysis {
models: load_simple_categories(connection, query, "model", "unknown")?,
providers: load_simple_categories(connection, query, "provider", "未知 Provider")?,
sources: load_simple_categories(connection, query, "source", "未知来源")?,
sources: load_source_categories(connection, query, config)?,
api_keys: load_api_key_categories(connection, query)?,
})
}
fn load_source_categories(
connection: &Connection,
query: &UsageQuery,
config: &GuiConfigFile,
) -> Result<Vec<UsageCategory>, String> {
let mut categories = load_simple_categories(connection, query, "source", "未知来源")?;
for category in &mut categories {
category.label = usage_source_display(config, "", &category.key);
}
Ok(categories)
}
fn load_simple_categories(
connection: &Connection,
query: &UsageQuery,
@@ -1047,17 +1067,7 @@ fn load_api_key_categories(
let key = row.get::<_, String>(0)?;
let remark = row.get::<_, String>(1)?;
let display = row.get::<_, String>(2)?;
let label = if !remark.is_empty() {
if display.is_empty() {
remark
} else {
format!("{remark} · {display}")
}
} else if !display.is_empty() {
display
} else {
"未记录密钥".to_string()
};
let label = api_key_category_label(remark, display);
Ok(UsageCategory {
key,
label,
@@ -1073,14 +1083,19 @@ fn load_api_key_categories(
}
#[tauri::command]
pub(crate) fn get_usage_events(query: UsageQuery) -> Result<UsageEventPage, String> {
pub(crate) fn get_usage_events(
query: UsageQuery,
gui_config_state: tauri::State<'_, GuiConfigState>,
) -> Result<UsageEventPage, String> {
let connection = open_usage_database()?;
load_usage_events(&connection, &query)
let config = gui_config_state.snapshot()?;
load_usage_events(&connection, &query, &config)
}
fn load_usage_events(
connection: &Connection,
query: &UsageQuery,
config: &GuiConfigFile,
) -> Result<UsageEventPage, String> {
let filter = build_usage_filter(query);
let total_sql = format!("SELECT COUNT(*) FROM usage_events{}", filter.clause);
@@ -1117,11 +1132,14 @@ fn load_usage_events(
let mut statement = connection
.prepare(&sql)
.map_err(|error| format!("准备 SQLite 使用事件查询失败: {error}"))?;
let items = statement
let mut items = statement
.query_map(params_from_iter(values.iter()), usage_record_from_row)
.map_err(|error| format!("查询 SQLite 使用事件失败: {error}"))?
.collect::<Result<Vec<_>, _>>()
.map_err(|error| format!("读取 SQLite 使用事件失败: {error}"))?;
for item in &mut items {
item.source_display = usage_source_display(config, &item.provider, &item.source);
}
Ok(UsageEventPage {
items,
total,
@@ -1138,6 +1156,7 @@ fn usage_record_from_row(row: &Row<'_>) -> rusqlite::Result<UsageRecord> {
latency_ms: from_sql_i64(row.get(2)?),
ttft_ms: row.get::<_, Option<i64>>(3)?.map(from_sql_i64),
source: row.get(4)?,
source_display: String::new(),
auth_index: row.get(5)?,
failed: row.get::<_, i64>(6)? != 0,
provider: row.get(7)?,
@@ -1296,6 +1315,38 @@ fn mask_api_key(value: &str) -> String {
format!("{start}••••{end}")
}
fn api_key_category_label(remark: String, display: String) -> String {
if !remark.is_empty() {
remark
} else if !display.is_empty() {
mask_api_key(&display)
} else {
"未记录密钥".to_string()
}
}
fn usage_source_display(config: &GuiConfigFile, provider: &str, source: &str) -> String {
let source = source.trim();
if source.is_empty() {
return "未知来源".to_string();
}
if let Some(remark) = config.api_access_remark_for_source(provider, source) {
return remark.to_string();
}
let character_count = source.chars().count();
let looks_like_secret = !source.contains('@')
&& !source.chars().any(char::is_whitespace)
&& (source.starts_with("sk-")
|| source.starts_with("AIza")
|| source.starts_with("key-")
|| character_count >= 48);
if looks_like_secret {
mask_api_key(source)
} else {
source.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1331,6 +1382,7 @@ mod tests {
latency_ms: 100,
ttft_ms: Some(20),
source: "source".to_string(),
source_display: String::new(),
auth_index: "auth".to_string(),
failed: false,
provider: "openai".to_string(),
@@ -1363,6 +1415,39 @@ mod tests {
assert_eq!(mask_api_key("sk-1234567890"), "sk-1••••7890");
}
#[test]
fn api_key_category_uses_either_remark_or_masked_key() {
assert_eq!(
api_key_category_label("生产环境".to_string(), "12••••".to_string()),
"生产环境"
);
assert_eq!(
api_key_category_label(String::new(), "123456".to_string()),
"12••••"
);
}
#[test]
fn usage_source_prefers_api_access_remark_and_masks_secret_fallbacks() {
let key = "sk-1234567890abcdefghijklmnopqrstuvwxyz";
let mut config = GuiConfigFile::default();
config.api_access_remarks.push(crate::GuiApiAccessRemark {
provider_section: "codex-api-key".to_string(),
api_key_hash: hash_text(key),
remark: "生产环境".to_string(),
});
assert_eq!(usage_source_display(&config, "codex", key), "生产环境");
assert_eq!(
usage_source_display(&GuiConfigFile::default(), "codex", key),
"sk-1••••wxyz"
);
assert_eq!(
usage_source_display(&config, "codex", "account@example.com"),
"account@example.com"
);
}
#[test]
fn local_hour_key_uses_year_month_day_and_hour() {
let record = sample_record("id", "2026-07-17T20:30:00+08:00", "model");
@@ -1381,10 +1466,14 @@ mod tests {
window_height: None,
auth_dir: String::new(),
api_keys: Vec::new(),
api_access_remarks: Vec::new(),
management_secret_key: "123456".to_string(),
usage_statistics_enabled: true,
plugins_enabled: false,
routing_strategy: "round-robin".to_string(),
proxy_url: String::new(),
routing_session_affinity: false,
routing_session_affinity_ttl: String::new(),
codex_session_repair_on_launch: false,
};
let record = normalize_usage_record(
@@ -1519,8 +1608,9 @@ mod tests {
};
let overview = load_usage_overview(&connection, &query).unwrap();
let analysis = load_usage_analysis(&connection, &query).unwrap();
let events = load_usage_events(&connection, &query).unwrap();
let config = GuiConfigFile::default();
let analysis = load_usage_analysis(&connection, &query, &config).unwrap();
let events = load_usage_events(&connection, &query, &config).unwrap();
assert_eq!(overview.total_requests, 1);
assert_eq!(overview.failure_count, 1);
+2 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "EasyCLIProxyAPI",
"version": "0.2.7",
"version": "0.2.8",
"identifier": "com.cpa.gui",
"build": {
"beforeDevCommand": "bun run dev",
@@ -14,7 +14,7 @@
{
"label": "main",
"title": "EasyCLIProxyAPI",
"width": 1380,
"width": 1500,
"height": 815,
"minWidth": 640,
"minHeight": 600
+33 -4
View File
@@ -12,7 +12,7 @@ export const jaOverrides = {
'app.nav.oauth': 'OAuth',
'app.nav.api': 'API 接続',
'app.nav.authFiles': '認証ファイル',
'app.nav.quota': 'クォータ',
'app.nav.quota': 'クォータ照会',
'app.nav.usageRecords': '使用履歴',
'app.nav.agents': 'エージェント',
'app.coreRequired.title': '先にコアを起動してください',
@@ -28,7 +28,7 @@ export const jaOverrides = {
'error.render.title': 'ページの描画中にエラーが発生しました',
'error.unknown': '不明なエラー',
'error.reload': 'ページを再読み込み',
'quota.title': 'クォータ',
'quota.title': 'クォータ照会',
'quota.queryableCredentials.one': '照会可能な認証情報 {count} 件',
'quota.queryableCredentials.other': '照会可能な認証情報 {count} 件',
'quota.credentials.one': '認証情報 {count} 件',
@@ -220,6 +220,9 @@ export const jaOverrides = {
'config.error.invalidKey': '空白を含まない表示可能な ASCII 文字のみ使用できます',
'config.error.duplicateKey': '同じ認証キーがすでに存在します',
'config.error.remarkTooLong': 'メモは 80 文字以内で入力してください',
'config.error.portRange': 'ポートは 1〜65535 の整数で入力してください',
'config.error.networkRestartFailed': '設定は保存されましたが、コアの再起動に失敗しました:{error}',
'config.error.saveFailed': '保存に失敗しました:{error}',
'config.notice.keyAdded': '認証キーを追加しました',
'config.notice.keyUpdated': '認証キーを更新しました',
'config.notice.keyDeleted': '認証キーを削除しました',
@@ -228,6 +231,13 @@ export const jaOverrides = {
'config.notice.pluginsEnabled': 'プラグインシステムを有効にしました',
'config.notice.pluginsDisabled': 'プラグインシステムを無効にしました',
'config.notice.routingUpdated': 'ルーティング方式を更新しました',
'config.notice.networkUpdated': 'ネットワークとセッションルーティング設定を保存しました',
'config.notice.networkRestarted': 'ネットワーク設定を保存し、コアを再起動しました',
'config.notice.networkNextStart': 'ネットワーク設定を保存しました。次回のコア起動時に反映されます',
'config.notice.proxyUpdated': 'プロキシ URL を保存しました',
'config.notice.sessionAffinityEnabled': 'セッションアフィニティを有効にしました',
'config.notice.sessionAffinityDisabled': 'セッションアフィニティを無効にしました',
'config.notice.sessionTtlUpdated': 'セッションアフィニティ TTL を保存しました',
'config.keys.title': '認証キー',
'config.keys.count': 'キー数',
'config.keys.add': '認証キーを追加',
@@ -248,6 +258,22 @@ export const jaOverrides = {
'common.disabled': '無効',
'config.plugins.enable': 'プラグインシステムを有効にする',
'config.routing.title': 'ルーティング方式',
'config.network.title': 'ネットワークとセッションルーティング',
'config.network.saving': '保存中',
'config.network.saved': '保存済み',
'config.network.unsaved': '未保存の変更あり',
'config.network.confirmSave': '確認して保存',
'config.network.port': 'ポート',
'config.network.portHint': '範囲は 1〜65535。コア実行中の変更時は自動的に再起動します',
'config.network.allowLan': 'LAN アクセスを許可',
'config.network.allowLanHint': 'LAN 内の他のデバイスからこのプロキシへのアクセスを許可します',
'config.network.proxyUrl': 'プロキシ URL',
'config.network.proxyPlaceholder': '例:socks5://127.0.0.1:7890',
'config.network.proxyHint': 'グローバル上流プロキシ。空欄の場合は使用しません',
'config.network.sessionAffinity': 'セッションアフィニティ',
'config.network.sessionAffinityHint': '同じセッションでは以前選択した認証情報を優先します',
'config.network.sessionTtl': 'セッションアフィニティ TTL',
'config.network.sessionTtlHint': '例:1h。空欄の場合はコアのデフォルト値を使用します',
'config.keys.addTitle': '認証キーを追加',
'config.keys.editTitle': '認証キーを編集',
'common.close': '閉じる',
@@ -503,6 +529,7 @@ export const jaOverrides = {
'apiAccess.error.noModels': '公開可能なモデルが見つかりません。Base URL と API キーを確認してください',
'apiAccess.error.stale': '接続設定が別の操作で変更されました。更新してから再試行してください',
'apiAccess.error.duplicate': '同じ接続設定がすでに存在します',
'apiAccess.error.remarkInvalid': 'メモは 80 文字以内で、改行や制御文字を含めないでください',
'apiAccess.notice.updated': '接続を更新しました',
'apiAccess.notice.added': '接続を追加しました',
'apiAccess.deleteConfirm': '「{name}」を削除しますか?',
@@ -521,6 +548,7 @@ export const jaOverrides = {
'apiAccess.empty.addFirst': '右上の「追加」から最初の接続を追加してください',
'apiAccess.keys.count': 'キー {count} 件',
'apiAccess.keys.summary': '{key} · 合計 {count} 件',
'apiAccess.remarkValue': 'メモ:{remark}',
'apiAccess.defaultUrl': '既定のアドレスを使用',
'apiAccess.models.summary': 'モデル {count} 件 · {models}',
'apiAccess.priorityValue': '優先度 {priority}',
@@ -546,6 +574,8 @@ export const jaOverrides = {
'apiAccess.field.name': '名前',
'apiAccess.field.keysMany': 'API キー(1 行に 1 件)',
'apiAccess.field.key': 'API キー',
'apiAccess.field.remark': 'メモ',
'apiAccess.remarkPlaceholder': '例:本番環境、チームのメインアカウント',
'apiAccess.baseRequiredPlaceholder': '必須、例:https://api.example.com',
'apiAccess.baseOptionalPlaceholder': '任意。空欄の場合は既定のアドレスを使用',
'apiAccess.preset.title': 'OpenAI 互換プリセット',
@@ -643,8 +673,7 @@ export const jaOverrides = {
'appUpdate.current': '現在のバージョン',
'appUpdate.latest': '最新バージョン',
'appUpdate.status': '更新状態',
'appUpdate.portableReady': 'ポータブル版の自動更新に対応',
'appUpdate.manualOnly': 'このビルドは手動更新が必要です',
'appUpdate.manualFallback': '検出に失敗した場合は、GitHub ページから手動で更新してください',
'appUpdate.upToDate': '最新バージョンです',
'appUpdate.available': 'バージョン {version} を利用できます',
'appUpdate.check': '更新を確認',
+66 -8
View File
@@ -15,7 +15,7 @@ export const zhCN = {
'app.nav.oauth': 'OAuth',
'app.nav.api': 'API 接入',
'app.nav.authFiles': '认证文件',
'app.nav.quota': '配额',
'app.nav.quota': '额度查询',
'app.nav.usageRecords': '使用记录',
'app.nav.agents': '智能体',
'app.coreRequired.title': '请先启动内核',
@@ -31,7 +31,7 @@ export const zhCN = {
'error.render.title': '页面渲染出现异常',
'error.unknown': '未知错误',
'error.reload': '重新加载页面',
'quota.title': '配额',
'quota.title': '额度查询',
'quota.queryableCredentials.one': '{count} 个可查询凭据',
'quota.queryableCredentials.other': '{count} 个可查询凭据',
'quota.credentials.one': '{count} 个凭据',
@@ -223,6 +223,9 @@ export const zhCN = {
'config.error.invalidKey': '只能使用 ASCII 可见字符,且不能包含空格',
'config.error.duplicateKey': '该鉴权密钥已经存在',
'config.error.remarkTooLong': '备注不能超过 80 个字符',
'config.error.portRange': '端口必须是 1–65535 之间的整数',
'config.error.networkRestartFailed': '设置已保存,但内核重启失败:{error}',
'config.error.saveFailed': '保存失败:{error}',
'config.notice.keyAdded': '鉴权密钥已新增',
'config.notice.keyUpdated': '鉴权密钥已更新',
'config.notice.keyDeleted': '鉴权密钥已删除',
@@ -231,6 +234,13 @@ export const zhCN = {
'config.notice.pluginsEnabled': '插件系统已启用',
'config.notice.pluginsDisabled': '插件系统已停用',
'config.notice.routingUpdated': '路由策略已更新',
'config.notice.networkUpdated': '网络与会话路由设置已保存',
'config.notice.networkRestarted': '网络设置已保存,内核已重启',
'config.notice.networkNextStart': '网络设置已保存,将在下次启动内核时生效',
'config.notice.proxyUpdated': '代理 URL 已保存',
'config.notice.sessionAffinityEnabled': '会话粘性路由已启用',
'config.notice.sessionAffinityDisabled': '会话粘性路由已停用',
'config.notice.sessionTtlUpdated': '会话粘性 TTL 已保存',
'config.keys.title': '鉴权密钥',
'config.keys.count': '密钥数量',
'config.keys.add': '新增鉴权密钥',
@@ -251,6 +261,22 @@ export const zhCN = {
'common.disabled': '已停用',
'config.plugins.enable': '启用插件系统',
'config.routing.title': '路由策略',
'config.network.title': '网络与会话路由',
'config.network.saving': '正在保存',
'config.network.saved': '已保存',
'config.network.unsaved': '有未保存修改',
'config.network.confirmSave': '确认保存',
'config.network.port': '端口',
'config.network.portHint': '范围 1–65535;内核运行时修改会自动重启',
'config.network.allowLan': '允许局域网',
'config.network.allowLanHint': '允许其他局域网设备访问本机代理',
'config.network.proxyUrl': '代理 URL',
'config.network.proxyPlaceholder': '例如 socks5://127.0.0.1:7890',
'config.network.proxyHint': '全局上游代理;留空表示不使用',
'config.network.sessionAffinity': '会话粘性路由',
'config.network.sessionAffinityHint': '同一会话优先复用已选择的凭据',
'config.network.sessionTtl': '会话粘性 TTL',
'config.network.sessionTtlHint': '例如 1h;留空使用内核默认值',
'config.keys.addTitle': '新增鉴权密钥',
'config.keys.editTitle': '编辑鉴权密钥',
'common.close': '关闭',
@@ -506,6 +532,7 @@ export const zhCN = {
'apiAccess.error.noModels': '没有发现可放行的模型,请确认 Base URL 和 API 密钥',
'apiAccess.error.stale': '接入配置已被其他操作修改,请刷新后重试',
'apiAccess.error.duplicate': '相同的接入配置已经存在',
'apiAccess.error.remarkInvalid': '备注不能超过 80 个字符,且不能包含换行或控制字符',
'apiAccess.notice.updated': '接入已更新',
'apiAccess.notice.added': '接入已新增',
'apiAccess.deleteConfirm': '确定删除“{name}”吗?',
@@ -524,6 +551,7 @@ export const zhCN = {
'apiAccess.empty.addFirst': '点击右上角“新增”添加第一个接入',
'apiAccess.keys.count': '{count} 个密钥',
'apiAccess.keys.summary': '{key} · 共 {count} 个密钥',
'apiAccess.remarkValue': '备注:{remark}',
'apiAccess.defaultUrl': '使用默认地址',
'apiAccess.models.summary': '模型 {count} 个 · {models}',
'apiAccess.priorityValue': '优先级 {priority}',
@@ -549,6 +577,8 @@ export const zhCN = {
'apiAccess.field.name': '名称',
'apiAccess.field.keysMany': 'API 密钥(每行一个)',
'apiAccess.field.key': 'API 密钥',
'apiAccess.field.remark': '备注',
'apiAccess.remarkPlaceholder': '例如:生产环境、团队主账号',
'apiAccess.baseRequiredPlaceholder': '必填,例如 https://api.example.com',
'apiAccess.baseOptionalPlaceholder': '可选,留空使用默认地址',
'apiAccess.preset.title': 'OpenAI 兼容预设',
@@ -646,8 +676,7 @@ export const zhCN = {
'appUpdate.current': '当前版本',
'appUpdate.latest': '最新版本',
'appUpdate.status': '升级状态',
'appUpdate.portableReady': '支持便携版自动升级',
'appUpdate.manualOnly': '当前版本需要手动更新',
'appUpdate.manualFallback': '如果检测失败,请手动到GitHub页面更新',
'appUpdate.upToDate': '已是最新版本',
'appUpdate.available': '发现新版本 {version}',
'appUpdate.check': '检查更新',
@@ -802,7 +831,7 @@ export const en: Record<MessageKey, string> = {
'app.nav.oauth': 'OAuth',
'app.nav.api': 'API Access',
'app.nav.authFiles': 'Auth Files',
'app.nav.quota': 'Quota',
'app.nav.quota': 'Quota Lookup',
'app.nav.usageRecords': 'Usage',
'app.nav.agents': 'Agents',
'app.coreRequired.title': 'Start the core first',
@@ -818,7 +847,7 @@ export const en: Record<MessageKey, string> = {
'error.render.title': 'Something went wrong while rendering this page',
'error.unknown': 'Unknown error',
'error.reload': 'Reload Page',
'quota.title': 'Quota',
'quota.title': 'Quota Lookup',
'quota.queryableCredentials.one': '{count} queryable credential',
'quota.queryableCredentials.other': '{count} queryable credentials',
'quota.credentials.one': '{count} credential',
@@ -1010,6 +1039,9 @@ export const en: Record<MessageKey, string> = {
'config.error.invalidKey': 'Use visible ASCII characters only, without spaces',
'config.error.duplicateKey': 'This authentication key already exists',
'config.error.remarkTooLong': 'The remark cannot exceed 80 characters',
'config.error.portRange': 'The port must be an integer from 1 to 65535',
'config.error.networkRestartFailed': 'Settings were saved, but the core failed to restart: {error}',
'config.error.saveFailed': 'Failed to save: {error}',
'config.notice.keyAdded': 'Authentication key added',
'config.notice.keyUpdated': 'Authentication key updated',
'config.notice.keyDeleted': 'Authentication key deleted',
@@ -1018,6 +1050,13 @@ export const en: Record<MessageKey, string> = {
'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',
'config.notice.networkNextStart': 'Network settings saved and will apply on the next core start',
'config.notice.proxyUpdated': 'Proxy URL saved',
'config.notice.sessionAffinityEnabled': 'Session affinity enabled',
'config.notice.sessionAffinityDisabled': 'Session affinity disabled',
'config.notice.sessionTtlUpdated': 'Session affinity TTL saved',
'config.keys.title': 'Authentication Keys',
'config.keys.count': 'Key count',
'config.keys.add': 'Add Authentication Key',
@@ -1038,6 +1077,22 @@ export const en: Record<MessageKey, string> = {
'common.disabled': 'Disabled',
'config.plugins.enable': 'Enable plugin system',
'config.routing.title': 'Routing Strategy',
'config.network.title': 'Network & Session Routing',
'config.network.saving': 'Saving',
'config.network.saved': 'Saved',
'config.network.unsaved': 'Unsaved changes',
'config.network.confirmSave': 'Confirm & Save',
'config.network.port': 'Port',
'config.network.portHint': 'Range 1–65535; changing it restarts a running core',
'config.network.allowLan': 'Allow LAN',
'config.network.allowLanHint': 'Allow other devices on the LAN to access this proxy',
'config.network.proxyUrl': 'Proxy URL',
'config.network.proxyPlaceholder': 'For example, socks5://127.0.0.1:7890',
'config.network.proxyHint': 'Global upstream proxy; leave blank to disable',
'config.network.sessionAffinity': 'Session Affinity',
'config.network.sessionAffinityHint': 'Prefer the previously selected credential for the same session',
'config.network.sessionTtl': 'Session Affinity TTL',
'config.network.sessionTtlHint': 'For example, 1h; leave blank to use the core default',
'config.keys.addTitle': 'Add Authentication Key',
'config.keys.editTitle': 'Edit Authentication Key',
'common.close': 'Close',
@@ -1293,6 +1348,7 @@ export const en: Record<MessageKey, string> = {
'apiAccess.error.noModels': 'No models were discovered. Check the Base URL and API key.',
'apiAccess.error.stale': 'The connection configuration changed elsewhere. Refresh and try again.',
'apiAccess.error.duplicate': 'An identical connection already exists',
'apiAccess.error.remarkInvalid': 'The remark cannot exceed 80 characters or contain line breaks or control characters',
'apiAccess.notice.updated': 'Connection updated',
'apiAccess.notice.added': 'Connection added',
'apiAccess.deleteConfirm': 'Delete “{name}”?',
@@ -1311,6 +1367,7 @@ export const en: Record<MessageKey, string> = {
'apiAccess.empty.addFirst': 'Click “Add” in the upper-right to create your first connection',
'apiAccess.keys.count': '{count} keys',
'apiAccess.keys.summary': '{key} · {count} keys total',
'apiAccess.remarkValue': 'Remark: {remark}',
'apiAccess.defaultUrl': 'Using default address',
'apiAccess.models.summary': '{count} models · {models}',
'apiAccess.priorityValue': 'Priority {priority}',
@@ -1336,6 +1393,8 @@ export const en: Record<MessageKey, string> = {
'apiAccess.field.name': 'Name',
'apiAccess.field.keysMany': 'API Keys (one per line)',
'apiAccess.field.key': 'API Key',
'apiAccess.field.remark': 'Remark',
'apiAccess.remarkPlaceholder': 'For example, Production or Team primary account',
'apiAccess.baseRequiredPlaceholder': 'Required, for example https://api.example.com',
'apiAccess.baseOptionalPlaceholder': 'Optional; leave blank to use the default address',
'apiAccess.preset.title': 'OpenAI-Compatible Preset',
@@ -1433,8 +1492,7 @@ export const en: Record<MessageKey, string> = {
'appUpdate.current': 'Current Version',
'appUpdate.latest': 'Latest Version',
'appUpdate.status': 'Update Status',
'appUpdate.portableReady': 'Portable auto-update supported',
'appUpdate.manualOnly': 'This build requires a manual update',
'appUpdate.manualFallback': 'If detection fails, update manually from the GitHub page',
'appUpdate.upToDate': 'Up to Date',
'appUpdate.available': 'Version {version} is available',
'appUpdate.check': 'Check for Updates',
+1 -1
View File
@@ -1,4 +1,4 @@
const alwaysAvailablePages = new Set(['home', 'versions']);
const alwaysAvailablePages = new Set(['home', 'versions', 'config']);
export function isAlwaysAvailablePage(pageId: string) {
return alwaysAvailablePages.has(pageId);
+15 -6
View File
@@ -511,6 +511,7 @@ export function AgentsPage() {
const [closeAppNotice, setCloseAppNotice] = useState('');
const [closeAppConfirmOpen, setCloseAppConfirmOpen] = useState(false);
const [oauthConfiguration, setOauthConfiguration] = useState(false);
const autoLoadedModelClientRef = useRef<AgentClientId | null>(null);
const loadStatuses = useCallback(async (forceRefresh = false) => {
const command = forceRefresh
@@ -543,21 +544,27 @@ export function AgentsPage() {
setLoading(true);
setDetectionError('');
try {
await Promise.all([loadStatuses(true), loadModels()]);
await loadStatuses(true);
} catch (requestError) {
setDetectionError(String(requestError));
} finally {
setLoading(false);
}
}, [loadModels, loadStatuses]);
}, [loadStatuses]);
useEffect(() => {
setLoading(true);
setDetectionError('');
void Promise.all([loadStatuses(), loadModels()])
void loadStatuses()
.catch((requestError) => setDetectionError(String(requestError)))
.finally(() => setLoading(false));
}, [loadModels, loadStatuses]);
}, [loadStatuses]);
useEffect(() => {
if (autoLoadedModelClientRef.current === selected) return;
autoLoadedModelClientRef.current = selected;
void loadModels();
}, [loadModels, selected]);
useEffect(() => {
let disposed = false;
@@ -565,7 +572,7 @@ export function AgentsPage() {
void listen('config-files-changed', () => {
if (disposed) return;
setDetectionError('');
void Promise.all([loadStatuses(true), loadModels()]).catch((requestError) => {
void loadStatuses(true).catch((requestError) => {
if (!disposed) setDetectionError(String(requestError));
});
}).then((unlisten) => {
@@ -576,7 +583,7 @@ export function AgentsPage() {
disposed = true;
stop?.();
};
}, [loadModels, loadStatuses]);
}, [loadStatuses]);
useEffect(() => {
writeSelectedAgentClient(selected);
@@ -724,6 +731,7 @@ export function AgentsPage() {
await invoke<AgentConfigActionResult>('apply_agent_config', {
client: selected,
model,
models,
oauthConfiguration,
});
await reloadStatusesAfterAction();
@@ -743,6 +751,7 @@ export function AgentsPage() {
await invoke<AgentConfigActionResult>('reset_agent_config_to_default', {
client: selected,
model,
models,
oauthConfiguration,
});
setDefaultConfirmOpen(false);
+131 -20
View File
@@ -1,4 +1,5 @@
import { FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import {
Check,
Edit3,
@@ -67,11 +68,13 @@ type ProviderRow = {
disabled: boolean;
priority: number | null;
authIndex: string;
remark: string;
};
export type ProviderDraft = {
name: string;
apiKey: string;
remark: string;
baseUrl: string;
priority: string;
models: ModelOption[];
@@ -182,9 +185,13 @@ const rowFromRecord = (
authIndex: entry
? readString(entry, 'auth-index', 'authIndex')
: readString(record, 'auth-index', 'authIndex'),
remark: '',
};
};
const providerRemarkIdentity = (section: ProviderSection, apiKeys: string[]) =>
`${section}\u0000${apiKeys.join('\u0000')}`;
export const stripResponseFields = (record: Record<string, unknown>) => {
const next = { ...record };
delete next['auth-index'];
@@ -316,6 +323,7 @@ const thinkingLevelsFromModels = (models: ModelOption[]): string[] => {
const draftFromRow = (row: ProviderRow): ProviderDraft => ({
name: row.name,
apiKey: definitionFor(row.section).openAi ? row.apiKeys.join('\n') : row.apiKey,
remark: row.remark,
baseUrl: row.baseUrl,
priority: row.priority === null ? '' : String(row.priority),
models: row.models,
@@ -351,6 +359,7 @@ const draftFromRow = (row: ProviderRow): ProviderDraft => ({
const emptyProviderDraft = (): ProviderDraft => ({
name: '',
apiKey: '',
remark: '',
baseUrl: '',
priority: '',
models: [],
@@ -529,7 +538,10 @@ export const buildProviderRecord = (
return applyAdvancedFields(next, section, draft);
};
const providerIdentityMatches = (row: ProviderRow, record: Record<string, unknown>) => {
const providerIdentityMatches = (
row: ProviderRecordIdentity,
record: Record<string, unknown>,
) => {
if (definitionFor(row.section).openAi) {
return readString(record, 'name') === row.name;
}
@@ -539,6 +551,56 @@ const providerIdentityMatches = (row: ProviderRow, record: Record<string, unknow
);
};
type ProviderRecordIdentity = Pick<
ProviderRow,
'section' | 'index' | 'name' | 'apiKey' | 'baseUrl'
>;
const providerPrimaryIdentityMatches = (
row: ProviderRecordIdentity,
record: Record<string, unknown>,
) => definitionFor(row.section).openAi
? readString(record, 'name') === row.name
: readString(record, 'api-key', 'apiKey') === row.apiKey;
export const resolveProviderRecordIndex = (
records: Record<string, unknown>[],
row: ProviderRecordIdentity,
) => {
const exactIndex = records.findIndex((record) => providerIdentityMatches(row, record));
if (exactIndex >= 0) return exactIndex;
const indexedRecord = records[row.index];
if (indexedRecord && providerPrimaryIdentityMatches(row, indexedRecord)) {
return row.index;
}
const primaryMatches = records
.map((record, index) => ({ record, index }))
.filter(({ record }) => providerPrimaryIdentityMatches(row, record));
return primaryMatches.length === 1 ? primaryMatches[0].index : -1;
};
export const providerRecordWithDisabledState = (
section: ProviderSection,
record: Record<string, unknown>,
disabled: boolean,
) => {
const nextRecord = stripResponseFields(record);
if (definitionFor(section).openAi) {
nextRecord.disabled = disabled;
return nextRecord;
}
const excludedModels = Array.isArray(nextRecord['excluded-models'])
? nextRecord['excluded-models'].map(String).filter((model) => model.trim() !== '*')
: [];
if (disabled) excludedModels.push('*');
if (excludedModels.length > 0) nextRecord['excluded-models'] = excludedModels;
else delete nextRecord['excluded-models'];
return nextRecord;
};
export function ApiAccessPage() {
const { t } = useI18n();
const [records, setRecords] = useState(emptyRecords);
@@ -551,6 +613,7 @@ export function ApiAccessPage() {
const [dialogOpen, setDialogOpen] = useState(false);
const [editingRow, setEditingRow] = useState<ProviderRow | null>(null);
const [dialogDraft, setDialogDraft] = useState<ProviderDraft>(emptyProviderDraft);
const [apiAccessRemarks, setApiAccessRemarks] = useState<Record<string, string>>({});
const activeDefinition = definitionFor(activeCategory);
const activeSection = activeDefinition.section;
@@ -594,20 +657,51 @@ export function ApiAccessPage() {
void loadProviders();
}, [loadProviders]);
useEffect(() => {
const providerRows = (Object.entries(records) as [ProviderSection, Record<string, unknown>[]][])
.flatMap(([section, items]) => items.map((record, index) => rowFromRecord(section, record, index)));
if (providerRows.length === 0) {
setApiAccessRemarks({});
return;
}
let disposed = false;
void invoke<string[]>('resolve_api_access_remarks', {
queries: providerRows.map((row) => ({
providerSection: row.section,
apiKeys: row.apiKeys,
})),
}).then((remarks) => {
if (disposed) return;
setApiAccessRemarks(Object.fromEntries(providerRows.map((row, index) => [
providerRemarkIdentity(row.section, row.apiKeys),
remarks[index] ?? '',
])));
}).catch(() => {
if (!disposed) setApiAccessRemarks({});
});
return () => {
disposed = true;
};
}, [records]);
const rows = useMemo(
() =>
records[activeSection]
.map((record, index) => rowFromRecord(activeSection, record, index))
.map((row) => ({
...row,
remark: apiAccessRemarks[providerRemarkIdentity(row.section, row.apiKeys)] ?? '',
}))
.filter((row) => providerCategoryMatchesRecord(activeCategory, row.record))
.filter((row) => {
const query = filter.trim().toLowerCase();
if (!query) return true;
return [row.name, row.apiKey, row.baseUrl, row.models.map((model) => model.name).join(' ')]
return [row.name, row.remark, row.apiKey, row.baseUrl, row.models.map((model) => model.name).join(' ')]
.join(' ')
.toLowerCase()
.includes(query);
}),
[activeCategory, activeSection, filter, records],
[activeCategory, activeSection, apiAccessRemarks, filter, records],
);
const openCreate = () => {
@@ -647,6 +741,10 @@ export function ApiAccessPage() {
);
return false;
}
if (Array.from(preparedDraft.remark.trim()).length > 80 || /[\u0000-\u001f\u007f]/.test(preparedDraft.remark)) {
setError(t('apiAccess.error.remarkInvalid'));
return false;
}
let baseUrl = preparedDraft.baseUrl.trim();
let providerHeaders: Record<string, string> = {};
try {
@@ -682,9 +780,7 @@ export function ApiAccessPage() {
let nextList: Record<string, unknown>[];
if (editingRow) {
const targetIndex = current.findIndex((record) =>
providerIdentityMatches(editingRow, record),
);
const targetIndex = resolveProviderRecordIndex(current, editingRow);
if (targetIndex < 0) {
throw new Error(t('apiAccess.error.stale'));
}
@@ -711,6 +807,14 @@ export function ApiAccessPage() {
}
await managementApi.put(`/${activeSection}`, nextList.map(stripResponseFields));
await invoke('save_api_access_remark', {
update: {
providerSection: activeSection,
previousApiKeys: editingRow?.apiKeys ?? [],
apiKeys: parsedApiKeys,
remark: draftToSave.remark,
},
});
setNotice(editingRow ? t('apiAccess.notice.updated') : t('apiAccess.notice.added'));
await loadProviders();
return true;
@@ -734,6 +838,14 @@ export function ApiAccessPage() {
query: { 'api-key': row.apiKey, 'base-url': row.baseUrl },
});
}
await invoke('save_api_access_remark', {
update: {
providerSection: row.section,
previousApiKeys: row.apiKeys,
apiKeys: [],
remark: '',
},
});
setNotice(t('apiAccess.notice.deleted'));
await loadProviders();
} catch (requestError) {
@@ -749,7 +861,7 @@ export function ApiAccessPage() {
try {
const latestConfig = await managementApi.get('/config');
const latestRows = sectionRecordsFromConfig(latestConfig, row.section);
const targetIndex = latestRows.findIndex((record) => providerIdentityMatches(row, record));
const targetIndex = resolveProviderRecordIndex(latestRows, row);
if (targetIndex < 0) {
throw new Error(t('apiAccess.error.stale'));
}
@@ -765,17 +877,15 @@ export function ApiAccessPage() {
value: { disabled: !currentlyDisabled },
});
} else {
const nextRecord = stripResponseFields(latestRecord);
const excludedModels = Array.isArray(nextRecord['excluded-models'])
? nextRecord['excluded-models'].map(String).filter((model) => model.trim() !== '*')
: [];
if (!currentlyDisabled) excludedModels.push('*');
if (excludedModels.length > 0) nextRecord['excluded-models'] = excludedModels;
else delete nextRecord['excluded-models'];
await managementApi.patch(`/${row.section}`, {
index: targetIndex,
value: nextRecord,
});
const nextRecord = providerRecordWithDisabledState(
row.section,
latestRecord,
!currentlyDisabled,
);
const nextRows = latestRows.map((record, index) =>
index === targetIndex ? nextRecord : stripResponseFields(record),
);
await managementApi.put(`/${row.section}`, nextRows);
}
setNotice(currentlyDisabled ? t('apiAccess.notice.enabled') : t('apiAccess.notice.disabled'));
await loadProviders();
@@ -859,7 +969,7 @@ export function ApiAccessPage() {
<article className="real-provider-row" key={`${row.section}-${row.index}-${row.authIndex}`}>
<div className="provider-row-main">
<div className="provider-row-title">
<strong title={row.name}>{row.name}</strong>
<strong title={row.remark || row.name}>{row.remark || row.name}</strong>
</div>
<code title={definitionFor(row.section).openAi ? t('apiAccess.keys.count', { count: row.apiKeys.length }) : undefined}>
{definitionFor(row.section).openAi && row.apiKeys.length > 1
@@ -967,7 +1077,7 @@ function ApiProviderDialog({
&& visibleModelOptions.every((model) => selectedModelNames.has(model.name.toLowerCase()));
const updateTextField = (
field: 'name' | 'apiKey' | 'baseUrl' | 'priority' | 'prefix' | 'headersText' | 'excludedModelsText' | 'testModel' | 'cloakMode' | 'cloakSensitiveWordsText',
field: 'name' | 'apiKey' | 'remark' | 'baseUrl' | 'priority' | 'prefix' | 'headersText' | 'excludedModelsText' | 'testModel' | 'cloakMode' | 'cloakSensitiveWordsText',
value: string,
) => {
setDraft((current) => ({ ...current, [field]: value }));
@@ -1140,6 +1250,7 @@ function ApiProviderDialog({
<input autoFocus type="password" value={draft.apiKey} onChange={(event) => updateTextField('apiKey', event.currentTarget.value)} placeholder="sk-..." />
)}
</label>
<label><span>{t('apiAccess.field.remark')}</span><input value={draft.remark} maxLength={80} onChange={(event) => updateTextField('remark', event.currentTarget.value)} placeholder={t('apiAccess.remarkPlaceholder')} /></label>
<label><span>Base URL</span><input value={draft.baseUrl} onChange={(event) => updateTextField('baseUrl', event.currentTarget.value)} placeholder={activeSection === 'codex-api-key' || activeSection === 'openai-compatibility' ? t('apiAccess.baseRequiredPlaceholder') : t('apiAccess.baseOptionalPlaceholder')} /></label>
{activeCategory === 'deepseek' ? (
<div className="provider-preset-summary">
+240 -7
View File
@@ -5,9 +5,12 @@ import {
AlertCircle,
Check,
Copy,
Clock3,
Eye,
EyeOff,
KeyRound,
Link2,
Network,
Pencil,
Plus,
Plug,
@@ -17,12 +20,18 @@ import {
Trash2,
X,
} from 'lucide-react';
import { useCoreRuntime, type CoreStatus } from '../coreRuntime';
import { useI18n } from '../i18n';
type CoreConfigSettings = {
apiKeys: CoreApiKey[];
port: number;
allowLan: boolean;
pluginsEnabled: boolean;
routingStrategy: string;
proxyUrl: string;
routingSessionAffinity: boolean;
routingSessionAffinityTtl: string;
};
type CoreApiKey = {
@@ -30,7 +39,14 @@ type CoreApiKey = {
remark: string;
};
type ConfigAction = 'add-key' | 'update-key' | 'delete-key' | 'plugins' | 'routing' | null;
type ConfigAction =
| 'add-key'
| 'update-key'
| 'delete-key'
| 'plugins'
| 'routing'
| 'network'
| null;
type NoticeTone = 'success' | 'error';
const ROUTING_OPTIONS = [
@@ -40,6 +56,7 @@ const ROUTING_OPTIONS = [
export function ConfigPanelPage() {
const { t } = useI18n();
const { status: coreStatus, publishStatus, refreshStatus } = useCoreRuntime();
const [settings, setSettings] = useState<CoreConfigSettings | null>(null);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState('');
@@ -53,6 +70,12 @@ export function ConfigPanelPage() {
const [formError, setFormError] = useState('');
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
const [notice, setNotice] = useState<{ message: string; tone: NoticeTone } | null>(null);
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 noticeTimerRef = useRef<number | null>(null);
const copyTimerRef = useRef<number | null>(null);
@@ -89,12 +112,24 @@ export function ConfigPanelPage() {
}, 3200);
};
async function loadSettings() {
const applySettings = (result: CoreConfigSettings, syncNetworkDrafts = true) => {
setSettings(result);
if (syncNetworkDrafts) {
setPortDraft(String(result.port));
setAllowLanDraft(result.allowLan);
setProxyUrlDraft(result.proxyUrl);
setSessionAffinityDraft(result.routingSessionAffinity);
setSessionTtlDraft(result.routingSessionAffinityTtl);
setPortError('');
}
};
async function loadSettings(syncNetworkDrafts = true) {
setLoading(true);
setLoadError('');
try {
const result = await invoke<CoreConfigSettings>('get_core_config_settings');
setSettings(result);
applySettings(result, syncNetworkDrafts);
} catch (error) {
setSettings(null);
setLoadError(String(error));
@@ -112,13 +147,14 @@ export function ConfigPanelPage() {
setBusyAction(action);
try {
const result = await invoke<CoreConfigSettings>(command, args);
setSettings(result);
applySettings(result, false);
setLoadError('');
showNotice(successMessage, 'success');
return true;
} catch (error) {
showNotice(String(error), 'error');
void loadSettings();
if (settings) applySettings(settings, false);
showNotice(t('config.error.saveFailed', { error: String(error) }), 'error');
void loadSettings(false);
return false;
} finally {
setBusyAction(null);
@@ -250,7 +286,64 @@ export function ConfigPanelPage() {
);
};
const saveNetworkRoutingSettings = async () => {
if (!settings || busyAction !== null) return;
const port = Number(portDraft);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
setPortError(t('config.error.portRange'));
showNotice(t('config.error.portRange'), 'error');
return;
}
const proxyUrl = proxyUrlDraft.trim();
const routingSessionAffinityTtl = sessionTtlDraft.trim();
const networkChanged = port !== settings.port || allowLanDraft !== settings.allowLan;
setPortError('');
setBusyAction('network');
try {
const result = await invoke<CoreConfigSettings>('save_network_routing_settings', {
settings: {
port,
allowLan: allowLanDraft,
proxyUrl,
routingSessionAffinity: sessionAffinityDraft,
routingSessionAffinityTtl,
},
});
applySettings(result);
setLoadError('');
if (networkChanged && coreStatus?.running) {
try {
const status = await invoke<CoreStatus>('restart_core_process');
publishStatus(status);
showNotice(t('config.notice.networkRestarted'), 'success');
} catch (error) {
await refreshStatus();
showNotice(t('config.error.networkRestartFailed', { error: String(error) }), 'error');
}
} else if (networkChanged) {
showNotice(t('config.notice.networkNextStart'), 'success');
} else {
showNotice(t('config.notice.networkUpdated'), 'success');
}
} catch (error) {
applySettings(settings);
showNotice(t('config.error.saveFailed', { error: String(error) }), 'error');
void loadSettings();
} finally {
setBusyAction(null);
}
};
const controlsDisabled = loading || settings === null || busyAction !== null;
const networkRoutingDirty = Boolean(settings) && (
portDraft !== String(settings?.port)
|| allowLanDraft !== settings?.allowLan
|| proxyUrlDraft.trim() !== settings?.proxyUrl
|| sessionAffinityDraft !== settings?.routingSessionAffinity
|| sessionTtlDraft.trim() !== settings?.routingSessionAffinityTtl
);
const selectedDeleteKey =
deleteIndex === null ? '' : settings?.apiKeys[deleteIndex]?.apiKey || '';
const deletingLastKey = deleteIndex !== null && settings?.apiKeys.length === 1;
@@ -295,7 +388,7 @@ export function ConfigPanelPage() {
<AlertCircle size={24} aria-hidden="true" />
<strong>{t('config.unavailable')}</strong>
<span title={loadError}>{loadError}</span>
<button type="button" className="secondary-button compact-button" onClick={loadSettings}>
<button type="button" className="secondary-button compact-button" onClick={() => void loadSettings()}>
<RefreshCw size={16} aria-hidden="true" />
{t('common.retry')}
</button>
@@ -426,6 +519,146 @@ export function ConfigPanelPage() {
</div>
</div>
<section className="panel config-network-panel">
<div className="config-panel-heading">
<div className="config-heading-title">
<Network size={18} aria-hidden="true" />
<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>
<button
type="button"
className="primary-button compact-button"
disabled={controlsDisabled || !networkRoutingDirty}
onClick={() => void saveNetworkRoutingSettings()}
>
<Check size={16} aria-hidden="true" />
{busyAction === 'network'
? t('config.network.saving')
: t('config.network.confirmSave')}
</button>
</div>
</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' : ''}`}
type="text"
inputMode="numeric"
pattern="[0-9]*"
maxLength={5}
value={portDraft}
disabled={controlsDisabled}
aria-invalid={Boolean(portError)}
title={portError || t('config.network.portHint')}
onChange={(event) => {
setPortDraft(event.currentTarget.value.replace(/\D/g, '').slice(0, 5));
setPortError('');
}}
onKeyDown={(event) => {
if (event.key === 'Escape' && settings) {
setPortDraft(String(settings.port));
setPortError('');
event.currentTarget.blur();
}
}}
/>
<small>{t('config.network.portHint')}</small>
</label>
<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) => 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) => 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>
</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>
<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>
</div>
</section>
{addDialogOpen ? (
<div className="config-dialog-backdrop" onMouseDown={(event) => {
if (event.currentTarget === event.target) closeAddDialog();
+16 -125
View File
@@ -123,11 +123,8 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) {
const [checkingLatest, setCheckingLatest] = useState(Boolean(latestCheckPromise));
const [allowLanAccess, setAllowLanAccess] = useState(false);
const [customPort, setCustomPort] = useState('8317');
const [settingsLoaded, setSettingsLoaded] = useState(false);
const [settingsError, setSettingsError] = useState('');
const [installing, setInstalling] = useState(false);
const [processBusy, setProcessBusy] = useState(false);
const [networkBusy, setNetworkBusy] = useState(false);
const [processNotice, setProcessNotice] = useState<{
message: string;
tone: MessageType;
@@ -145,8 +142,6 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) {
const [lanIpChecked, setLanIpChecked] = useState(false);
const installDialogRef = useRef<HTMLDivElement>(null);
const savedPortRef = useRef(8317);
const savedAllowLanRef = useRef(false);
const settingsSaveRequestRef = useRef(0);
const processNoticeTimerRef = useRef<number | null>(null);
const copiedApiTimerRef = useRef<number | null>(null);
@@ -347,13 +342,7 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) {
setAllowLanAccess(settings.allowLan);
setCustomPort(String(settings.port));
savedPortRef.current = settings.port;
savedAllowLanRef.current = settings.allowLan;
setSettingsError('');
} catch (error) {
setSettingsError(String(error));
} finally {
setSettingsLoaded(true);
}
} catch {}
};
const loadHomeApiKey = async () => {
@@ -367,73 +356,6 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) {
}
};
const saveNetworkSettings = async (
allowLan: boolean,
port: number,
restartAfterSave = false,
) => {
const requestId = ++settingsSaveRequestRef.current;
try {
const settings = await invoke<GuiSettings>('save_gui_settings', {
settings: { allowLan, port },
});
if (requestId !== settingsSaveRequestRef.current) {
return;
}
setAllowLanAccess(settings.allowLan);
setCustomPort(String(settings.port));
savedPortRef.current = settings.port;
savedAllowLanRef.current = settings.allowLan;
setSettingsError('');
if (restartAfterSave) {
if (coreStatus?.running) {
await runCoreProcessCommand('restart_core_process', {
success: t('kernel.notice.networkRestarted'),
failure: t('kernel.notice.networkRestartFailed'),
});
} else {
showProcessNotice(t('kernel.notice.networkNextStart'), 'info');
}
}
} catch (error) {
if (requestId !== settingsSaveRequestRef.current) {
return;
}
setAllowLanAccess(savedAllowLanRef.current);
setCustomPort(String(savedPortRef.current));
setSettingsError(String(error));
if (restartAfterSave) {
showProcessNotice(t('kernel.notice.networkSaveFailed', { error: String(error) }), 'error');
}
}
};
const updateAllowLanAccess = (allowLan: boolean) => {
const editedPort = Number(customPort);
const port =
Number.isInteger(editedPort) && editedPort >= 1 && editedPort <= 65535
? editedPort
: savedPortRef.current;
setAllowLanAccess(allowLan);
setNetworkBusy(true);
void saveNetworkSettings(allowLan, port, true).finally(() => setNetworkBusy(false));
};
const commitCustomPort = () => {
const port = Number(customPort);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
setCustomPort(String(savedPortRef.current));
setSettingsError(t('kernel.error.port'));
return;
}
setNetworkBusy(true);
void saveNetworkSettings(allowLanAccess, port, port !== savedPortRef.current)
.finally(() => setNetworkBusy(false));
};
const loadPlatform = async () => {
try {
const result = await invoke<CorePlatform>('detect_core_platform');
@@ -746,7 +668,7 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) {
<h2>{t('kernel.control.title')}</h2>
</div>
<span className={`state-pill ${statusTone}`} title={statusError || undefined}>
{processBusy || networkBusy ? t('common.processing') : statusLabel}
{processBusy ? t('common.processing') : statusLabel}
</span>
</div>
@@ -764,48 +686,15 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) {
<dd>{coreStatus?.processId || t('kernel.control.noPid')}</dd>
</div>
<div className="panel-detail-row">
<dt>{t('kernel.control.allowLan')}</dt>
<dd className="detail-control-cell">
<span className="switch-control" title={t('kernel.control.lanRestartHint')}>
<input
type="checkbox"
aria-label={t('kernel.control.allowLanAria')}
disabled={!settingsLoaded || networkBusy || processBusy}
checked={allowLanAccess}
onChange={(event) => updateAllowLanAccess(event.currentTarget.checked)}
/>
<span className="switch-track" />
</span>
<dt>{t('kernel.overview.coreVersion')}</dt>
<dd>
{currentVersion
|| (coreInstalled ? t('common.unavailable') : t('kernel.status.notInstalled'))}
</dd>
</div>
<div className="panel-detail-row">
<dt>
<label htmlFor="custom-port">{t('kernel.control.port')}</label>
</dt>
<dd className="detail-control-cell">
<input
id="custom-port"
className={`compact-text-input ${settingsError ? 'error' : ''}`}
type="text"
inputMode="numeric"
pattern="[0-9]*"
maxLength={5}
placeholder={t('kernel.control.portPlaceholder')}
title={settingsError || t('kernel.control.portRange')}
aria-invalid={Boolean(settingsError)}
disabled={!settingsLoaded || networkBusy || processBusy}
value={customPort}
onChange={(event) =>
setCustomPort(event.currentTarget.value.replace(/\D/g, '').slice(0, 5))
}
onBlur={commitCustomPort}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.currentTarget.blur();
}
}}
/>
</dd>
<dt>{t('kernel.overview.appVersion')}</dt>
<dd>{currentAppVersion}</dd>
</div>
</dl>
@@ -813,7 +702,7 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) {
<button
type="button"
className={coreRunning ? 'danger-button' : 'primary-button'}
disabled={!coreInstalled || installing || processBusy || networkBusy}
disabled={!coreInstalled || installing || processBusy}
onClick={() =>
void runCoreProcessCommand(
coreRunning ? 'stop_core_process' : 'start_core_process',
@@ -826,7 +715,7 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) {
<button
type="button"
className="secondary-button"
disabled={!coreInstalled || !coreRunning || installing || processBusy || networkBusy}
disabled={!coreInstalled || !coreRunning || installing || processBusy}
onClick={() =>
void runCoreProcessCommand('restart_core_process', { success: t('kernel.notice.restarted') })
}
@@ -836,7 +725,7 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) {
<button
type="button"
className="secondary-button"
disabled={processBusy || networkBusy}
disabled={processBusy}
onClick={() => void refreshStatus()}
>
{t('kernel.control.refresh')}
@@ -860,9 +749,11 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) {
: t('appUpdate.phase.checking'))}
</p>
</div>
<span className={`state-pill ${appUpdate?.updateAvailable ? 'update' : appUpdateError ? 'error' : 'success'}`}>
{appUpdate?.autoUpdateSupported ? t('appUpdate.portableReady') : t('appUpdate.manualOnly')}
</span>
{!appUpdate?.autoUpdateSupported && (
<span className={`state-pill ${appUpdateError ? 'error' : 'success'}`}>
{t('appUpdate.manualFallback')}
</span>
)}
</div>
<dl className="panel-detail-grid software-update-details">
+5 -4
View File
@@ -60,6 +60,7 @@ type UsageRecord = {
latency_ms: number;
ttft_ms: number | null;
source: string;
source_display: string;
failed: boolean;
provider: string;
model: string;
@@ -372,19 +373,19 @@ function AnalysisView({ analysis, overview }: { analysis: UsageAnalysis; overvie
failures: point.failure,
tokens: point.tokens,
})).sort((left, right) => right.tokens - left.tokens);
return <div className="usage-analysis-grid"><CategoryPanel title={t('usage.analysis.models')} items={analysis.models} /><CategoryPanel title="Provider" items={analysis.providers} /><CategoryPanel title={t('usage.analysis.sources')} items={analysis.sources} /><CategoryPanel title={t('usage.analysis.keys')} items={analysis.apiKeys} /><CategoryPanel title={t('usage.analysis.hours')} items={hours} /></div>;
return <div className="usage-analysis-grid"><CategoryPanel title={t('usage.analysis.models')} items={analysis.models} /><CategoryPanel title="Provider" items={analysis.providers} /><CategoryPanel title={t('usage.analysis.sources')} items={analysis.sources} compactLabels /><CategoryPanel title={t('usage.analysis.keys')} items={analysis.apiKeys} /><CategoryPanel title={t('usage.analysis.hours')} items={hours} /></div>;
}
function CategoryPanel({ title, items }: { title: string; items: UsageCategory[] }) {
function CategoryPanel({ title, items, compactLabels = false }: { title: string; items: UsageCategory[]; compactLabels?: boolean }) {
const { t } = useI18n();
const max = Math.max(...items.map((item) => item.tokens), 1);
const total = items.reduce((sum, item) => sum + item.tokens, 0);
return <section className="panel usage-category-panel"><div className="usage-section-heading"><div><strong>{title}</strong><span>{t('usage.analysis.sortedByTokens')}</span></div></div>{items.length ? <div className="usage-category-list">{items.slice(0, 10).map((item) => <div key={item.key}><span><strong title={item.label}>{item.label}</strong><small>{t('usage.analysis.itemMeta', { requests: compactNumber(item.requests), percent: total ? (item.tokens * 100 / total).toFixed(1) : '0.0', tokens: compactNumber(item.tokens) })}</small></span><i><b style={{ width: `${item.tokens * 100 / max}%` }} /></i></div>)}</div> : <UsageEmpty />}</section>;
return <section className={`panel usage-category-panel${compactLabels ? ' compact-labels' : ''}`}><div className="usage-section-heading"><div><strong>{title}</strong><span>{t('usage.analysis.sortedByTokens')}</span></div></div>{items.length ? <div className="usage-category-list">{items.slice(0, 10).map((item) => <div key={item.key}><span><strong title={item.label}>{item.label}</strong><small>{t('usage.analysis.itemMeta', { requests: compactNumber(item.requests), percent: total ? (item.tokens * 100 / total).toFixed(1) : '0.0', tokens: compactNumber(item.tokens) })}</small></span><i><b style={{ width: `${item.tokens * 100 / max}%` }} /></i></div>)}</div> : <UsageEmpty />}</section>;
}
function EventsView({ events, onPage }: { events: UsageEventPage; onPage: (page: number) => void }) {
const { t } = useI18n();
return <section className="panel usage-events-panel"><div className="usage-events-summary"><span>{t('usage.events.total', { count: compactNumber(events.total) })}</span><span>{t('usage.events.page', { page: events.page, total: events.totalPages })}</span></div>{events.items.length ? <div className="usage-table-wrap"><table className="usage-events-table"><thead><tr><th>{t('usage.column.time')}</th><th>{t('usage.column.model')}</th><th>Provider</th><th>{t('usage.column.source')}</th><th>API Key</th><th>{t('usage.column.result')}</th><th>{t('usage.column.latency')}</th><th>TTFT</th><th>{t('usage.column.input')}</th><th>{t('usage.column.output')}</th><th>{t('usage.column.reasoning')}</th><th>{t('usage.column.cache')}</th><th>{t('usage.column.total')}</th></tr></thead><tbody>{events.items.map((record) => <tr key={record.id}><td>{formatTime(record.timestamp)}</td><td className="usage-stacked-cell"><strong title={record.alias || record.model}>{record.alias || record.model}</strong>{record.alias || record.reasoning_effort ? <small title={record.model}>{record.alias ? record.model : ''}{record.alias && record.reasoning_effort ? ' · ' : ''}{record.reasoning_effort}</small> : null}</td><td title={record.provider}>{record.provider || '—'}</td><td title={record.source}>{record.source || '—'}</td><td className="usage-stacked-cell"><strong title={record.api_key_remark}>{record.api_key_remark || t('usage.key.noRemark')}</strong><small>{record.api_key_display || '—'}</small></td><td><span className={`usage-result ${record.failed ? 'failed' : 'success'}`}>{record.failed ? t('usage.result.failed') : t('usage.result.success')}</span></td><td>{record.latency_ms} ms</td><td>{record.ttft_ms == null ? '—' : `${record.ttft_ms} ms`}</td><td>{compactNumber(record.tokens.input_tokens)}</td><td>{compactNumber(record.tokens.output_tokens)}</td><td>{compactNumber(record.tokens.reasoning_tokens)}</td><td>{compactNumber(record.tokens.cache_read_tokens)}</td><td><strong>{compactNumber(record.tokens.total_tokens)}</strong></td></tr>)}</tbody></table></div> : <UsageEmpty />}<div className="usage-pagination"><button type="button" className="secondary-button" disabled={events.page <= 1} onClick={() => onPage(events.page - 1)}>{t('usage.previous')}</button><button type="button" className="secondary-button" disabled={events.page >= events.totalPages} onClick={() => onPage(events.page + 1)}>{t('usage.next')}</button></div></section>;
return <section className="panel usage-events-panel"><div className="usage-events-summary"><span>{t('usage.events.total', { count: compactNumber(events.total) })}</span><span>{t('usage.events.page', { page: events.page, total: events.totalPages })}</span></div>{events.items.length ? <div className="usage-table-wrap"><table className="usage-events-table"><thead><tr><th>{t('usage.column.time')}</th><th>{t('usage.column.model')}</th><th>Provider</th><th>{t('usage.column.source')}</th><th>API Key</th><th>{t('usage.column.result')}</th><th>{t('usage.column.latency')}</th><th>TTFT</th><th>{t('usage.column.input')}</th><th>{t('usage.column.output')}</th><th>{t('usage.column.reasoning')}</th><th>{t('usage.column.cache')}</th><th>{t('usage.column.total')}</th></tr></thead><tbody>{events.items.map((record) => <tr key={record.id}><td>{formatTime(record.timestamp)}</td><td className="usage-stacked-cell"><strong title={record.alias || record.model}>{record.alias || record.model}</strong>{record.alias || record.reasoning_effort ? <small title={record.model}>{record.alias ? record.model : ''}{record.alias && record.reasoning_effort ? ' · ' : ''}{record.reasoning_effort}</small> : null}</td><td title={record.provider}>{record.provider || '—'}</td><td title={record.source_display || undefined}>{record.source_display || '—'}</td><td className="usage-stacked-cell"><strong title={record.api_key_remark}>{record.api_key_remark || t('usage.key.noRemark')}</strong><small>{record.api_key_display || '—'}</small></td><td><span className={`usage-result ${record.failed ? 'failed' : 'success'}`}>{record.failed ? t('usage.result.failed') : t('usage.result.success')}</span></td><td>{record.latency_ms} ms</td><td>{record.ttft_ms == null ? '—' : `${record.ttft_ms} ms`}</td><td>{compactNumber(record.tokens.input_tokens)}</td><td>{compactNumber(record.tokens.output_tokens)}</td><td>{compactNumber(record.tokens.reasoning_tokens)}</td><td>{compactNumber(record.tokens.cache_read_tokens)}</td><td><strong>{compactNumber(record.tokens.total_tokens)}</strong></td></tr>)}</tbody></table></div> : <UsageEmpty />}<div className="usage-pagination"><button type="button" className="secondary-button" disabled={events.page <= 1} onClick={() => onPage(events.page - 1)}>{t('usage.previous')}</button><button type="button" className="secondary-button" disabled={events.page >= events.totalPages} onClick={() => onPage(events.page + 1)}>{t('usage.next')}</button></div></section>;
}
function UsageEmpty() {
+115
View File
@@ -1244,6 +1244,101 @@ input:focus-visible {
height: 206px;
}
.config-network-panel {
display: grid;
gap: 18px;
}
.config-network-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-auto-rows: 154px;
gap: 16px 18px;
}
.config-network-field {
display: grid;
height: 100%;
min-width: 0;
align-content: start;
gap: 8px;
padding: 14px;
border: 1px solid #e3e1db;
border-radius: 7px;
background: #faf8f2;
}
.config-network-field > span,
.config-network-toggle > div > span {
color: #39352f;
font-size: 13px;
font-weight: 700;
}
.config-network-field small {
color: #7a746c;
font-size: 11px;
line-height: 1.45;
}
.config-network-label {
display: flex;
align-items: center;
gap: 7px;
}
.config-network-label svg {
flex: none;
color: #777067;
}
.config-network-input {
width: 100%;
height: 38px;
min-width: 0;
padding: 0 11px;
border: 1px solid #d5d2cb;
border-radius: 6px;
outline: none;
background: #fffdf8;
color: #2d2a26;
font: inherit;
font-size: 13px;
}
.config-network-input:focus {
border-color: #777067;
box-shadow: 0 0 0 3px rgba(119, 112, 103, 0.12);
}
.config-network-input.error {
border-color: #b64132;
box-shadow: 0 0 0 3px rgba(182, 65, 50, 0.1);
}
.config-network-input:disabled {
cursor: not-allowed;
opacity: 0.58;
}
.config-network-port-field .config-network-input {
width: 120px;
max-width: 100%;
}
.config-network-toggle {
grid-template-columns: minmax(0, 1fr) auto;
align-content: center;
align-items: center;
gap: 16px;
}
.config-network-toggle > div {
display: grid;
min-width: 0;
gap: 7px;
}
.config-panel-heading {
display: flex;
align-items: center;
@@ -5225,12 +5320,16 @@ input:focus-visible {
.usage-token-breakdown,
.usage-category-list {
display: grid;
width: 100%;
min-width: 0;
gap: 12px;
}
.usage-token-breakdown > div,
.usage-category-list > div {
display: grid;
grid-template-columns: minmax(0, 1fr);
min-width: 0;
gap: 6px;
}
@@ -5239,11 +5338,14 @@ input:focus-visible {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
min-width: 0;
gap: 8px;
}
.usage-token-breakdown strong,
.usage-category-list strong {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
color: #5f5a53;
@@ -5262,6 +5364,9 @@ input:focus-visible {
.usage-token-breakdown i,
.usage-category-list i {
display: block;
width: 100%;
min-width: 0;
max-width: 100%;
overflow: hidden;
height: 5px;
border-radius: 999px;
@@ -5286,6 +5391,11 @@ input:focus-visible {
min-height: 290px;
}
.usage-category-panel.compact-labels .usage-category-list strong {
flex: 0 1 auto;
max-width: min(46%, 160px);
}
.usage-events-panel {
display: grid;
gap: 10px;
@@ -6184,6 +6294,11 @@ input:focus-visible {
grid-template-columns: 1fr;
}
.config-network-grid {
grid-template-columns: 1fr;
grid-auto-rows: auto;
}
.client-api-grid {
grid-template-columns: 1fr;
}
+4 -3
View File
@@ -2,13 +2,14 @@ import { describe, expect, test } from 'bun:test';
import { appUpdateIndicatorState } from '../src/appUpdateModel';
import { canOpenAppPage, isAlwaysAvailablePage } from '../src/navigation';
describe('首页与版本管理导航', () => {
test('内核停止时仅首页和版本管理始终可进入', () => {
describe('首页、配置与版本管理导航', () => {
test('内核停止时首页、配置和版本管理始终可进入', () => {
expect(isAlwaysAvailablePage('home')).toBe(true);
expect(isAlwaysAvailablePage('versions')).toBe(true);
expect(isAlwaysAvailablePage('config')).toBe(true);
expect(canOpenAppPage('home', false)).toBe(true);
expect(canOpenAppPage('versions', false)).toBe(true);
expect(canOpenAppPage('config', false)).toBe(false);
expect(canOpenAppPage('config', false)).toBe(true);
expect(canOpenAppPage('quota', false)).toBe(false);
});
+50
View File
@@ -10,7 +10,9 @@ import {
modelSelectionForDiscovery,
parseProviderHeaders,
providerCategoryMatchesRecord,
providerRecordWithDisabledState,
providerSectionOrder,
resolveProviderRecordIndex,
sectionRecordsFromConfig,
stripResponseFields,
} from '../src/pages/ApiAccessPage';
@@ -209,6 +211,54 @@ describe('API 接入配置合并', () => {
expect(result['excluded-models']).toEqual(['claude-old-*', '*']);
});
it('重新启用仅含全停用规则的普通提供商时删除停用规则', () => {
const result = providerRecordWithDisabledState(
'claude-api-key',
{
'api-key': 'claude-key',
'excluded-models': ['*'],
custom: { keep: true },
'auth-index': 'runtime-only',
},
false,
);
expect(result['excluded-models']).toBeUndefined();
expect(result.custom).toEqual({ keep: true });
expect(result['auth-index']).toBeUndefined();
});
it('切换普通提供商时保留其他模型排除规则且不会重复追加停用规则', () => {
const disabled = providerRecordWithDisabledState(
'codex-api-key',
{
'api-key': 'codex-key',
'excluded-models': ['preview-*', '*'],
},
true,
);
const enabled = providerRecordWithDisabledState('codex-api-key', disabled, false);
expect(disabled['excluded-models']).toEqual(['preview-*', '*']);
expect(enabled['excluded-models']).toEqual(['preview-*']);
});
it('运行时补全默认 Base URL 后仍能按原始索引找到配置记录', () => {
const records = [
{ 'api-key': 'first-key', 'base-url': 'https://first.example' },
{ 'api-key': 'target-key' },
];
const index = resolveProviderRecordIndex(records, {
section: 'claude-api-key',
index: 1,
name: 'Claude',
apiKey: 'target-key',
baseUrl: 'https://api.anthropic.com',
});
expect(index).toBe(1);
});
it('校验自定义请求头格式', () => {
expect(parseProviderHeaders('Authorization: Bearer abc:def')).toEqual({
Authorization: 'Bearer abc:def',