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

Dev
This commit is contained in:
lzt404 authored and GitHub committed 2026-08-13 23:05:09 +08:00
commit da62c3139b
19 files changed
+1953 -110

No files matched your search

+2
View File
@@ -81,6 +81,8 @@ OpenAI、Claude、Gemini、およびその他の互換形式の間で変換で
- 時間、モデル、プロバイダー、ソース、キー、結果でデータを絞り込み。
- リクエストと Token の推移、入力・出力・推論・キャッシュの使用量を確認。
- リクエスト詳細、分析画面、価格統計を閲覧。
- CPA のリアルタイム使用量購読と永続ローカル inbox で収集し、購読できない場合は HTTP に自動フォールバック。
- 起動時に旧使用履歴データベースを一度だけ移行し、移行前のバックアップを `usage-records/backups` に保存。
### エージェントクライアント設定
+2
View File
@@ -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
+2
View File
@@ -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.
+1 -1
View File
@@ -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"
+5 -7
View File
@@ -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();
+84 -1
View File
@@ -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);
}
+40
View File
@@ -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
View File
@@ -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,
}
}
}
+4
View File
@@ -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]
+58
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+320
View File
@@ -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();
}
}
+10
View File
@@ -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': 'システム起動時に開始',
+10
View File
@@ -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',
+10
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+23
View File
@@ -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;