19 files changed
+1953
-110
No files matched your search
@@ -81,6 +81,8 @@ OpenAI、Claude、Gemini、およびその他の互換形式の間で変換で
|
||||
- 時間、モデル、プロバイダー、ソース、キー、結果でデータを絞り込み。
|
||||
- リクエストと Token の推移、入力・出力・推論・キャッシュの使用量を確認。
|
||||
- リクエスト詳細、分析画面、価格統計を閲覧。
|
||||
- CPA のリアルタイム使用量購読と永続ローカル inbox で収集し、購読できない場合は HTTP に自動フォールバック。
|
||||
- 起動時に旧使用履歴データベースを一度だけ移行し、移行前のバックアップを `usage-records/backups` に保存。
|
||||
|
||||
### エージェントクライアント設定
|
||||
|
||||
|
||||
@@ -82,6 +82,8 @@ The Usage page helps you understand local request activity and token consumption
|
||||
- Filter usage by time, model, provider, source, key, and result.
|
||||
- Inspect request/token trends and input, output, reasoning, and cache usage.
|
||||
- Browse request details, analysis views, and price statistics.
|
||||
- Collect through CPA's real-time usage subscription with a durable local inbox and automatic HTTP fallback.
|
||||
- Upgrade legacy usage databases once at startup after saving a backup under `usage-records/backups`.
|
||||
|
||||
### Agent client configuration
|
||||
|
||||
|
||||
@@ -79,6 +79,8 @@ API 接入页面按照协议或 Provider 管理上游 API 凭证和服务地址
|
||||
- 按时间、模型、Provider、来源、密钥和结果筛选数据。
|
||||
- 查看请求与 Token 趋势,以及输入、输出、思考和缓存用量构成。
|
||||
- 浏览请求明细、分析视图和价格统计。
|
||||
- 通过 CPA 实时订阅和本地持久化 inbox 采集记录,订阅不可用时自动降级到 HTTP。
|
||||
- 启动时一次性迁移旧版使用记录数据库,并在 `usage-records/backups` 下保留迁移前备份。
|
||||
|
||||
### 智能体客户端配置
|
||||
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
CLIProxyAPI is the core file of this project. After downloading it from the GitHub repository, you can simply extract it locally and use it directly.
|
||||
|
||||
Latest release page: https://github.com/router-for-me/CLIProxyAPI/releases/latest
|
||||
|
||||
After extraction, you will find the cli-proxy-api binary file and the config.example.yaml configuration template.
|
||||
|
||||
You need to copy config.example.yaml to config.yaml and modify the DIY configuration before running it. Of course, the default configuration can also run directly.
|
||||
|
||||
@@ -29,7 +29,7 @@ tar = "0.4"
|
||||
tauri = { version = "~2.11", features = ["tray-icon"] }
|
||||
tauri-plugin-autostart = "2"
|
||||
tauri-plugin-opener = "2"
|
||||
tokio = { version = "1", features = ["macros", "sync", "time"] }
|
||||
tokio = { version = "1", features = ["io-util", "macros", "net", "sync", "time"] }
|
||||
tokio-util = "0.7"
|
||||
toml = "0.8"
|
||||
toml_edit = "0.20"
|
||||
|
||||
@@ -103,16 +103,14 @@ pub(crate) fn save_network_routing_settings(
|
||||
next.proxy_url = proxy_url.clone();
|
||||
next.routing_session_affinity = settings.routing_session_affinity;
|
||||
next.routing_session_affinity_ttl = routing_session_affinity_ttl.clone();
|
||||
next.request_retry = settings.request_retry;
|
||||
next.max_retry_credentials = settings.max_retry_credentials;
|
||||
next.max_retry_interval = settings.max_retry_interval;
|
||||
next.streaming_bootstrap_retries = settings.streaming_bootstrap_retries;
|
||||
validate_gui_config(&next)?;
|
||||
|
||||
patch_core_network_routing_settings(&next)?;
|
||||
let config = match gui_config_state.update_network_routing(
|
||||
settings.port,
|
||||
settings.allow_lan,
|
||||
&proxy_url,
|
||||
settings.routing_session_affinity,
|
||||
&routing_session_affinity_ttl,
|
||||
) {
|
||||
let config = match gui_config_state.update_network_routing(&next) {
|
||||
Ok(config) => config,
|
||||
Err(error) => {
|
||||
let rollback_error = patch_core_network_routing_settings(&previous).err();
|
||||
|
||||
@@ -341,6 +341,31 @@ pub(crate) fn patch_core_network_routing_yaml(
|
||||
"session-affinity-ttl",
|
||||
serde_norway::Value::String(config.routing_session_affinity_ttl.clone()),
|
||||
)?;
|
||||
set_core_yaml_top_level_value(
|
||||
document,
|
||||
"request-retry",
|
||||
serde_norway::to_value(config.request_retry)
|
||||
.map_err(|err| format!("序列化请求重试次数失败: {err}"))?,
|
||||
)?;
|
||||
set_core_yaml_top_level_value(
|
||||
document,
|
||||
"max-retry-credentials",
|
||||
serde_norway::to_value(config.max_retry_credentials)
|
||||
.map_err(|err| format!("序列化最大重试凭据数失败: {err}"))?,
|
||||
)?;
|
||||
set_core_yaml_top_level_value(
|
||||
document,
|
||||
"max-retry-interval",
|
||||
serde_norway::to_value(config.max_retry_interval)
|
||||
.map_err(|err| format!("序列化最大重试等待时间失败: {err}"))?,
|
||||
)?;
|
||||
set_core_yaml_nested_value(
|
||||
document,
|
||||
"streaming",
|
||||
"bootstrap-retries",
|
||||
serde_norway::to_value(config.streaming_bootstrap_retries)
|
||||
.map_err(|err| format!("序列化流式启动重试次数失败: {err}"))?,
|
||||
)?;
|
||||
Ok(*document != original)
|
||||
})
|
||||
}
|
||||
@@ -446,6 +471,31 @@ pub(crate) fn apply_gui_managed_settings(
|
||||
"session-affinity-ttl",
|
||||
serde_norway::Value::String(config.routing_session_affinity_ttl.clone()),
|
||||
)?;
|
||||
changed |= set_core_yaml_top_level_value(
|
||||
document,
|
||||
"request-retry",
|
||||
serde_norway::to_value(config.request_retry)
|
||||
.map_err(|err| format!("序列化请求重试次数失败: {err}"))?,
|
||||
)?;
|
||||
changed |= set_core_yaml_top_level_value(
|
||||
document,
|
||||
"max-retry-credentials",
|
||||
serde_norway::to_value(config.max_retry_credentials)
|
||||
.map_err(|err| format!("序列化最大重试凭据数失败: {err}"))?,
|
||||
)?;
|
||||
changed |= set_core_yaml_top_level_value(
|
||||
document,
|
||||
"max-retry-interval",
|
||||
serde_norway::to_value(config.max_retry_interval)
|
||||
.map_err(|err| format!("序列化最大重试等待时间失败: {err}"))?,
|
||||
)?;
|
||||
changed |= set_core_yaml_nested_value(
|
||||
document,
|
||||
"streaming",
|
||||
"bootstrap-retries",
|
||||
serde_norway::to_value(config.streaming_bootstrap_retries)
|
||||
.map_err(|err| format!("序列化流式启动重试次数失败: {err}"))?,
|
||||
)?;
|
||||
Ok(changed)
|
||||
})?
|
||||
.unwrap_or_else(|| content.to_string());
|
||||
@@ -711,7 +761,11 @@ pub(crate) fn load_or_create_gui_config() -> Result<GuiConfigFile, String> {
|
||||
|| presence.routing_strategy.is_none()
|
||||
|| presence.proxy_url.is_none()
|
||||
|| presence.routing_session_affinity.is_none()
|
||||
|| presence.routing_session_affinity_ttl.is_none();
|
||||
|| presence.routing_session_affinity_ttl.is_none()
|
||||
|| presence.request_retry.is_none()
|
||||
|| presence.max_retry_credentials.is_none()
|
||||
|| presence.max_retry_interval.is_none()
|
||||
|| presence.streaming_bootstrap_retries.is_none();
|
||||
if missing_core_settings && !gui_parse_failed {
|
||||
if let Ok(core_settings) = read_installed_core_config_settings() {
|
||||
if presence.host.is_none() {
|
||||
@@ -759,6 +813,18 @@ pub(crate) fn load_or_create_gui_config() -> Result<GuiConfigFile, String> {
|
||||
if presence.routing_session_affinity_ttl.is_none() {
|
||||
config.routing_session_affinity_ttl = core_settings.routing_session_affinity_ttl;
|
||||
}
|
||||
if presence.request_retry.is_none() {
|
||||
config.request_retry = core_settings.request_retry;
|
||||
}
|
||||
if presence.max_retry_credentials.is_none() {
|
||||
config.max_retry_credentials = core_settings.max_retry_credentials;
|
||||
}
|
||||
if presence.max_retry_interval.is_none() {
|
||||
config.max_retry_interval = core_settings.max_retry_interval;
|
||||
}
|
||||
if presence.streaming_bootstrap_retries.is_none() {
|
||||
config.streaming_bootstrap_retries = core_settings.streaming_bootstrap_retries;
|
||||
}
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
@@ -828,6 +894,10 @@ pub(crate) fn apply_core_settings_to_gui_config(
|
||||
config.proxy_url = core_settings.proxy_url.clone();
|
||||
config.routing_session_affinity = core_settings.routing_session_affinity;
|
||||
config.routing_session_affinity_ttl = core_settings.routing_session_affinity_ttl.clone();
|
||||
config.request_retry = core_settings.request_retry;
|
||||
config.max_retry_credentials = core_settings.max_retry_credentials;
|
||||
config.max_retry_interval = core_settings.max_retry_interval;
|
||||
config.streaming_bootstrap_retries = core_settings.streaming_bootstrap_retries;
|
||||
}
|
||||
|
||||
pub(crate) fn default_api_key_entry() -> GuiApiKeyEntry {
|
||||
@@ -1130,6 +1200,19 @@ pub(crate) fn write_gui_config_to_path(
|
||||
"routing-session-affinity-ttl",
|
||||
value(config.routing_session_affinity_ttl.as_str()),
|
||||
),
|
||||
("request-retry", value(i64::from(config.request_retry))),
|
||||
(
|
||||
"max-retry-credentials",
|
||||
value(i64::from(config.max_retry_credentials)),
|
||||
),
|
||||
(
|
||||
"max-retry-interval",
|
||||
value(i64::from(config.max_retry_interval)),
|
||||
),
|
||||
(
|
||||
"streaming-bootstrap-retries",
|
||||
value(i64::from(config.streaming_bootstrap_retries)),
|
||||
),
|
||||
] {
|
||||
set_codex_table_item(root, key, item);
|
||||
}
|
||||
|
||||
@@ -1056,6 +1056,42 @@ pub(crate) fn core_config_settings_from_value(
|
||||
})
|
||||
.transpose()?
|
||||
.unwrap_or_default();
|
||||
let request_retry = yaml_mapping_value(root, "request-retry")
|
||||
.map(|value| {
|
||||
value
|
||||
.as_u64()
|
||||
.and_then(|value| u32::try_from(value).ok())
|
||||
.ok_or_else(|| "request-retry 必须是非负整数".to_string())
|
||||
})
|
||||
.transpose()?
|
||||
.unwrap_or(DEFAULT_REQUEST_RETRY);
|
||||
let max_retry_credentials = yaml_mapping_value(root, "max-retry-credentials")
|
||||
.map(|value| {
|
||||
value
|
||||
.as_u64()
|
||||
.and_then(|value| u32::try_from(value).ok())
|
||||
.ok_or_else(|| "max-retry-credentials 必须是非负整数".to_string())
|
||||
})
|
||||
.transpose()?
|
||||
.unwrap_or(DEFAULT_MAX_RETRY_CREDENTIALS);
|
||||
let max_retry_interval = yaml_mapping_value(root, "max-retry-interval")
|
||||
.map(|value| {
|
||||
value
|
||||
.as_u64()
|
||||
.and_then(|value| u32::try_from(value).ok())
|
||||
.ok_or_else(|| "max-retry-interval 必须是非负整数".to_string())
|
||||
})
|
||||
.transpose()?
|
||||
.unwrap_or(DEFAULT_MAX_RETRY_INTERVAL);
|
||||
let streaming_bootstrap_retries = nested_yaml_value(root, &["streaming", "bootstrap-retries"])
|
||||
.map(|value| {
|
||||
value
|
||||
.as_u64()
|
||||
.and_then(|value| u32::try_from(value).ok())
|
||||
.ok_or_else(|| "streaming.bootstrap-retries 必须是非负整数".to_string())
|
||||
})
|
||||
.transpose()?
|
||||
.unwrap_or(DEFAULT_STREAMING_BOOTSTRAP_RETRIES);
|
||||
|
||||
Ok(CoreConfigSettings {
|
||||
host,
|
||||
@@ -1071,6 +1107,10 @@ pub(crate) fn core_config_settings_from_value(
|
||||
proxy_url,
|
||||
routing_session_affinity,
|
||||
routing_session_affinity_ttl,
|
||||
request_retry,
|
||||
max_retry_credentials,
|
||||
max_retry_interval,
|
||||
streaming_bootstrap_retries,
|
||||
management_secret_key,
|
||||
})
|
||||
}
|
||||
|
||||
+51
-14
@@ -120,6 +120,10 @@ const OAUTH_DIR_NAME: &str = "oauth";
|
||||
const DEFAULT_AUTH_DIR: &str = "../oauth";
|
||||
const DEFAULT_API_KEY: &str = "123456";
|
||||
const DEFAULT_API_KEY_INITIAL_REMARK: &str = "默认密钥";
|
||||
const DEFAULT_REQUEST_RETRY: u32 = 3;
|
||||
const DEFAULT_MAX_RETRY_CREDENTIALS: u32 = 0;
|
||||
const DEFAULT_MAX_RETRY_INTERVAL: u32 = 30;
|
||||
const DEFAULT_STREAMING_BOOTSTRAP_RETRIES: u32 = 0;
|
||||
const LEGACY_DEFAULT_MANAGEMENT_SECRET_KEY: &str = "123456";
|
||||
const MANAGED_AGENT_PROVIDER_ID: &str = "cpa-gui";
|
||||
const PI_AGENT_ID: &str = "pi";
|
||||
@@ -561,6 +565,10 @@ struct GuiConfigFile {
|
||||
proxy_url: String,
|
||||
routing_session_affinity: bool,
|
||||
routing_session_affinity_ttl: String,
|
||||
request_retry: u32,
|
||||
max_retry_credentials: u32,
|
||||
max_retry_interval: u32,
|
||||
streaming_bootstrap_retries: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
|
||||
@@ -668,6 +676,10 @@ impl Default for GuiConfigFile {
|
||||
proxy_url: String::new(),
|
||||
routing_session_affinity: false,
|
||||
routing_session_affinity_ttl: String::new(),
|
||||
request_retry: DEFAULT_REQUEST_RETRY,
|
||||
max_retry_credentials: DEFAULT_MAX_RETRY_CREDENTIALS,
|
||||
max_retry_interval: DEFAULT_MAX_RETRY_INTERVAL,
|
||||
streaming_bootstrap_retries: DEFAULT_STREAMING_BOOTSTRAP_RETRIES,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -691,6 +703,10 @@ struct GuiConfigPresence {
|
||||
proxy_url: Option<String>,
|
||||
routing_session_affinity: Option<bool>,
|
||||
routing_session_affinity_ttl: Option<String>,
|
||||
request_retry: Option<u32>,
|
||||
max_retry_credentials: Option<u32>,
|
||||
max_retry_interval: Option<u32>,
|
||||
streaming_bootstrap_retries: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
@@ -1043,6 +1059,10 @@ struct GuiNetworkRoutingSettings {
|
||||
proxy_url: String,
|
||||
routing_session_affinity: bool,
|
||||
routing_session_affinity_ttl: String,
|
||||
request_retry: u32,
|
||||
max_retry_credentials: u32,
|
||||
max_retry_interval: u32,
|
||||
streaming_bootstrap_retries: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
@@ -1063,6 +1083,10 @@ struct CoreConfigSettings {
|
||||
proxy_url: String,
|
||||
routing_session_affinity: bool,
|
||||
routing_session_affinity_ttl: String,
|
||||
request_retry: u32,
|
||||
max_retry_credentials: u32,
|
||||
max_retry_interval: u32,
|
||||
streaming_bootstrap_retries: u32,
|
||||
// Kept for internal config migration/tests; never exposed to the WebView.
|
||||
#[allow(dead_code)]
|
||||
#[serde(skip_serializing)]
|
||||
@@ -1088,6 +1112,10 @@ struct CoreConfigView {
|
||||
proxy_url: String,
|
||||
routing_session_affinity: bool,
|
||||
routing_session_affinity_ttl: String,
|
||||
request_retry: u32,
|
||||
max_retry_credentials: u32,
|
||||
max_retry_interval: u32,
|
||||
streaming_bootstrap_retries: u32,
|
||||
}
|
||||
|
||||
impl Default for CoreInstallTask {
|
||||
@@ -1439,21 +1467,18 @@ impl GuiConfigState {
|
||||
})
|
||||
}
|
||||
|
||||
fn update_network_routing(
|
||||
&self,
|
||||
port: u16,
|
||||
allow_lan: bool,
|
||||
proxy_url: &str,
|
||||
routing_session_affinity: bool,
|
||||
routing_session_affinity_ttl: &str,
|
||||
) -> Result<GuiConfigFile, String> {
|
||||
fn update_network_routing(&self, settings: &GuiConfigFile) -> Result<GuiConfigFile, String> {
|
||||
self.update(|config| {
|
||||
config.port = port;
|
||||
config.allow_lan = allow_lan;
|
||||
config.host = if allow_lan { "0.0.0.0" } else { "127.0.0.1" }.to_string();
|
||||
config.proxy_url = proxy_url.to_string();
|
||||
config.routing_session_affinity = routing_session_affinity;
|
||||
config.routing_session_affinity_ttl = routing_session_affinity_ttl.to_string();
|
||||
config.port = settings.port;
|
||||
config.allow_lan = settings.allow_lan;
|
||||
config.host = settings.host.clone();
|
||||
config.proxy_url = settings.proxy_url.clone();
|
||||
config.routing_session_affinity = settings.routing_session_affinity;
|
||||
config.routing_session_affinity_ttl = settings.routing_session_affinity_ttl.clone();
|
||||
config.request_retry = settings.request_retry;
|
||||
config.max_retry_credentials = settings.max_retry_credentials;
|
||||
config.max_retry_interval = settings.max_retry_interval;
|
||||
config.streaming_bootstrap_retries = settings.streaming_bootstrap_retries;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
@@ -1542,6 +1567,10 @@ impl GuiConfigState {
|
||||
config.proxy_url = settings.proxy_url.clone();
|
||||
config.routing_session_affinity = settings.routing_session_affinity;
|
||||
config.routing_session_affinity_ttl = settings.routing_session_affinity_ttl.clone();
|
||||
config.request_retry = settings.request_retry;
|
||||
config.max_retry_credentials = settings.max_retry_credentials;
|
||||
config.max_retry_interval = settings.max_retry_interval;
|
||||
config.streaming_bootstrap_retries = settings.streaming_bootstrap_retries;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
@@ -1587,6 +1616,10 @@ impl From<&GuiConfigFile> for CoreConfigSettings {
|
||||
proxy_url: config.proxy_url.clone(),
|
||||
routing_session_affinity: config.routing_session_affinity,
|
||||
routing_session_affinity_ttl: config.routing_session_affinity_ttl.clone(),
|
||||
request_retry: config.request_retry,
|
||||
max_retry_credentials: config.max_retry_credentials,
|
||||
max_retry_interval: config.max_retry_interval,
|
||||
streaming_bootstrap_retries: config.streaming_bootstrap_retries,
|
||||
management_secret_key: Some(config.management_secret_key.clone()),
|
||||
}
|
||||
}
|
||||
@@ -1611,6 +1644,10 @@ impl From<&GuiConfigFile> for CoreConfigView {
|
||||
proxy_url: config.proxy_url.clone(),
|
||||
routing_session_affinity: config.routing_session_affinity,
|
||||
routing_session_affinity_ttl: config.routing_session_affinity_ttl.clone(),
|
||||
request_retry: config.request_retry,
|
||||
max_retry_credentials: config.max_retry_credentials,
|
||||
max_retry_interval: config.max_retry_interval,
|
||||
streaming_bootstrap_retries: config.streaming_bootstrap_retries,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,10 @@ fn gui_config_defaults_are_stable() {
|
||||
assert!(content.contains("management-secret-key = \"\""));
|
||||
assert!(content.contains("plugins-enabled = false"));
|
||||
assert!(content.contains("routing-strategy = \"round-robin\""));
|
||||
assert!(content.contains("request-retry = 3"));
|
||||
assert!(content.contains("max-retry-credentials = 0"));
|
||||
assert!(content.contains("max-retry-interval = 30"));
|
||||
assert!(content.contains("streaming-bootstrap-retries = 0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -213,6 +213,10 @@ fn legacy_gui_config_can_seed_managed_core_settings() {
|
||||
proxy_url: "http://127.0.0.1:8080".to_string(),
|
||||
routing_session_affinity: true,
|
||||
routing_session_affinity_ttl: "45m".to_string(),
|
||||
request_retry: 1,
|
||||
max_retry_credentials: 2,
|
||||
max_retry_interval: 5,
|
||||
streaming_bootstrap_retries: 1,
|
||||
management_secret_key: Some("management-secret".to_string()),
|
||||
};
|
||||
|
||||
@@ -233,6 +237,10 @@ fn legacy_gui_config_can_seed_managed_core_settings() {
|
||||
assert_eq!(config.proxy_url, "http://127.0.0.1:8080");
|
||||
assert!(config.routing_session_affinity);
|
||||
assert_eq!(config.routing_session_affinity_ttl, "45m");
|
||||
assert_eq!(config.request_retry, 1);
|
||||
assert_eq!(config.max_retry_credentials, 2);
|
||||
assert_eq!(config.max_retry_interval, 5);
|
||||
assert_eq!(config.streaming_bootstrap_retries, 1);
|
||||
assert!(config.run_on_startup);
|
||||
}
|
||||
|
||||
@@ -343,6 +351,10 @@ fn confirmed_network_routing_patch_updates_all_fields_together() {
|
||||
proxy_url: "socks5://127.0.0.1:7890".to_string(),
|
||||
routing_session_affinity: true,
|
||||
routing_session_affinity_ttl: "2h".to_string(),
|
||||
request_retry: 1,
|
||||
max_retry_credentials: 2,
|
||||
max_retry_interval: 5,
|
||||
streaming_bootstrap_retries: 1,
|
||||
..GuiConfigFile::default()
|
||||
};
|
||||
let input = "# network\nhost: 127.0.0.1\nport: 8317\nproxy-url: \"\"\n# routing\nrouting:\n strategy: round-robin\n# unrelated\ndebug: true\n";
|
||||
@@ -356,6 +368,10 @@ fn confirmed_network_routing_patch_updates_all_fields_together() {
|
||||
assert_eq!(document["proxy-url"], "socks5://127.0.0.1:7890");
|
||||
assert_eq!(document["routing"]["session-affinity"], true);
|
||||
assert_eq!(document["routing"]["session-affinity-ttl"], "2h");
|
||||
assert_eq!(document["request-retry"], 1);
|
||||
assert_eq!(document["max-retry-credentials"], 2);
|
||||
assert_eq!(document["max-retry-interval"], 5);
|
||||
assert_eq!(document["streaming"]["bootstrap-retries"], 1);
|
||||
assert_eq!(document["routing"]["strategy"], "round-robin");
|
||||
assert_eq!(document["debug"], true);
|
||||
assert!(updated.contains("# network"));
|
||||
@@ -634,6 +650,32 @@ fn core_config_reads_proxy_and_session_affinity_fields() {
|
||||
assert_eq!(defaults.routing_session_affinity_ttl, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn core_config_reads_retry_fields_and_uses_core_defaults() {
|
||||
let document = serde_norway::from_str::<serde_norway::Value>(
|
||||
"request-retry: 1\nmax-retry-credentials: 2\nmax-retry-interval: 5\nstreaming:\n bootstrap-retries: 4\n",
|
||||
)
|
||||
.unwrap();
|
||||
let settings = core_config_settings_from_value(&document).unwrap();
|
||||
|
||||
assert_eq!(settings.request_retry, 1);
|
||||
assert_eq!(settings.max_retry_credentials, 2);
|
||||
assert_eq!(settings.max_retry_interval, 5);
|
||||
assert_eq!(settings.streaming_bootstrap_retries, 4);
|
||||
|
||||
let defaults = core_config_settings_from_value(&serde_norway::from_str("{}").unwrap()).unwrap();
|
||||
assert_eq!(defaults.request_retry, DEFAULT_REQUEST_RETRY);
|
||||
assert_eq!(
|
||||
defaults.max_retry_credentials,
|
||||
DEFAULT_MAX_RETRY_CREDENTIALS
|
||||
);
|
||||
assert_eq!(defaults.max_retry_interval, DEFAULT_MAX_RETRY_INTERVAL);
|
||||
assert_eq!(
|
||||
defaults.streaming_bootstrap_retries,
|
||||
DEFAULT_STREAMING_BOOTSTRAP_RETRIES
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_session_settings_use_canonical_yaml_and_preserve_unrelated_content() {
|
||||
let input = "# global proxy\nproxy-url: old\n# routing options\nrouting:\n strategy: round-robin\n sessionAffinity: false\n sessionAffinityTTL: 10m\n# unrelated option\ndebug: true\n";
|
||||
@@ -641,6 +683,10 @@ fn managed_session_settings_use_canonical_yaml_and_preserve_unrelated_content()
|
||||
proxy_url: "http://127.0.0.1:8080".to_string(),
|
||||
routing_session_affinity: true,
|
||||
routing_session_affinity_ttl: "1h".to_string(),
|
||||
request_retry: 1,
|
||||
max_retry_credentials: 2,
|
||||
max_retry_interval: 5,
|
||||
streaming_bootstrap_retries: 1,
|
||||
..GuiConfigFile::default()
|
||||
};
|
||||
let rendered = apply_gui_managed_settings(input, &config).unwrap();
|
||||
@@ -649,6 +695,10 @@ fn managed_session_settings_use_canonical_yaml_and_preserve_unrelated_content()
|
||||
assert_eq!(document["proxy-url"], "http://127.0.0.1:8080");
|
||||
assert_eq!(document["routing"]["session-affinity"], true);
|
||||
assert_eq!(document["routing"]["session-affinity-ttl"], "1h");
|
||||
assert_eq!(document["request-retry"], 1);
|
||||
assert_eq!(document["max-retry-credentials"], 2);
|
||||
assert_eq!(document["max-retry-interval"], 5);
|
||||
assert_eq!(document["streaming"]["bootstrap-retries"], 1);
|
||||
assert!(rendered.contains("# global proxy"));
|
||||
assert!(rendered.contains("# unrelated option"));
|
||||
assert_eq!(document["debug"], true);
|
||||
@@ -737,6 +787,10 @@ fn startup_preserves_all_user_owned_yaml_and_only_applies_gui_managed_values() {
|
||||
proxy_url: "socks5://127.0.0.1:7890".to_string(),
|
||||
routing_session_affinity: true,
|
||||
routing_session_affinity_ttl: "1h".to_string(),
|
||||
request_retry: 1,
|
||||
max_retry_credentials: 2,
|
||||
max_retry_interval: 5,
|
||||
streaming_bootstrap_retries: 1,
|
||||
};
|
||||
let merged = merge_core_config_yaml(template, Some(current), &config).unwrap();
|
||||
let current_document = serde_norway::from_str::<serde_norway::Value>(current).unwrap();
|
||||
@@ -759,6 +813,10 @@ fn startup_preserves_all_user_owned_yaml_and_only_applies_gui_managed_values() {
|
||||
assert_eq!(document["proxy-url"], "socks5://127.0.0.1:7890");
|
||||
assert_eq!(document["routing"]["session-affinity"], true);
|
||||
assert_eq!(document["routing"]["session-affinity-ttl"], "1h");
|
||||
assert_eq!(document["request-retry"], 1);
|
||||
assert_eq!(document["max-retry-credentials"], 2);
|
||||
assert_eq!(document["max-retry-interval"], 5);
|
||||
assert_eq!(document["streaming"]["bootstrap-retries"], 1);
|
||||
assert_eq!(document["usage-statistics-enabled"], false);
|
||||
assert!(document.get("new-option").is_none());
|
||||
assert_eq!(document["nested"], current_document["nested"]);
|
||||
|
||||
+1065
-39
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,320 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::{
|
||||
io::{AsyncReadExt, AsyncWriteExt},
|
||||
net::TcpStream,
|
||||
time::timeout,
|
||||
};
|
||||
|
||||
const USAGE_CHANNEL: &str = "usage";
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const IO_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const MAX_FRAME_BYTES: usize = 16 * 1024 * 1024;
|
||||
const MAX_ARRAY_LENGTH: usize = 10_000;
|
||||
const MAX_NESTING_DEPTH: usize = 8;
|
||||
|
||||
pub(super) struct UsageSubscription {
|
||||
stream: TcpStream,
|
||||
read_buffer: Vec<u8>,
|
||||
}
|
||||
|
||||
impl UsageSubscription {
|
||||
pub(super) async fn connect(port: u16, management_key: &str) -> Result<Self, String> {
|
||||
let address = format!("127.0.0.1:{port}");
|
||||
let stream = timeout(CONNECT_TIMEOUT, TcpStream::connect(&address))
|
||||
.await
|
||||
.map_err(|_| format!("连接 CPA usage 订阅超时: {address}"))?
|
||||
.map_err(|error| format!("连接 CPA usage 订阅失败 {address}: {error}"))?;
|
||||
let mut subscription = Self {
|
||||
stream,
|
||||
read_buffer: Vec::new(),
|
||||
};
|
||||
subscription.send_command(&["AUTH", management_key]).await?;
|
||||
match subscription.read_frame().await? {
|
||||
RespValue::Simple(value) if value.eq_ignore_ascii_case("OK") => {}
|
||||
RespValue::Error(error) => return Err(format!("CPA usage 订阅认证失败: {error}")),
|
||||
value => return Err(format!("CPA usage 订阅认证响应无效: {}", value.kind())),
|
||||
}
|
||||
subscription
|
||||
.send_command(&["SUBSCRIBE", USAGE_CHANNEL])
|
||||
.await?;
|
||||
let acknowledgement = subscription.read_frame().await?;
|
||||
if !is_subscription_ack(&acknowledgement) {
|
||||
return Err("CPA 未确认 usage 订阅".to_string());
|
||||
}
|
||||
Ok(subscription)
|
||||
}
|
||||
|
||||
pub(super) async fn next_message(&mut self) -> Result<String, String> {
|
||||
loop {
|
||||
let frame = self.read_frame().await?;
|
||||
if let Some(payload) = subscription_payload(&frame) {
|
||||
return Ok(payload);
|
||||
}
|
||||
if let RespValue::Error(error) = frame {
|
||||
return Err(format!("CPA usage 订阅返回错误: {error}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_command(&mut self, parts: &[&str]) -> Result<(), String> {
|
||||
let mut command = format!("*{}\r\n", parts.len()).into_bytes();
|
||||
for part in parts {
|
||||
command.extend_from_slice(format!("${}\r\n", part.len()).as_bytes());
|
||||
command.extend_from_slice(part.as_bytes());
|
||||
command.extend_from_slice(b"\r\n");
|
||||
}
|
||||
timeout(IO_TIMEOUT, self.stream.write_all(&command))
|
||||
.await
|
||||
.map_err(|_| "写入 CPA usage 订阅命令超时".to_string())?
|
||||
.map_err(|error| format!("写入 CPA usage 订阅命令失败: {error}"))
|
||||
}
|
||||
|
||||
async fn read_frame(&mut self) -> Result<RespValue, String> {
|
||||
loop {
|
||||
match parse_resp_frame(&self.read_buffer, 0, 0)? {
|
||||
ParseResult::Complete(value, consumed) => {
|
||||
self.read_buffer.drain(..consumed);
|
||||
return Ok(value);
|
||||
}
|
||||
ParseResult::Incomplete => {}
|
||||
}
|
||||
if self.read_buffer.len() >= MAX_FRAME_BYTES {
|
||||
return Err("CPA usage 订阅响应超过大小限制".to_string());
|
||||
}
|
||||
let mut chunk = [0_u8; 8192];
|
||||
let read = timeout(IO_TIMEOUT, self.stream.read(&mut chunk))
|
||||
.await
|
||||
.map_err(|_| "读取 CPA usage 订阅响应超时".to_string())?
|
||||
.map_err(|error| format!("读取 CPA usage 订阅响应失败: {error}"))?;
|
||||
if read == 0 {
|
||||
return Err("CPA usage 订阅连接已关闭".to_string());
|
||||
}
|
||||
self.read_buffer.extend_from_slice(&chunk[..read]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum RespValue {
|
||||
Simple(String),
|
||||
Error(String),
|
||||
Integer(i64),
|
||||
Bulk(Option<Vec<u8>>),
|
||||
Array(Option<Vec<RespValue>>),
|
||||
}
|
||||
|
||||
impl RespValue {
|
||||
fn kind(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Simple(_) => "simple string",
|
||||
Self::Error(_) => "error",
|
||||
Self::Integer(_) => "integer",
|
||||
Self::Bulk(_) => "bulk string",
|
||||
Self::Array(_) => "array",
|
||||
}
|
||||
}
|
||||
|
||||
fn text(&self) -> Option<String> {
|
||||
match self {
|
||||
Self::Simple(value) | Self::Error(value) => Some(value.clone()),
|
||||
Self::Bulk(Some(value)) => String::from_utf8(value.clone()).ok(),
|
||||
Self::Integer(value) => Some(value.to_string()),
|
||||
Self::Bulk(None) | Self::Array(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ParseResult {
|
||||
Complete(RespValue, usize),
|
||||
Incomplete,
|
||||
}
|
||||
|
||||
fn parse_resp_frame(input: &[u8], offset: usize, depth: usize) -> Result<ParseResult, String> {
|
||||
if depth > MAX_NESTING_DEPTH {
|
||||
return Err("CPA usage 订阅响应嵌套过深".to_string());
|
||||
}
|
||||
let Some(prefix) = input.get(offset).copied() else {
|
||||
return Ok(ParseResult::Incomplete);
|
||||
};
|
||||
match prefix {
|
||||
b'+' | b'-' | b':' => {
|
||||
let Some((line, next)) = resp_line(input, offset + 1) else {
|
||||
return Ok(ParseResult::Incomplete);
|
||||
};
|
||||
let text = String::from_utf8(line.to_vec())
|
||||
.map_err(|_| "CPA usage 订阅文本响应不是 UTF-8".to_string())?;
|
||||
let value = match prefix {
|
||||
b'+' => RespValue::Simple(text),
|
||||
b'-' => RespValue::Error(text),
|
||||
_ => RespValue::Integer(
|
||||
text.parse::<i64>()
|
||||
.map_err(|_| "CPA usage 订阅整数响应无效".to_string())?,
|
||||
),
|
||||
};
|
||||
Ok(ParseResult::Complete(value, next - offset))
|
||||
}
|
||||
b'$' => {
|
||||
let Some((line, data_start)) = resp_line(input, offset + 1) else {
|
||||
return Ok(ParseResult::Incomplete);
|
||||
};
|
||||
let length = parse_resp_length(line)?;
|
||||
if length < 0 {
|
||||
return Ok(ParseResult::Complete(
|
||||
RespValue::Bulk(None),
|
||||
data_start - offset,
|
||||
));
|
||||
}
|
||||
let length =
|
||||
usize::try_from(length).map_err(|_| "CPA usage 订阅字符串长度无效".to_string())?;
|
||||
if length > MAX_FRAME_BYTES {
|
||||
return Err("CPA usage 订阅字符串超过大小限制".to_string());
|
||||
}
|
||||
let data_end = data_start.saturating_add(length);
|
||||
let frame_end = data_end.saturating_add(2);
|
||||
if input.len() < frame_end {
|
||||
return Ok(ParseResult::Incomplete);
|
||||
}
|
||||
if input.get(data_end..frame_end) != Some(b"\r\n") {
|
||||
return Err("CPA usage 订阅字符串结尾无效".to_string());
|
||||
}
|
||||
Ok(ParseResult::Complete(
|
||||
RespValue::Bulk(Some(input[data_start..data_end].to_vec())),
|
||||
frame_end - offset,
|
||||
))
|
||||
}
|
||||
b'*' => {
|
||||
let Some((line, mut next)) = resp_line(input, offset + 1) else {
|
||||
return Ok(ParseResult::Incomplete);
|
||||
};
|
||||
let length = parse_resp_length(line)?;
|
||||
if length < 0 {
|
||||
return Ok(ParseResult::Complete(RespValue::Array(None), next - offset));
|
||||
}
|
||||
let length =
|
||||
usize::try_from(length).map_err(|_| "CPA usage 订阅数组长度无效".to_string())?;
|
||||
if length > MAX_ARRAY_LENGTH {
|
||||
return Err("CPA usage 订阅数组超过大小限制".to_string());
|
||||
}
|
||||
let mut values = Vec::with_capacity(length);
|
||||
for _ in 0..length {
|
||||
match parse_resp_frame(input, next, depth + 1)? {
|
||||
ParseResult::Complete(value, consumed) => {
|
||||
values.push(value);
|
||||
next = next.saturating_add(consumed);
|
||||
}
|
||||
ParseResult::Incomplete => return Ok(ParseResult::Incomplete),
|
||||
}
|
||||
}
|
||||
Ok(ParseResult::Complete(
|
||||
RespValue::Array(Some(values)),
|
||||
next - offset,
|
||||
))
|
||||
}
|
||||
_ => Err("CPA usage 订阅响应类型无效".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn resp_line(input: &[u8], start: usize) -> Option<(&[u8], usize)> {
|
||||
let relative_end = input
|
||||
.get(start..)?
|
||||
.windows(2)
|
||||
.position(|pair| pair == b"\r\n")?;
|
||||
let end = start + relative_end;
|
||||
Some((&input[start..end], end + 2))
|
||||
}
|
||||
|
||||
fn parse_resp_length(value: &[u8]) -> Result<i64, String> {
|
||||
std::str::from_utf8(value)
|
||||
.map_err(|_| "CPA usage 订阅长度不是 UTF-8".to_string())?
|
||||
.parse::<i64>()
|
||||
.map_err(|_| "CPA usage 订阅长度无效".to_string())
|
||||
}
|
||||
|
||||
fn is_subscription_ack(value: &RespValue) -> bool {
|
||||
let RespValue::Array(Some(values)) = value else {
|
||||
return false;
|
||||
};
|
||||
values.len() >= 2
|
||||
&& values[0]
|
||||
.text()
|
||||
.is_some_and(|value| value.eq_ignore_ascii_case("subscribe"))
|
||||
&& values[1].text().as_deref() == Some(USAGE_CHANNEL)
|
||||
}
|
||||
|
||||
fn subscription_payload(value: &RespValue) -> Option<String> {
|
||||
let RespValue::Array(Some(values)) = value else {
|
||||
return None;
|
||||
};
|
||||
if values.len() < 3
|
||||
|| !values[0]
|
||||
.text()
|
||||
.is_some_and(|value| value.eq_ignore_ascii_case("message"))
|
||||
|| values[1].text().as_deref() != Some(USAGE_CHANNEL)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
values[2].text()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
#[test]
|
||||
fn parses_usage_subscription_message() {
|
||||
let input = b"*3\r\n$7\r\nmessage\r\n$5\r\nusage\r\n$16\r\n{\"request_id\":1}\r\n";
|
||||
let ParseResult::Complete(value, consumed) = parse_resp_frame(input, 0, 0).unwrap() else {
|
||||
panic!("expected complete RESP frame");
|
||||
};
|
||||
assert_eq!(consumed, input.len());
|
||||
assert_eq!(
|
||||
subscription_payload(&value).as_deref(),
|
||||
Some("{\"request_id\":1}")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_oversized_arrays() {
|
||||
let error = match parse_resp_frame(b"*10001\r\n", 0, 0) {
|
||||
Err(error) => error,
|
||||
_ => panic!("expected oversized RESP array to fail"),
|
||||
};
|
||||
assert!(error.contains("数组超过大小限制"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authenticates_subscribes_and_receives_usage_payload() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
let expected_auth = b"*2\r\n$4\r\nAUTH\r\n$6\r\nsecret\r\n";
|
||||
let mut auth = vec![0_u8; expected_auth.len()];
|
||||
stream.read_exact(&mut auth).await.unwrap();
|
||||
assert_eq!(auth, expected_auth);
|
||||
stream.write_all(b"+OK\r\n").await.unwrap();
|
||||
|
||||
let expected_subscribe = b"*2\r\n$9\r\nSUBSCRIBE\r\n$5\r\nusage\r\n";
|
||||
let mut subscribe = vec![0_u8; expected_subscribe.len()];
|
||||
stream.read_exact(&mut subscribe).await.unwrap();
|
||||
assert_eq!(subscribe, expected_subscribe);
|
||||
stream
|
||||
.write_all(b"*3\r\n$9\r\nsubscribe\r\n$5\r\nusage\r\n:1\r\n")
|
||||
.await
|
||||
.unwrap();
|
||||
stream
|
||||
.write_all(b"*3\r\n$7\r\nmessage\r\n$5\r\nusage\r\n$16\r\n{\"request_id\":1}\r\n")
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
let mut subscription = UsageSubscription::connect(port, "secret").await.unwrap();
|
||||
assert_eq!(
|
||||
subscription.next_message().await.unwrap(),
|
||||
"{\"request_id\":1}"
|
||||
);
|
||||
server.await.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -274,6 +274,7 @@ export const jaOverrides = {
|
||||
'config.error.duplicateKey': '同じ認証キーがすでに存在します',
|
||||
'config.error.remarkTooLong': 'メモは 80 文字以内で入力してください',
|
||||
'config.error.portRange': 'ポートは 1〜65535 の整数で入力してください',
|
||||
'config.error.retryRange': '再試行設定は 0〜4294967295 の整数で入力してください',
|
||||
'config.error.networkRestartFailed': '設定は保存されましたが、コアの再起動に失敗しました:{error}',
|
||||
'config.error.saveFailed': '保存に失敗しました:{error}',
|
||||
'config.notice.keyAdded': '認証キーを追加しました',
|
||||
@@ -330,6 +331,7 @@ export const jaOverrides = {
|
||||
'config.network.title': 'ネットワークとセッションルーティング',
|
||||
'config.network.networkSection': 'ネットワーク設定',
|
||||
'config.network.routingSection': 'セッションルーティング',
|
||||
'config.network.retrySection': '失敗時の再試行',
|
||||
'config.network.saving': '保存中',
|
||||
'config.network.saved': '保存済み',
|
||||
'config.network.unsaved': '未保存の変更あり',
|
||||
@@ -345,6 +347,14 @@ export const jaOverrides = {
|
||||
'config.network.sessionAffinityHint': '同じセッションでは以前選択した認証情報を優先します',
|
||||
'config.network.sessionTtl': 'セッションアフィニティ TTL',
|
||||
'config.network.sessionTtlHint': '例:1h。空欄の場合はコアのデフォルト値を使用します',
|
||||
'config.network.requestRetry': 'リクエスト再試行回数',
|
||||
'config.network.requestRetryHint': '403、408、500、502、503、504 応答の再試行回数です。0 で無効になります',
|
||||
'config.network.maxRetryCredentials': '試行する認証情報の上限',
|
||||
'config.network.maxRetryCredentialsHint': '失敗した1リクエストで試す認証情報の上限です。0 ですべてを試します',
|
||||
'config.network.maxRetryInterval': '再試行の最大待機時間(秒)',
|
||||
'config.network.maxRetryIntervalHint': 'クールダウン中の認証情報を待つ上限です。小さくすると累積待機時間を短縮できますが、リクエストの強制タイムアウトではありません',
|
||||
'config.network.streamingBootstrapRetries': 'ストリーミング開始時の再試行回数',
|
||||
'config.network.streamingBootstrapRetriesHint': '最初の応答を送信する前に許可する安全な再試行回数です。0 で無効になります',
|
||||
'config.notice.softwareUpdated': 'ソフトウェア設定を保存しました',
|
||||
'config.software.title': 'ソフトウェア設定',
|
||||
'config.software.autostart': 'システム起動時に開始',
|
||||
|
||||
@@ -274,6 +274,7 @@ export const en: Record<MessageKey, string> = {
|
||||
'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.retryRange': 'Retry settings must be integers from 0 to 4294967295',
|
||||
'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',
|
||||
@@ -341,6 +342,7 @@ export const en: Record<MessageKey, string> = {
|
||||
'config.network.title': 'Network & Session Routing',
|
||||
'config.network.networkSection': 'Network Settings',
|
||||
'config.network.routingSection': 'Session Routing',
|
||||
'config.network.retrySection': 'Failure Retries',
|
||||
'config.network.saving': 'Saving',
|
||||
'config.network.saved': 'Saved',
|
||||
'config.network.unsaved': 'Unsaved changes',
|
||||
@@ -356,6 +358,14 @@ export const en: Record<MessageKey, string> = {
|
||||
'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.network.requestRetry': 'Request Retries',
|
||||
'config.network.requestRetryHint': 'Retries responses with status 403, 408, 500, 502, 503, or 504; 0 disables retries',
|
||||
'config.network.maxRetryCredentials': 'Maximum Credentials Tried',
|
||||
'config.network.maxRetryCredentialsHint': 'Maximum credentials tried for one failed request; 0 tries all available credentials',
|
||||
'config.network.maxRetryInterval': 'Maximum Retry Wait (seconds)',
|
||||
'config.network.maxRetryIntervalHint': 'Limits how long to wait for cooled-down credentials; lowering it reduces cumulative wait, but is not a hard request timeout',
|
||||
'config.network.streamingBootstrapRetries': 'Streaming Bootstrap Retries',
|
||||
'config.network.streamingBootstrapRetriesHint': 'Safe retries allowed before the first response is sent; 0 disables them',
|
||||
'config.keys.addTitle': 'Add Authentication Key',
|
||||
'config.keys.editTitle': 'Edit Authentication Key',
|
||||
'common.close': 'Close',
|
||||
|
||||
@@ -272,6 +272,7 @@ export const zhCN = {
|
||||
'config.error.duplicateKey': '该鉴权密钥已经存在',
|
||||
'config.error.remarkTooLong': '备注不能超过 80 个字符',
|
||||
'config.error.portRange': '端口必须是 1–65535 之间的整数',
|
||||
'config.error.retryRange': '重试设置必须是 0–4294967295 之间的整数',
|
||||
'config.error.networkRestartFailed': '设置已保存,但内核重启失败:{error}',
|
||||
'config.error.saveFailed': '保存失败:{error}',
|
||||
'config.notice.keyAdded': '鉴权密钥已新增',
|
||||
@@ -339,6 +340,7 @@ export const zhCN = {
|
||||
'config.network.title': '网络与会话路由',
|
||||
'config.network.networkSection': '网络设置',
|
||||
'config.network.routingSection': '会话路由',
|
||||
'config.network.retrySection': '失败重试',
|
||||
'config.network.saving': '正在保存',
|
||||
'config.network.saved': '已保存',
|
||||
'config.network.unsaved': '有未保存修改',
|
||||
@@ -354,6 +356,14 @@ export const zhCN = {
|
||||
'config.network.sessionAffinityHint': '同一会话优先复用已选择的凭据',
|
||||
'config.network.sessionTtl': '会话粘性 TTL',
|
||||
'config.network.sessionTtlHint': '例如 1h;留空使用内核默认值',
|
||||
'config.network.requestRetry': '请求重试次数',
|
||||
'config.network.requestRetryHint': '对 403、408、500、502、503、504 响应的重试次数;0 表示不重试',
|
||||
'config.network.maxRetryCredentials': '最大尝试凭据数',
|
||||
'config.network.maxRetryCredentialsHint': '单次失败请求最多尝试的凭据数;0 表示尝试全部可用凭据',
|
||||
'config.network.maxRetryInterval': '最大重试等待(秒)',
|
||||
'config.network.maxRetryIntervalHint': '等待冷却凭据的最长时间;减小可缩短累计等待,但不是单次请求硬超时',
|
||||
'config.network.streamingBootstrapRetries': '流式启动重试次数',
|
||||
'config.network.streamingBootstrapRetriesHint': '发送首个响应前允许的安全重试次数;0 表示禁用',
|
||||
'config.keys.addTitle': '新增鉴权密钥',
|
||||
'config.keys.editTitle': '编辑鉴权密钥',
|
||||
'common.close': '关闭',
|
||||
|
||||
+87
-39
@@ -1,4 +1,4 @@
|
||||
import { FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import {
|
||||
Check,
|
||||
@@ -79,6 +79,13 @@ type ProviderRow = {
|
||||
remark: string;
|
||||
};
|
||||
|
||||
type ProviderSaveResult =
|
||||
| { saved: true }
|
||||
| { saved: false; error: string; target: 'form' | 'models' };
|
||||
|
||||
const requestErrorMessage = (error: unknown) =>
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
export type ProviderDraft = {
|
||||
name: string;
|
||||
apiKey: string;
|
||||
@@ -758,12 +765,14 @@ export function ApiAccessPage() {
|
||||
);
|
||||
|
||||
const openCreate = () => {
|
||||
setError('');
|
||||
setEditingRow(null);
|
||||
setDialogDraft(createProviderDraft(activeCategory));
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (row: ProviderRow) => {
|
||||
setError('');
|
||||
setEditingRow(row);
|
||||
const draft = draftFromRow(row);
|
||||
setDialogDraft(activeCategory === 'deepseek'
|
||||
@@ -772,7 +781,7 @@ export function ApiAccessPage() {
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const saveProvider = async (nextDraft: ProviderDraft): Promise<boolean> => {
|
||||
const saveProvider = async (nextDraft: ProviderDraft): Promise<ProviderSaveResult> => {
|
||||
const definition = activeDefinition;
|
||||
const preparedDraft = applyProviderRemarkIdentity(
|
||||
activeCategory,
|
||||
@@ -790,18 +799,18 @@ export function ApiAccessPage() {
|
||||
|| (remarkRequired && !preparedDraft.remark.trim())
|
||||
|| (baseUrlRequired && !preparedDraft.baseUrl.trim())
|
||||
) {
|
||||
setError(
|
||||
definition.openAi
|
||||
return {
|
||||
saved: false,
|
||||
target: 'form',
|
||||
error: definition.openAi
|
||||
? t('apiAccess.error.requiredAll')
|
||||
: baseUrlRequired
|
||||
? t('apiAccess.error.requiredBaseKey')
|
||||
: t('apiAccess.error.requiredKey'),
|
||||
);
|
||||
return false;
|
||||
};
|
||||
}
|
||||
if (Array.from(preparedDraft.remark.trim()).length > 80 || /[\u0000-\u001f\u007f]/.test(preparedDraft.remark)) {
|
||||
setError(t('apiAccess.error.remarkInvalid'));
|
||||
return false;
|
||||
return { saved: false, target: 'form', error: t('apiAccess.error.remarkInvalid') };
|
||||
}
|
||||
let baseUrl = preparedDraft.baseUrl.trim();
|
||||
let providerHeaders: Record<string, string> = {};
|
||||
@@ -810,23 +819,31 @@ export function ApiAccessPage() {
|
||||
if (baseUrlRequired && !baseUrl) throw new Error(t('apiAccess.error.baseRequired', { provider: definition.label }));
|
||||
providerHeaders = parseProviderHeaders(preparedDraft.headersText ?? '');
|
||||
} catch (requestError) {
|
||||
setError(String(requestError));
|
||||
return false;
|
||||
return { saved: false, target: 'form', error: requestErrorMessage(requestError) };
|
||||
}
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
let draftToSave = { ...preparedDraftForSave, baseUrl };
|
||||
if (definition.openAi && draftToSave.models.length === 0) {
|
||||
const fetchedModels = await fetchModels(
|
||||
'openai',
|
||||
baseUrl,
|
||||
parsedApiKeys[0],
|
||||
editingRow?.authIndex,
|
||||
providerHeaders,
|
||||
);
|
||||
if (fetchedModels.length === 0) {
|
||||
throw new Error(t('apiAccess.error.noModels'));
|
||||
let fetchedModels: ModelOption[];
|
||||
try {
|
||||
fetchedModels = await fetchModels(
|
||||
'openai',
|
||||
baseUrl,
|
||||
parsedApiKeys[0],
|
||||
editingRow?.authIndex,
|
||||
providerHeaders,
|
||||
);
|
||||
if (fetchedModels.length === 0) {
|
||||
throw new Error(t('apiAccess.error.noModels'));
|
||||
}
|
||||
} catch (requestError) {
|
||||
return {
|
||||
saved: false,
|
||||
target: 'models',
|
||||
error: requestErrorMessage(requestError),
|
||||
};
|
||||
}
|
||||
draftToSave = applyProviderPreset(activeCategory, {
|
||||
...draftToSave,
|
||||
@@ -883,10 +900,9 @@ export function ApiAccessPage() {
|
||||
});
|
||||
setNotice(editingRow ? t('apiAccess.notice.updated') : t('apiAccess.notice.added'));
|
||||
await loadProviders();
|
||||
return true;
|
||||
return { saved: true };
|
||||
} catch (requestError) {
|
||||
setError(String(requestError));
|
||||
return false;
|
||||
return { saved: false, target: 'form', error: requestErrorMessage(requestError) };
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -1312,7 +1328,7 @@ type ApiProviderDialogProps = {
|
||||
initialDraft: ProviderDraft;
|
||||
busy: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (draft: ProviderDraft) => Promise<boolean>;
|
||||
onSave: (draft: ProviderDraft) => Promise<ProviderSaveResult>;
|
||||
};
|
||||
|
||||
function ApiProviderDialog({
|
||||
@@ -1329,6 +1345,7 @@ function ApiProviderDialog({
|
||||
const [draft, setDraft] = useState<ProviderDraft>(initialDraft);
|
||||
const [modelLoading, setModelLoading] = useState(false);
|
||||
const [modelError, setModelError] = useState('');
|
||||
const [formError, setFormError] = useState('');
|
||||
const [discoveredModels, setDiscoveredModels] = useState<ModelOption[]>([]);
|
||||
const [modelDiscoveryOpen, setModelDiscoveryOpen] = useState(false);
|
||||
const [modelSearch, setModelSearch] = useState('');
|
||||
@@ -1336,6 +1353,7 @@ function ApiProviderDialog({
|
||||
const [selectedModelNames, setSelectedModelNames] = useState<Set<string>>(
|
||||
() => new Set(initialDraft.models.map((model) => model.name.toLowerCase())),
|
||||
);
|
||||
const modelCardRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const modelOptions = useMemo(() => {
|
||||
const options = new Map<string, ModelOption>();
|
||||
@@ -1366,6 +1384,10 @@ function ApiProviderDialog({
|
||||
field: 'apiKey' | 'remark' | 'baseUrl' | 'priority' | 'prefix' | 'headersText' | 'excludedModelsText' | 'testModel' | 'cloakMode' | 'cloakSensitiveWordsText',
|
||||
value: string,
|
||||
) => {
|
||||
setFormError('');
|
||||
if (field === 'apiKey' || field === 'baseUrl' || field === 'headersText') {
|
||||
setModelError('');
|
||||
}
|
||||
setDraft((current) => ({ ...current, [field]: value }));
|
||||
};
|
||||
|
||||
@@ -1373,6 +1395,7 @@ function ApiProviderDialog({
|
||||
field: 'disableCooling' | 'websockets' | 'cloakStrictMode' | 'cloakCacheUserId',
|
||||
value: boolean,
|
||||
) => {
|
||||
setFormError('');
|
||||
setDraft((current) => ({ ...current, [field]: value }));
|
||||
};
|
||||
|
||||
@@ -1457,8 +1480,7 @@ function ApiProviderDialog({
|
||||
));
|
||||
if (!models.length) setModelError(t('apiAccess.error.noAvailableModels'));
|
||||
} catch (requestError) {
|
||||
setDiscoveredModels([]);
|
||||
setModelError(String(requestError));
|
||||
setModelError(requestErrorMessage(requestError));
|
||||
} finally {
|
||||
setModelLoading(false);
|
||||
}
|
||||
@@ -1519,7 +1541,20 @@ function ApiProviderDialog({
|
||||
|
||||
const submit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (await onSave(draft)) onClose();
|
||||
setFormError('');
|
||||
const result = await onSave(draft);
|
||||
if (result.saved) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (result.target === 'models') {
|
||||
setModelError(result.error);
|
||||
window.requestAnimationFrame(() => {
|
||||
modelCardRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
});
|
||||
return;
|
||||
}
|
||||
setFormError(result.error);
|
||||
};
|
||||
|
||||
const hasModelExclusions = activeSection !== 'openai-compatibility'
|
||||
@@ -1619,7 +1654,7 @@ function ApiProviderDialog({
|
||||
) : <small className="thinking-level-empty">{t('apiAccess.thinking.empty')}</small>}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="model-config-card">
|
||||
<div className="model-config-card" ref={modelCardRef}>
|
||||
<div className="model-config-heading">
|
||||
<div><span>{t('apiAccess.models.title')}</span><small>{t('apiAccess.models.description')}</small></div>
|
||||
<button type="button" className="secondary-button compact-button" onClick={openModelDiscovery} disabled={busy}>
|
||||
@@ -1720,6 +1755,11 @@ function ApiProviderDialog({
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
{formError ? (
|
||||
<div className="management-alert error api-provider-dialog-error" role="alert">
|
||||
{formError}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="config-dialog-actions two-actions">
|
||||
<button type="button" className="secondary-button" onClick={onClose} disabled={busy}>{t('common.cancel')}</button>
|
||||
<button type="submit" className="primary-button" disabled={busy}>{busy ? t('common.saving') : t('common.save')}</button>
|
||||
@@ -1754,22 +1794,30 @@ function ApiProviderDialog({
|
||||
<div className="model-discovery-content">
|
||||
{modelLoading ? (
|
||||
<div className="model-discovery-message"><LoaderCircle size={20} className="spin" />{t('apiAccess.modelDialog.fetching')}</div>
|
||||
) : modelError ? (
|
||||
) : modelError && modelOptions.length === 0 ? (
|
||||
<div className="model-discovery-message error"><strong>{t('apiAccess.modelDialog.fetchFailed')}</strong><span>{modelError}</span></div>
|
||||
) : visibleModelOptions.length === 0 ? (
|
||||
<div className="model-discovery-message"><strong>{modelOptions.length ? t('apiAccess.modelDialog.noMatch') : t('apiAccess.modelDialog.none')}</strong><span>{modelOptions.length ? t('apiAccess.modelDialog.tryKeyword') : t('apiAccess.modelDialog.checkCredentials')}</span></div>
|
||||
) : (
|
||||
<div className="model-discovery-list">
|
||||
{visibleModelOptions.map((model) => {
|
||||
const checked = selectedModelNames.has(model.name.toLowerCase());
|
||||
return (
|
||||
<label className={`model-discovery-row ${checked ? 'selected' : ''}`} key={model.name}>
|
||||
<input type="checkbox" checked={checked} onChange={() => toggleModelSelection(model)} />
|
||||
<span><strong title={model.name}>{model.name}</strong>{model.alias ? <small title={model.alias}>{model.alias}</small> : null}</span>
|
||||
{checked ? <Check size={16} aria-hidden="true" /> : null}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
<div className="model-discovery-results">
|
||||
{modelError ? (
|
||||
<div className="model-discovery-inline-error" role="alert">
|
||||
<strong>{t('apiAccess.modelDialog.fetchFailed')}</strong>
|
||||
<span title={modelError}>{modelError}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="model-discovery-list">
|
||||
{visibleModelOptions.map((model) => {
|
||||
const checked = selectedModelNames.has(model.name.toLowerCase());
|
||||
return (
|
||||
<label className={`model-discovery-row ${checked ? 'selected' : ''}`} key={model.name}>
|
||||
<input type="checkbox" checked={checked} onChange={() => toggleModelSelection(model)} />
|
||||
<span><strong title={model.name}>{model.name}</strong>{model.alias ? <small title={model.alias}>{model.alias}</small> : null}</span>
|
||||
{checked ? <Check size={16} aria-hidden="true" /> : null}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+179
-1
@@ -35,6 +35,10 @@ type CoreConfigSettings = {
|
||||
proxyUrl: string;
|
||||
routingSessionAffinity: boolean;
|
||||
routingSessionAffinityTtl: string;
|
||||
requestRetry: number;
|
||||
maxRetryCredentials: number;
|
||||
maxRetryInterval: number;
|
||||
streamingBootstrapRetries: number;
|
||||
};
|
||||
|
||||
type CoreApiKey = {
|
||||
@@ -54,7 +58,16 @@ type ConfigAction =
|
||||
type NoticeTone = 'success' | 'error';
|
||||
type ConfigSubpage = 'general' | 'network' | 'software' | 'aliases';
|
||||
type CloseBehavior = 'ask' | 'exit' | 'minimize-to-tray';
|
||||
type NetworkDraftField = 'port' | 'allowLan' | 'proxyUrl' | 'sessionAffinity' | 'sessionTtl';
|
||||
type NetworkDraftField =
|
||||
| 'port'
|
||||
| 'allowLan'
|
||||
| 'proxyUrl'
|
||||
| 'sessionAffinity'
|
||||
| 'sessionTtl'
|
||||
| 'requestRetry'
|
||||
| 'maxRetryCredentials'
|
||||
| 'maxRetryInterval'
|
||||
| 'streamingBootstrapRetries';
|
||||
type DraftRefreshMode = 'replace' | 'preserve';
|
||||
|
||||
type NetworkDraftDirty = Record<NetworkDraftField, boolean>;
|
||||
@@ -71,6 +84,10 @@ const cleanNetworkDraft = (): NetworkDraftDirty => ({
|
||||
proxyUrl: false,
|
||||
sessionAffinity: false,
|
||||
sessionTtl: false,
|
||||
requestRetry: false,
|
||||
maxRetryCredentials: false,
|
||||
maxRetryInterval: false,
|
||||
streamingBootstrapRetries: false,
|
||||
});
|
||||
|
||||
const ROUTING_OPTIONS = [
|
||||
@@ -110,7 +127,12 @@ export function ConfigPanelPage() {
|
||||
const [proxyUrlDraft, setProxyUrlDraft] = useState('');
|
||||
const [sessionAffinityDraft, setSessionAffinityDraft] = useState(false);
|
||||
const [sessionTtlDraft, setSessionTtlDraft] = useState('');
|
||||
const [requestRetryDraft, setRequestRetryDraft] = useState('3');
|
||||
const [maxRetryCredentialsDraft, setMaxRetryCredentialsDraft] = useState('0');
|
||||
const [maxRetryIntervalDraft, setMaxRetryIntervalDraft] = useState('30');
|
||||
const [streamingBootstrapRetriesDraft, setStreamingBootstrapRetriesDraft] = useState('0');
|
||||
const [portError, setPortError] = useState('');
|
||||
const [retryError, setRetryError] = useState('');
|
||||
const [savedStatusVisible, setSavedStatusVisible] = useState(false);
|
||||
const networkDraftDirtyRef = useRef<NetworkDraftDirty>(cleanNetworkDraft());
|
||||
const noticeTimerRef = useRef<number | null>(null);
|
||||
@@ -162,6 +184,12 @@ export function ConfigPanelPage() {
|
||||
if (!dirty.proxyUrl) setProxyUrlDraft(result.proxyUrl);
|
||||
if (!dirty.sessionAffinity) setSessionAffinityDraft(result.routingSessionAffinity);
|
||||
if (!dirty.sessionTtl) setSessionTtlDraft(result.routingSessionAffinityTtl);
|
||||
if (!dirty.requestRetry) setRequestRetryDraft(String(result.requestRetry));
|
||||
if (!dirty.maxRetryCredentials) setMaxRetryCredentialsDraft(String(result.maxRetryCredentials));
|
||||
if (!dirty.maxRetryInterval) setMaxRetryIntervalDraft(String(result.maxRetryInterval));
|
||||
if (!dirty.streamingBootstrapRetries) {
|
||||
setStreamingBootstrapRetriesDraft(String(result.streamingBootstrapRetries));
|
||||
}
|
||||
return;
|
||||
}
|
||||
networkDraftDirtyRef.current = cleanNetworkDraft();
|
||||
@@ -170,7 +198,12 @@ export function ConfigPanelPage() {
|
||||
setProxyUrlDraft(result.proxyUrl);
|
||||
setSessionAffinityDraft(result.routingSessionAffinity);
|
||||
setSessionTtlDraft(result.routingSessionAffinityTtl);
|
||||
setRequestRetryDraft(String(result.requestRetry));
|
||||
setMaxRetryCredentialsDraft(String(result.maxRetryCredentials));
|
||||
setMaxRetryIntervalDraft(String(result.maxRetryInterval));
|
||||
setStreamingBootstrapRetriesDraft(String(result.streamingBootstrapRetries));
|
||||
setPortError('');
|
||||
setRetryError('');
|
||||
};
|
||||
|
||||
const markDraftDirty = (field: NetworkDraftField) => {
|
||||
@@ -426,8 +459,25 @@ export function ConfigPanelPage() {
|
||||
|
||||
const proxyUrl = proxyUrlDraft.trim();
|
||||
const routingSessionAffinityTtl = sessionTtlDraft.trim();
|
||||
const retryDrafts = [
|
||||
requestRetryDraft,
|
||||
maxRetryCredentialsDraft,
|
||||
maxRetryIntervalDraft,
|
||||
streamingBootstrapRetriesDraft,
|
||||
];
|
||||
const retryValues = retryDrafts.map(Number);
|
||||
if (
|
||||
retryDrafts.some((value) => value.length === 0)
|
||||
|| retryValues.some((value) => !Number.isInteger(value) || value < 0 || value > 4294967295)
|
||||
) {
|
||||
setRetryError(t('config.error.retryRange'));
|
||||
showNotice(t('config.error.retryRange'), 'error');
|
||||
return;
|
||||
}
|
||||
const [requestRetry, maxRetryCredentials, maxRetryInterval, streamingBootstrapRetries] = retryValues;
|
||||
const networkChanged = port !== settings.port || allowLanDraft !== settings.allowLan;
|
||||
setPortError('');
|
||||
setRetryError('');
|
||||
setSavedStatusVisible(false);
|
||||
setBusyAction('network');
|
||||
try {
|
||||
@@ -438,6 +488,10 @@ export function ConfigPanelPage() {
|
||||
proxyUrl,
|
||||
routingSessionAffinity: sessionAffinityDraft,
|
||||
routingSessionAffinityTtl,
|
||||
requestRetry,
|
||||
maxRetryCredentials,
|
||||
maxRetryInterval,
|
||||
streamingBootstrapRetries,
|
||||
},
|
||||
});
|
||||
applySettings(result);
|
||||
@@ -509,6 +563,10 @@ export function ConfigPanelPage() {
|
||||
|| proxyUrlDraft.trim() !== settings?.proxyUrl
|
||||
|| sessionAffinityDraft !== settings?.routingSessionAffinity
|
||||
|| sessionTtlDraft.trim() !== settings?.routingSessionAffinityTtl
|
||||
|| requestRetryDraft !== String(settings?.requestRetry)
|
||||
|| maxRetryCredentialsDraft !== String(settings?.maxRetryCredentials)
|
||||
|| maxRetryIntervalDraft !== String(settings?.maxRetryInterval)
|
||||
|| streamingBootstrapRetriesDraft !== String(settings?.streamingBootstrapRetries)
|
||||
);
|
||||
const networkStatusLabel = loading
|
||||
? t('common.loading')
|
||||
@@ -1034,6 +1092,126 @@ export function ConfigPanelPage() {
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="config-network-section" aria-labelledby="config-retry-section-title">
|
||||
<div className="config-network-section-heading">
|
||||
<RefreshCw size={16} aria-hidden="true" />
|
||||
<h3 id="config-retry-section-title">{t('config.network.retrySection')}</h3>
|
||||
</div>
|
||||
<div className="config-network-grid">
|
||||
<label className="config-network-field">
|
||||
<span>{t('config.network.requestRetry')}</span>
|
||||
<input
|
||||
className={`config-network-input ${retryError ? 'error' : ''}`}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxLength={10}
|
||||
value={requestRetryDraft}
|
||||
disabled={controlsDisabled}
|
||||
aria-invalid={Boolean(retryError)}
|
||||
onChange={(event) => {
|
||||
markDraftDirty('requestRetry');
|
||||
setRequestRetryDraft(event.currentTarget.value.replace(/\D/g, '').slice(0, 10));
|
||||
setRetryError('');
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape' && settings) {
|
||||
clearDraftDirty('requestRetry');
|
||||
setRequestRetryDraft(String(settings.requestRetry));
|
||||
setRetryError('');
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<small>{t('config.network.requestRetryHint')}</small>
|
||||
</label>
|
||||
|
||||
<label className="config-network-field">
|
||||
<span>{t('config.network.maxRetryCredentials')}</span>
|
||||
<input
|
||||
className={`config-network-input ${retryError ? 'error' : ''}`}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxLength={10}
|
||||
value={maxRetryCredentialsDraft}
|
||||
disabled={controlsDisabled}
|
||||
aria-invalid={Boolean(retryError)}
|
||||
onChange={(event) => {
|
||||
markDraftDirty('maxRetryCredentials');
|
||||
setMaxRetryCredentialsDraft(event.currentTarget.value.replace(/\D/g, '').slice(0, 10));
|
||||
setRetryError('');
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape' && settings) {
|
||||
clearDraftDirty('maxRetryCredentials');
|
||||
setMaxRetryCredentialsDraft(String(settings.maxRetryCredentials));
|
||||
setRetryError('');
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<small>{t('config.network.maxRetryCredentialsHint')}</small>
|
||||
</label>
|
||||
|
||||
<label className="config-network-field">
|
||||
<span>{t('config.network.maxRetryInterval')}</span>
|
||||
<input
|
||||
className={`config-network-input ${retryError ? 'error' : ''}`}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxLength={10}
|
||||
value={maxRetryIntervalDraft}
|
||||
disabled={controlsDisabled}
|
||||
aria-invalid={Boolean(retryError)}
|
||||
onChange={(event) => {
|
||||
markDraftDirty('maxRetryInterval');
|
||||
setMaxRetryIntervalDraft(event.currentTarget.value.replace(/\D/g, '').slice(0, 10));
|
||||
setRetryError('');
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape' && settings) {
|
||||
clearDraftDirty('maxRetryInterval');
|
||||
setMaxRetryIntervalDraft(String(settings.maxRetryInterval));
|
||||
setRetryError('');
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<small>{t('config.network.maxRetryIntervalHint')}</small>
|
||||
</label>
|
||||
|
||||
<label className="config-network-field">
|
||||
<span>{t('config.network.streamingBootstrapRetries')}</span>
|
||||
<input
|
||||
className={`config-network-input ${retryError ? 'error' : ''}`}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxLength={10}
|
||||
value={streamingBootstrapRetriesDraft}
|
||||
disabled={controlsDisabled}
|
||||
aria-invalid={Boolean(retryError)}
|
||||
onChange={(event) => {
|
||||
markDraftDirty('streamingBootstrapRetries');
|
||||
setStreamingBootstrapRetriesDraft(event.currentTarget.value.replace(/\D/g, '').slice(0, 10));
|
||||
setRetryError('');
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape' && settings) {
|
||||
clearDraftDirty('streamingBootstrapRetries');
|
||||
setStreamingBootstrapRetriesDraft(String(settings.streamingBootstrapRetries));
|
||||
setRetryError('');
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<small>{t('config.network.streamingBootstrapRetriesHint')}</small>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -3104,6 +3104,29 @@ textarea:focus-visible {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.model-discovery-results {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.model-discovery-inline-error {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
padding: 8px 11px;
|
||||
border-bottom: 1px solid var(--theme-dfb7b0);
|
||||
background: var(--theme-fff1ee);
|
||||
color: var(--theme-9f2d20);
|
||||
font-size: var(--font-size-meta);
|
||||
}
|
||||
|
||||
.model-discovery-inline-error span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.model-discovery-row {
|
||||
display: grid;
|
||||
grid-template-columns: 18px minmax(0, 1fr) 18px;
|
||||
|
||||
Reference in new issue
Block a user