mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-05-07 03:34:20 +08:00
feat: apply common config as runtime overlay instead of materialized merge
Common config snippets are now dynamically overlaid when writing live files, rather than being pre-merged into provider snapshots at edit time. This ensures that updating a snippet immediately takes effect for the current provider and automatically propagates to other providers on their next switch. Key changes: - Add write_live_with_common_config() overlay pipeline - Strip common config from live before backfilling provider snapshots - Normalize provider snapshots on save to keep them snippet-free - Add explicit commonConfigEnabled flag in ProviderMeta (Option<bool>) - Migrate legacy providers on snippet save (infer flag from subset check) - Add Codex TOML snippet validation in set_common_config_snippet - Stabilize onConfigChange callbacks with useCallback in ProviderForm
This commit is contained in:
@@ -28,6 +28,39 @@ fn invalid_json_format_error(error: serde_json::Error) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid_toml_format_error(error: toml_edit::TomlError) -> String {
|
||||
let lang = settings::get_settings()
|
||||
.language
|
||||
.unwrap_or_else(|| "zh".to_string());
|
||||
|
||||
match lang.as_str() {
|
||||
"en" => format!("Invalid TOML format: {error}"),
|
||||
"ja" => format!("TOML形式が無効です: {error}"),
|
||||
_ => format!("无效的 TOML 格式: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_common_config_snippet(app_type: &str, snippet: &str) -> Result<(), String> {
|
||||
if snippet.trim().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match app_type {
|
||||
"claude" | "gemini" | "omo" | "omo-slim" => {
|
||||
serde_json::from_str::<serde_json::Value>(snippet)
|
||||
.map_err(invalid_json_format_error)?;
|
||||
}
|
||||
"codex" => {
|
||||
snippet
|
||||
.parse::<toml_edit::DocumentMut>()
|
||||
.map_err(invalid_toml_format_error)?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_config_status(app: String) -> Result<ConfigStatus, String> {
|
||||
match AppType::from_str(&app).map_err(|e| e.to_string())? {
|
||||
@@ -213,16 +246,12 @@ pub async fn set_common_config_snippet(
|
||||
snippet: String,
|
||||
state: tauri::State<'_, crate::store::AppState>,
|
||||
) -> Result<(), String> {
|
||||
if !snippet.trim().is_empty() {
|
||||
match app_type.as_str() {
|
||||
"claude" | "gemini" | "omo" | "omo-slim" => {
|
||||
serde_json::from_str::<serde_json::Value>(&snippet)
|
||||
.map_err(invalid_json_format_error)?;
|
||||
}
|
||||
"codex" => {}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let old_snippet = state
|
||||
.db
|
||||
.get_config_snippet(&app_type)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
validate_common_config_snippet(&app_type, &snippet)?;
|
||||
|
||||
let value = if snippet.trim().is_empty() {
|
||||
None
|
||||
@@ -230,11 +259,33 @@ pub async fn set_common_config_snippet(
|
||||
Some(snippet)
|
||||
};
|
||||
|
||||
if matches!(app_type.as_str(), "claude" | "codex" | "gemini") {
|
||||
if let Some(legacy_snippet) = old_snippet.as_deref().filter(|value| !value.trim().is_empty())
|
||||
{
|
||||
let app = AppType::from_str(&app_type).map_err(|e| e.to_string())?;
|
||||
crate::services::provider::ProviderService::migrate_legacy_common_config_usage(
|
||||
state.inner(),
|
||||
app,
|
||||
legacy_snippet,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
|
||||
state
|
||||
.db
|
||||
.set_config_snippet(&app_type, value)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if matches!(app_type.as_str(), "claude" | "codex" | "gemini") {
|
||||
let app = AppType::from_str(&app_type).map_err(|e| e.to_string())?;
|
||||
crate::services::provider::ProviderService::sync_current_provider_for_app(
|
||||
state.inner(),
|
||||
app,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
if app_type == "omo"
|
||||
&& state
|
||||
.db
|
||||
@@ -264,6 +315,27 @@ pub async fn set_common_config_snippet(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::validate_common_config_snippet;
|
||||
|
||||
#[test]
|
||||
fn validate_common_config_snippet_accepts_comment_only_codex_snippet() {
|
||||
validate_common_config_snippet("codex", "# comment only\n")
|
||||
.expect("comment-only codex snippet should be valid");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_common_config_snippet_rejects_invalid_codex_snippet() {
|
||||
let err = validate_common_config_snippet("codex", "[broken")
|
||||
.expect_err("invalid codex snippet should be rejected");
|
||||
assert!(
|
||||
err.contains("TOML") || err.contains("toml") || err.contains("格式"),
|
||||
"expected TOML validation error, got {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn extract_common_config_snippet(
|
||||
appType: String,
|
||||
|
||||
@@ -197,6 +197,9 @@ pub struct ProviderMeta {
|
||||
/// 自定义端点列表(按 URL 去重存储)
|
||||
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||
pub custom_endpoints: HashMap<String, crate::settings::CustomEndpoint>,
|
||||
/// 是否在写入 live 时应用通用配置片段
|
||||
#[serde(rename = "commonConfigEnabled", skip_serializing_if = "Option::is_none")]
|
||||
pub common_config_enabled: Option<bool>,
|
||||
/// 用量查询脚本配置
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub usage_script: Option<UsageScript>,
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use toml_edit::{DocumentMut, Item, TableLike};
|
||||
|
||||
use crate::app_config::AppType;
|
||||
use crate::codex_config::{get_codex_auth_path, get_codex_config_path};
|
||||
use crate::config::{delete_file, get_claude_settings_path, read_json_file, write_json_file};
|
||||
use crate::database::Database;
|
||||
use crate::error::AppError;
|
||||
use crate::provider::Provider;
|
||||
use crate::services::mcp::McpService;
|
||||
@@ -31,6 +33,525 @@ pub(crate) fn sanitize_claude_settings_for_live(settings: &Value) -> Value {
|
||||
v
|
||||
}
|
||||
|
||||
fn json_is_subset(target: &Value, source: &Value) -> bool {
|
||||
match source {
|
||||
Value::Object(source_map) => {
|
||||
let Some(target_map) = target.as_object() else {
|
||||
return false;
|
||||
};
|
||||
source_map
|
||||
.iter()
|
||||
.all(|(key, source_value)| target_map.get(key).is_some_and(|target_value| {
|
||||
json_is_subset(target_value, source_value)
|
||||
}))
|
||||
}
|
||||
Value::Array(source_arr) => {
|
||||
let Some(target_arr) = target.as_array() else {
|
||||
return false;
|
||||
};
|
||||
json_array_contains_subset(target_arr, source_arr)
|
||||
}
|
||||
_ => target == source,
|
||||
}
|
||||
}
|
||||
|
||||
fn json_array_contains_subset(target_arr: &[Value], source_arr: &[Value]) -> bool {
|
||||
let mut matched = vec![false; target_arr.len()];
|
||||
|
||||
source_arr.iter().all(|source_item| {
|
||||
if let Some((index, _)) = target_arr.iter().enumerate().find(|(index, target_item)| {
|
||||
!matched[*index] && json_is_subset(target_item, source_item)
|
||||
}) {
|
||||
matched[index] = true;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn json_remove_array_items(target_arr: &mut Vec<Value>, source_arr: &[Value]) {
|
||||
for source_item in source_arr {
|
||||
if let Some(index) = target_arr
|
||||
.iter()
|
||||
.position(|target_item| json_is_subset(target_item, source_item))
|
||||
{
|
||||
target_arr.remove(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn json_deep_merge(target: &mut Value, source: &Value) {
|
||||
match (target, source) {
|
||||
(Value::Object(target_map), Value::Object(source_map)) => {
|
||||
for (key, source_value) in source_map {
|
||||
match target_map.get_mut(key) {
|
||||
Some(target_value) => json_deep_merge(target_value, source_value),
|
||||
None => {
|
||||
target_map.insert(key.clone(), source_value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(target_value, source_value) => {
|
||||
*target_value = source_value.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn json_deep_remove(target: &mut Value, source: &Value) {
|
||||
let (Some(target_map), Some(source_map)) = (target.as_object_mut(), source.as_object()) else {
|
||||
return;
|
||||
};
|
||||
|
||||
for (key, source_value) in source_map {
|
||||
let mut remove_key = false;
|
||||
|
||||
if let Some(target_value) = target_map.get_mut(key) {
|
||||
if source_value.is_object() && target_value.is_object() {
|
||||
json_deep_remove(target_value, source_value);
|
||||
remove_key = target_value.as_object().is_some_and(|obj| obj.is_empty());
|
||||
} else if let (Some(target_arr), Some(source_arr)) =
|
||||
(target_value.as_array_mut(), source_value.as_array())
|
||||
{
|
||||
json_remove_array_items(target_arr, source_arr);
|
||||
remove_key = target_arr.is_empty();
|
||||
} else if json_is_subset(target_value, source_value) {
|
||||
remove_key = true;
|
||||
}
|
||||
}
|
||||
|
||||
if remove_key {
|
||||
target_map.remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn toml_value_is_subset(target: &toml_edit::Value, source: &toml_edit::Value) -> bool {
|
||||
match (target, source) {
|
||||
(toml_edit::Value::String(target), toml_edit::Value::String(source)) => {
|
||||
target.value() == source.value()
|
||||
}
|
||||
(toml_edit::Value::Integer(target), toml_edit::Value::Integer(source)) => {
|
||||
target.value() == source.value()
|
||||
}
|
||||
(toml_edit::Value::Float(target), toml_edit::Value::Float(source)) => {
|
||||
target.value() == source.value()
|
||||
}
|
||||
(toml_edit::Value::Boolean(target), toml_edit::Value::Boolean(source)) => {
|
||||
target.value() == source.value()
|
||||
}
|
||||
(toml_edit::Value::Datetime(target), toml_edit::Value::Datetime(source)) => {
|
||||
target.value() == source.value()
|
||||
}
|
||||
(toml_edit::Value::Array(target), toml_edit::Value::Array(source)) => {
|
||||
toml_array_contains_subset(target, source)
|
||||
}
|
||||
(toml_edit::Value::InlineTable(target), toml_edit::Value::InlineTable(source)) => {
|
||||
source.iter().all(|(key, source_item)| {
|
||||
target
|
||||
.get(key)
|
||||
.is_some_and(|target_item| toml_value_is_subset(target_item, source_item))
|
||||
})
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn toml_array_contains_subset(target: &toml_edit::Array, source: &toml_edit::Array) -> bool {
|
||||
let mut matched = vec![false; target.len()];
|
||||
let target_items: Vec<&toml_edit::Value> = target.iter().collect();
|
||||
|
||||
source.iter().all(|source_item| {
|
||||
if let Some((index, _)) = target_items.iter().enumerate().find(|(index, target_item)| {
|
||||
!matched[*index] && toml_value_is_subset(target_item, source_item)
|
||||
}) {
|
||||
matched[index] = true;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn toml_remove_array_items(target: &mut toml_edit::Array, source: &toml_edit::Array) {
|
||||
for source_item in source.iter() {
|
||||
let index = {
|
||||
let target_items: Vec<&toml_edit::Value> = target.iter().collect();
|
||||
target_items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(_, target_item)| toml_value_is_subset(target_item, source_item))
|
||||
.map(|(index, _)| index)
|
||||
};
|
||||
|
||||
if let Some(index) = index {
|
||||
target.remove(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn toml_item_is_subset(target: &Item, source: &Item) -> bool {
|
||||
if let Some(source_table) = source.as_table_like() {
|
||||
let Some(target_table) = target.as_table_like() else {
|
||||
return false;
|
||||
};
|
||||
return source_table.iter().all(|(key, source_item)| {
|
||||
target_table
|
||||
.get(key)
|
||||
.is_some_and(|target_item| toml_item_is_subset(target_item, source_item))
|
||||
});
|
||||
}
|
||||
|
||||
match (target.as_value(), source.as_value()) {
|
||||
(Some(target_value), Some(source_value)) => toml_value_is_subset(target_value, source_value),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_toml_item(target: &mut Item, source: &Item) {
|
||||
if let Some(source_table) = source.as_table_like() {
|
||||
if let Some(target_table) = target.as_table_like_mut() {
|
||||
merge_toml_table_like(target_table, source_table);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
*target = source.clone();
|
||||
}
|
||||
|
||||
fn merge_toml_table_like(target: &mut dyn TableLike, source: &dyn TableLike) {
|
||||
for (key, source_item) in source.iter() {
|
||||
match target.get_mut(key) {
|
||||
Some(target_item) => merge_toml_item(target_item, source_item),
|
||||
None => {
|
||||
target.insert(key, source_item.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_toml_item(target: &mut Item, source: &Item) {
|
||||
if let Some(source_table) = source.as_table_like() {
|
||||
if let Some(target_table) = target.as_table_like_mut() {
|
||||
remove_toml_table_like(target_table, source_table);
|
||||
if target_table.is_empty() {
|
||||
*target = Item::None;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(source_value) = source.as_value() {
|
||||
let mut remove_item = false;
|
||||
|
||||
if let Some(target_value) = target.as_value_mut() {
|
||||
match (target_value, source_value) {
|
||||
(toml_edit::Value::Array(target_arr), toml_edit::Value::Array(source_arr)) => {
|
||||
toml_remove_array_items(target_arr, source_arr);
|
||||
remove_item = target_arr.is_empty();
|
||||
}
|
||||
(target_value, source_value) if toml_value_is_subset(target_value, source_value) => {
|
||||
remove_item = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if remove_item {
|
||||
*target = Item::None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_toml_table_like(target: &mut dyn TableLike, source: &dyn TableLike) {
|
||||
let keys: Vec<String> = source.iter().map(|(key, _)| key.to_string()).collect();
|
||||
|
||||
for key in keys {
|
||||
let mut remove_key = false;
|
||||
if let (Some(target_item), Some(source_item)) = (target.get_mut(&key), source.get(&key)) {
|
||||
remove_toml_item(target_item, source_item);
|
||||
remove_key = target_item.is_none()
|
||||
|| target_item
|
||||
.as_table_like()
|
||||
.is_some_and(|table_like| table_like.is_empty());
|
||||
}
|
||||
|
||||
if remove_key {
|
||||
target.remove(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn settings_contain_common_config(app_type: &AppType, settings: &Value, snippet: &str) -> bool {
|
||||
let trimmed = snippet.trim();
|
||||
if trimmed.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
match app_type {
|
||||
AppType::Claude => match serde_json::from_str::<Value>(trimmed) {
|
||||
Ok(source) if source.is_object() => json_is_subset(settings, &source),
|
||||
_ => false,
|
||||
},
|
||||
AppType::Codex => {
|
||||
let config_toml = settings.get("config").and_then(Value::as_str).unwrap_or("");
|
||||
if config_toml.trim().is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let target_doc = match config_toml.parse::<DocumentMut>() {
|
||||
Ok(doc) => doc,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let source_doc = match trimmed.parse::<DocumentMut>() {
|
||||
Ok(doc) => doc,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
toml_item_is_subset(target_doc.as_item(), source_doc.as_item())
|
||||
}
|
||||
AppType::Gemini => match serde_json::from_str::<Value>(trimmed) {
|
||||
Ok(Value::Object(source_map)) => {
|
||||
let Some(target_map) = settings.get("env").and_then(Value::as_object) else {
|
||||
return false;
|
||||
};
|
||||
source_map.iter().all(|(key, source_value)| {
|
||||
target_map
|
||||
.get(key)
|
||||
.is_some_and(|target_value| json_is_subset(target_value, source_value))
|
||||
})
|
||||
}
|
||||
_ => false,
|
||||
},
|
||||
AppType::OpenCode | AppType::OpenClaw => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn provider_uses_common_config(
|
||||
app_type: &AppType,
|
||||
provider: &Provider,
|
||||
snippet: Option<&str>,
|
||||
) -> bool {
|
||||
match provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.common_config_enabled)
|
||||
{
|
||||
Some(explicit) => explicit && snippet.is_some_and(|value| !value.trim().is_empty()),
|
||||
None => snippet.is_some_and(|value| {
|
||||
settings_contain_common_config(app_type, &provider.settings_config, value)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn remove_common_config_from_settings(
|
||||
app_type: &AppType,
|
||||
settings: &Value,
|
||||
snippet: &str,
|
||||
) -> Result<Value, AppError> {
|
||||
let trimmed = snippet.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(settings.clone());
|
||||
}
|
||||
|
||||
match app_type {
|
||||
AppType::Claude => {
|
||||
let source = serde_json::from_str::<Value>(trimmed)
|
||||
.map_err(|e| AppError::Message(format!("Invalid Claude common config: {e}")))?;
|
||||
let mut result = settings.clone();
|
||||
json_deep_remove(&mut result, &source);
|
||||
Ok(result)
|
||||
}
|
||||
AppType::Codex => {
|
||||
let mut result = settings.clone();
|
||||
let config_toml = settings.get("config").and_then(Value::as_str).unwrap_or("");
|
||||
let mut target_doc = if config_toml.trim().is_empty() {
|
||||
DocumentMut::new()
|
||||
} else {
|
||||
config_toml.parse::<DocumentMut>().map_err(|e| {
|
||||
AppError::Message(format!("Invalid Codex config.toml while removing common config: {e}"))
|
||||
})?
|
||||
};
|
||||
let source_doc = trimmed.parse::<DocumentMut>().map_err(|e| {
|
||||
AppError::Message(format!("Invalid Codex common config snippet: {e}"))
|
||||
})?;
|
||||
|
||||
remove_toml_table_like(target_doc.as_table_mut(), source_doc.as_table());
|
||||
if let Some(obj) = result.as_object_mut() {
|
||||
obj.insert("config".to_string(), Value::String(target_doc.to_string()));
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
AppType::Gemini => {
|
||||
let source = serde_json::from_str::<Value>(trimmed)
|
||||
.map_err(|e| AppError::Message(format!("Invalid Gemini common config: {e}")))?;
|
||||
let mut result = settings.clone();
|
||||
if let Some(env) = result.get_mut("env") {
|
||||
json_deep_remove(env, &source);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
AppType::OpenCode | AppType::OpenClaw => Ok(settings.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_common_config_to_settings(
|
||||
app_type: &AppType,
|
||||
settings: &Value,
|
||||
snippet: &str,
|
||||
) -> Result<Value, AppError> {
|
||||
let trimmed = snippet.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(settings.clone());
|
||||
}
|
||||
|
||||
match app_type {
|
||||
AppType::Claude => {
|
||||
let source = serde_json::from_str::<Value>(trimmed)
|
||||
.map_err(|e| AppError::Message(format!("Invalid Claude common config: {e}")))?;
|
||||
let mut result = settings.clone();
|
||||
json_deep_merge(&mut result, &source);
|
||||
Ok(result)
|
||||
}
|
||||
AppType::Codex => {
|
||||
let mut result = settings.clone();
|
||||
let config_toml = settings.get("config").and_then(Value::as_str).unwrap_or("");
|
||||
let mut target_doc = if config_toml.trim().is_empty() {
|
||||
DocumentMut::new()
|
||||
} else {
|
||||
config_toml.parse::<DocumentMut>().map_err(|e| {
|
||||
AppError::Message(format!("Invalid Codex config.toml while applying common config: {e}"))
|
||||
})?
|
||||
};
|
||||
let source_doc = trimmed.parse::<DocumentMut>().map_err(|e| {
|
||||
AppError::Message(format!("Invalid Codex common config snippet: {e}"))
|
||||
})?;
|
||||
|
||||
merge_toml_table_like(target_doc.as_table_mut(), source_doc.as_table());
|
||||
if let Some(obj) = result.as_object_mut() {
|
||||
obj.insert("config".to_string(), Value::String(target_doc.to_string()));
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
AppType::Gemini => {
|
||||
let source = serde_json::from_str::<Value>(trimmed)
|
||||
.map_err(|e| AppError::Message(format!("Invalid Gemini common config: {e}")))?;
|
||||
let mut result = settings.clone();
|
||||
if let Some(env) = result.get_mut("env") {
|
||||
json_deep_merge(env, &source);
|
||||
} else if let Some(obj) = result.as_object_mut() {
|
||||
obj.insert("env".to_string(), source);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
AppType::OpenCode | AppType::OpenClaw => Ok(settings.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn write_live_with_common_config(
|
||||
db: &Database,
|
||||
app_type: &AppType,
|
||||
provider: &Provider,
|
||||
) -> Result<(), AppError> {
|
||||
let snippet = db.get_config_snippet(app_type.as_str())?;
|
||||
let mut effective_provider = provider.clone();
|
||||
|
||||
if provider_uses_common_config(app_type, provider, snippet.as_deref()) {
|
||||
if let Some(snippet_text) = snippet.as_deref() {
|
||||
match apply_common_config_to_settings(app_type, &provider.settings_config, snippet_text)
|
||||
{
|
||||
Ok(settings) => effective_provider.settings_config = settings,
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"Failed to apply common config for {} provider '{}': {err}",
|
||||
app_type.as_str(),
|
||||
provider.id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
write_live_snapshot(app_type, &effective_provider)
|
||||
}
|
||||
|
||||
pub(crate) fn strip_common_config_from_live_settings(
|
||||
db: &Database,
|
||||
app_type: &AppType,
|
||||
provider: &Provider,
|
||||
live_settings: Value,
|
||||
) -> Value {
|
||||
let snippet = match db.get_config_snippet(app_type.as_str()) {
|
||||
Ok(snippet) => snippet,
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"Failed to load common config for {} while backfilling '{}': {err}",
|
||||
app_type.as_str(),
|
||||
provider.id
|
||||
);
|
||||
return live_settings;
|
||||
}
|
||||
};
|
||||
|
||||
if !provider_uses_common_config(app_type, provider, snippet.as_deref()) {
|
||||
return live_settings;
|
||||
}
|
||||
|
||||
let Some(snippet_text) = snippet.as_deref() else {
|
||||
return live_settings;
|
||||
};
|
||||
|
||||
match remove_common_config_from_settings(app_type, &live_settings, snippet_text) {
|
||||
Ok(settings) => settings,
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"Failed to strip common config for {} provider '{}': {err}",
|
||||
app_type.as_str(),
|
||||
provider.id
|
||||
);
|
||||
live_settings
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_provider_common_config_for_storage(
|
||||
db: &Database,
|
||||
app_type: &AppType,
|
||||
provider: &mut Provider,
|
||||
) -> Result<(), AppError> {
|
||||
let uses_common_config = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.common_config_enabled)
|
||||
.unwrap_or(false);
|
||||
|
||||
if !uses_common_config {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(snippet) = db.get_config_snippet(app_type.as_str())? else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if snippet.trim().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match remove_common_config_from_settings(app_type, &provider.settings_config, &snippet) {
|
||||
Ok(settings) => provider.settings_config = settings,
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"Failed to normalize common config before saving {} provider '{}': {err}",
|
||||
app_type.as_str(),
|
||||
provider.id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Live configuration snapshot for backup/restore
|
||||
#[derive(Clone)]
|
||||
#[allow(dead_code)]
|
||||
@@ -245,7 +766,7 @@ fn sync_all_providers_to_live(state: &AppState, app_type: &AppType) -> Result<()
|
||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||
|
||||
for provider in providers.values() {
|
||||
if let Err(e) = write_live_snapshot(app_type, provider) {
|
||||
if let Err(e) = write_live_with_common_config(state.db.as_ref(), app_type, provider) {
|
||||
log::warn!(
|
||||
"Failed to sync {:?} provider '{}' to live: {e}",
|
||||
app_type,
|
||||
@@ -263,6 +784,29 @@ fn sync_all_providers_to_live(state: &AppState, app_type: &AppType) -> Result<()
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn sync_current_provider_for_app_to_live(
|
||||
state: &AppState,
|
||||
app_type: &AppType,
|
||||
) -> Result<(), AppError> {
|
||||
if app_type.is_additive_mode() {
|
||||
sync_all_providers_to_live(state, app_type)?;
|
||||
} else {
|
||||
let current_id = match crate::settings::get_effective_current_provider(&state.db, app_type)? {
|
||||
Some(id) => id,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||
if let Some(provider) = providers.get(¤t_id) {
|
||||
write_live_with_common_config(state.db.as_ref(), app_type, provider)?;
|
||||
}
|
||||
}
|
||||
|
||||
McpService::sync_all_enabled(state)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sync current provider to live configuration
|
||||
///
|
||||
/// 使用有效的当前供应商 ID(验证过存在性)。
|
||||
@@ -286,7 +830,7 @@ pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
|
||||
|
||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||
if let Some(provider) = providers.get(¤t_id) {
|
||||
write_live_snapshot(&app_type, provider)?;
|
||||
write_live_with_common_config(state.db.as_ref(), &app_type, provider)?;
|
||||
}
|
||||
// Note: get_effective_current_provider already validates existence,
|
||||
// so providers.get() should always succeed here
|
||||
@@ -742,3 +1286,133 @@ pub fn remove_openclaw_provider_from_live(provider_id: &str) -> Result<(), AppEr
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn claude_common_config_apply_and_remove_roundtrip_for_non_overlapping_fields() {
|
||||
let settings = json!({
|
||||
"env": {
|
||||
"ANTHROPIC_API_KEY": "sk-test"
|
||||
}
|
||||
});
|
||||
let snippet = r#"{
|
||||
"includeCoAuthoredBy": false,
|
||||
"env": {
|
||||
"CLAUDE_CODE_USE_BEDROCK": "1"
|
||||
}
|
||||
}"#;
|
||||
|
||||
let applied =
|
||||
apply_common_config_to_settings(&AppType::Claude, &settings, snippet).unwrap();
|
||||
assert_eq!(applied["includeCoAuthoredBy"], json!(false));
|
||||
assert_eq!(applied["env"]["CLAUDE_CODE_USE_BEDROCK"], json!("1"));
|
||||
|
||||
let stripped =
|
||||
remove_common_config_from_settings(&AppType::Claude, &applied, snippet).unwrap();
|
||||
assert_eq!(stripped, settings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_common_config_apply_and_remove_roundtrip_for_non_overlapping_fields() {
|
||||
let settings = json!({
|
||||
"auth": {
|
||||
"OPENAI_API_KEY": "sk-test"
|
||||
},
|
||||
"config": "model_provider = \"openai\"\n[general]\nmodel = \"gpt-5\"\n"
|
||||
});
|
||||
let snippet = "[shared]\nreasoning = \"medium\"\n";
|
||||
|
||||
let applied =
|
||||
apply_common_config_to_settings(&AppType::Codex, &settings, snippet).unwrap();
|
||||
let applied_config = applied["config"].as_str().unwrap_or_default();
|
||||
assert!(applied_config.contains("[shared]"));
|
||||
assert!(applied_config.contains("reasoning = \"medium\""));
|
||||
|
||||
let stripped =
|
||||
remove_common_config_from_settings(&AppType::Codex, &applied, snippet).unwrap();
|
||||
assert_eq!(stripped, settings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_common_config_flag_overrides_legacy_subset_detection() {
|
||||
let mut provider = Provider::with_id(
|
||||
"claude-test".to_string(),
|
||||
"Claude Test".to_string(),
|
||||
json!({
|
||||
"includeCoAuthoredBy": false
|
||||
}),
|
||||
None,
|
||||
);
|
||||
provider.meta = Some(crate::provider::ProviderMeta {
|
||||
common_config_enabled: Some(false),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
assert!(
|
||||
!provider_uses_common_config(
|
||||
&AppType::Claude,
|
||||
&provider,
|
||||
Some(r#"{ "includeCoAuthoredBy": false }"#),
|
||||
),
|
||||
"explicit false should win over legacy subset detection"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_common_config_array_subset_detection_and_strip_preserve_extra_items() {
|
||||
let settings = json!({
|
||||
"allowedTools": ["tool1", "tool2"]
|
||||
});
|
||||
let snippet = r#"{
|
||||
"allowedTools": ["tool1"]
|
||||
}"#;
|
||||
|
||||
assert!(
|
||||
settings_contain_common_config(&AppType::Claude, &settings, snippet),
|
||||
"array subset should be detected for legacy providers"
|
||||
);
|
||||
|
||||
let stripped =
|
||||
remove_common_config_from_settings(&AppType::Claude, &settings, snippet).unwrap();
|
||||
assert_eq!(
|
||||
stripped,
|
||||
json!({
|
||||
"allowedTools": ["tool2"]
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_common_config_array_subset_detection_and_strip_preserve_extra_items() {
|
||||
let settings = json!({
|
||||
"auth": {},
|
||||
"config": "allowed_tools = [\"tool1\", \"tool2\"]\n"
|
||||
});
|
||||
let snippet = "allowed_tools = [\"tool1\"]\n";
|
||||
|
||||
assert!(
|
||||
settings_contain_common_config(&AppType::Codex, &settings, snippet),
|
||||
"TOML array subset should be detected for legacy providers"
|
||||
);
|
||||
|
||||
let stripped =
|
||||
remove_common_config_from_settings(&AppType::Codex, &settings, snippet).unwrap();
|
||||
assert_eq!(stripped["auth"], json!({}));
|
||||
let stripped_config = stripped["config"].as_str().unwrap_or_default();
|
||||
let parsed = stripped_config
|
||||
.parse::<DocumentMut>()
|
||||
.expect("stripped codex config should remain valid TOML");
|
||||
let allowed_tools = parsed["allowed_tools"]
|
||||
.as_array()
|
||||
.expect("allowed_tools should remain an array");
|
||||
let values: Vec<&str> = allowed_tools
|
||||
.iter()
|
||||
.map(|value| value.as_str().expect("tool id should be string"))
|
||||
.collect();
|
||||
assert_eq!(values, vec!["tool2"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,10 @@ pub use live::{
|
||||
|
||||
// Internal re-exports (pub(crate))
|
||||
pub(crate) use live::sanitize_claude_settings_for_live;
|
||||
pub(crate) use live::write_live_snapshot;
|
||||
pub(crate) use live::{
|
||||
normalize_provider_common_config_for_storage, strip_common_config_from_live_settings,
|
||||
sync_current_provider_for_app_to_live, write_live_with_common_config,
|
||||
};
|
||||
|
||||
// Internal re-exports
|
||||
use live::{
|
||||
@@ -167,6 +170,7 @@ impl ProviderService {
|
||||
// Normalize Claude model keys
|
||||
Self::normalize_provider_if_claude(&app_type, &mut provider);
|
||||
Self::validate_provider_settings(&app_type, &provider)?;
|
||||
normalize_provider_common_config_for_storage(state.db.as_ref(), &app_type, &mut provider)?;
|
||||
|
||||
// Save to database
|
||||
state.db.save_provider(app_type.as_str(), &provider)?;
|
||||
@@ -181,7 +185,7 @@ impl ProviderService {
|
||||
// Users must explicitly switch/apply an OMO provider to activate it.
|
||||
return Ok(true);
|
||||
}
|
||||
write_live_snapshot(&app_type, &provider)?;
|
||||
write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?;
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
@@ -192,7 +196,7 @@ impl ProviderService {
|
||||
state
|
||||
.db
|
||||
.set_current_provider(app_type.as_str(), &provider.id)?;
|
||||
write_live_snapshot(&app_type, &provider)?;
|
||||
write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?;
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
@@ -208,6 +212,7 @@ impl ProviderService {
|
||||
// Normalize Claude model keys
|
||||
Self::normalize_provider_if_claude(&app_type, &mut provider);
|
||||
Self::validate_provider_settings(&app_type, &provider)?;
|
||||
normalize_provider_common_config_for_storage(state.db.as_ref(), &app_type, &mut provider)?;
|
||||
|
||||
// Save to database
|
||||
state.db.save_provider(app_type.as_str(), &provider)?;
|
||||
@@ -244,7 +249,7 @@ impl ProviderService {
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
write_live_snapshot(&app_type, &provider)?;
|
||||
write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?;
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
@@ -273,7 +278,7 @@ impl ProviderService {
|
||||
)
|
||||
.map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?;
|
||||
} else {
|
||||
write_live_snapshot(&app_type, &provider)?;
|
||||
write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?;
|
||||
// Sync MCP
|
||||
McpService::sync_all_enabled(state)?;
|
||||
}
|
||||
@@ -560,7 +565,12 @@ impl ProviderService {
|
||||
// Only backfill when switching to a different provider
|
||||
if let Ok(live_config) = read_live_settings(app_type.clone()) {
|
||||
if let Some(mut current_provider) = providers.get(¤t_id).cloned() {
|
||||
current_provider.settings_config = live_config;
|
||||
current_provider.settings_config = strip_common_config_from_live_settings(
|
||||
state.db.as_ref(),
|
||||
&app_type,
|
||||
¤t_provider,
|
||||
live_config,
|
||||
);
|
||||
if let Err(e) =
|
||||
state.db.save_provider(app_type.as_str(), ¤t_provider)
|
||||
{
|
||||
@@ -585,7 +595,7 @@ impl ProviderService {
|
||||
}
|
||||
|
||||
// Sync to live (write_gemini_live handles security flag internally for Gemini)
|
||||
write_live_snapshot(&app_type, provider)?;
|
||||
write_live_with_common_config(state.db.as_ref(), &app_type, provider)?;
|
||||
|
||||
// Sync MCP
|
||||
McpService::sync_all_enabled(state)?;
|
||||
@@ -598,6 +608,67 @@ impl ProviderService {
|
||||
sync_current_to_live(state)
|
||||
}
|
||||
|
||||
pub fn sync_current_provider_for_app(
|
||||
state: &AppState,
|
||||
app_type: AppType,
|
||||
) -> Result<(), AppError> {
|
||||
sync_current_provider_for_app_to_live(state, &app_type)
|
||||
}
|
||||
|
||||
pub fn migrate_legacy_common_config_usage(
|
||||
state: &AppState,
|
||||
app_type: AppType,
|
||||
legacy_snippet: &str,
|
||||
) -> Result<(), AppError> {
|
||||
if app_type.is_additive_mode() || legacy_snippet.trim().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||
|
||||
for provider in providers.values() {
|
||||
if provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.common_config_enabled)
|
||||
.is_some()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if !live::provider_uses_common_config(&app_type, provider, Some(legacy_snippet)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut updated_provider = provider.clone();
|
||||
updated_provider
|
||||
.meta
|
||||
.get_or_insert_with(Default::default)
|
||||
.common_config_enabled = Some(true);
|
||||
|
||||
match live::remove_common_config_from_settings(
|
||||
&app_type,
|
||||
&updated_provider.settings_config,
|
||||
legacy_snippet,
|
||||
) {
|
||||
Ok(settings) => updated_provider.settings_config = settings,
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"Failed to normalize legacy common config for {} provider '{}': {err}",
|
||||
app_type.as_str(),
|
||||
updated_provider.id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
state
|
||||
.db
|
||||
.save_provider(app_type.as_str(), &updated_provider)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Extract common config snippet from current provider
|
||||
///
|
||||
/// Extracts the current provider's configuration and removes provider-specific fields
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::database::Database;
|
||||
use crate::provider::Provider;
|
||||
use crate::proxy::server::ProxyServer;
|
||||
use crate::proxy::types::*;
|
||||
use crate::services::provider::write_live_snapshot;
|
||||
use crate::services::provider::write_live_with_common_config;
|
||||
use serde_json::{json, Value};
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
@@ -1266,7 +1266,7 @@ impl ProxyService {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
write_live_snapshot(app_type, provider)
|
||||
write_live_with_common_config(self.db.as_ref(), app_type, provider)
|
||||
.map_err(|e| format!("写入 {app_type:?} Live 配置失败: {e}"))?;
|
||||
|
||||
Ok(true)
|
||||
|
||||
@@ -237,13 +237,20 @@ export function ProviderForm({
|
||||
mode: "onSubmit",
|
||||
});
|
||||
|
||||
const handleSettingsConfigChange = useCallback(
|
||||
(config: string) => {
|
||||
form.setValue("settingsConfig", config);
|
||||
},
|
||||
[form],
|
||||
);
|
||||
|
||||
const {
|
||||
apiKey,
|
||||
handleApiKeyChange,
|
||||
showApiKey: shouldShowApiKey,
|
||||
} = useApiKeyState({
|
||||
initialConfig: form.getValues("settingsConfig"),
|
||||
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
||||
onConfigChange: handleSettingsConfigChange,
|
||||
selectedPresetId,
|
||||
category,
|
||||
appType: appId,
|
||||
@@ -254,7 +261,7 @@ export function ProviderForm({
|
||||
category,
|
||||
settingsConfig: form.getValues("settingsConfig"),
|
||||
codexConfig: "",
|
||||
onSettingsConfigChange: (config) => form.setValue("settingsConfig", config),
|
||||
onSettingsConfigChange: handleSettingsConfigChange,
|
||||
onCodexConfigChange: () => {},
|
||||
});
|
||||
|
||||
@@ -267,7 +274,7 @@ export function ProviderForm({
|
||||
handleModelChange,
|
||||
} = useModelState({
|
||||
settingsConfig: form.getValues("settingsConfig"),
|
||||
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
||||
onConfigChange: handleSettingsConfigChange,
|
||||
});
|
||||
|
||||
const [localApiFormat, setLocalApiFormat] = useState<ClaudeApiFormat>(() => {
|
||||
@@ -373,7 +380,7 @@ export function ProviderForm({
|
||||
selectedPresetId: appId === "claude" ? selectedPresetId : null,
|
||||
presetEntries: appId === "claude" ? presetEntries : [],
|
||||
settingsConfig: form.getValues("settingsConfig"),
|
||||
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
||||
onConfigChange: handleSettingsConfigChange,
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -386,8 +393,10 @@ export function ProviderForm({
|
||||
handleExtract: handleClaudeExtract,
|
||||
} = useCommonConfigSnippet({
|
||||
settingsConfig: form.getValues("settingsConfig"),
|
||||
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
||||
onConfigChange: handleSettingsConfigChange,
|
||||
initialData: appId === "claude" ? initialData : undefined,
|
||||
initialEnabled:
|
||||
appId === "claude" ? initialData?.meta?.commonConfigEnabled : undefined,
|
||||
selectedPresetId: selectedPresetId ?? undefined,
|
||||
enabled: appId === "claude",
|
||||
});
|
||||
@@ -404,6 +413,8 @@ export function ProviderForm({
|
||||
codexConfig,
|
||||
onConfigChange: handleCodexConfigChange,
|
||||
initialData: appId === "codex" ? initialData : undefined,
|
||||
initialEnabled:
|
||||
appId === "codex" ? initialData?.meta?.commonConfigEnabled : undefined,
|
||||
selectedPresetId: selectedPresetId ?? undefined,
|
||||
});
|
||||
|
||||
@@ -487,6 +498,8 @@ export function ProviderForm({
|
||||
envStringToObj,
|
||||
envObjToString,
|
||||
initialData: appId === "gemini" ? initialData : undefined,
|
||||
initialEnabled:
|
||||
appId === "gemini" ? initialData?.meta?.commonConfigEnabled : undefined,
|
||||
selectedPresetId: selectedPresetId ?? undefined,
|
||||
});
|
||||
|
||||
@@ -809,6 +822,14 @@ export function ProviderForm({
|
||||
payload.meta ?? (initialData?.meta ? { ...initialData.meta } : undefined);
|
||||
payload.meta = {
|
||||
...(baseMeta ?? {}),
|
||||
commonConfigEnabled:
|
||||
appId === "claude"
|
||||
? useCommonConfig
|
||||
: appId === "codex"
|
||||
? useCodexCommonConfigFlag
|
||||
: appId === "gemini"
|
||||
? useGeminiCommonConfigFlag
|
||||
: undefined,
|
||||
endpointAutoSelect,
|
||||
testConfig: testConfig.enabled ? testConfig : undefined,
|
||||
proxyConfig: proxyConfig.enabled ? proxyConfig : undefined,
|
||||
|
||||
@@ -16,6 +16,7 @@ interface UseCodexCommonConfigProps {
|
||||
initialData?: {
|
||||
settingsConfig?: Record<string, unknown>;
|
||||
};
|
||||
initialEnabled?: boolean;
|
||||
selectedPresetId?: string;
|
||||
}
|
||||
|
||||
@@ -27,6 +28,7 @@ export function useCodexCommonConfig({
|
||||
codexConfig,
|
||||
onConfigChange,
|
||||
initialData,
|
||||
initialEnabled,
|
||||
selectedPresetId,
|
||||
}: UseCodexCommonConfigProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -42,11 +44,14 @@ export function useCodexCommonConfig({
|
||||
const isUpdatingFromCommonConfig = useRef(false);
|
||||
// 用于跟踪新建模式是否已初始化默认勾选
|
||||
const hasInitializedNewMode = useRef(false);
|
||||
// 用于跟踪编辑模式是否已初始化显式开关/预览
|
||||
const hasInitializedEditMode = useRef(false);
|
||||
|
||||
// 当预设变化时,重置初始化标记,使新预设能够重新触发初始化逻辑
|
||||
useEffect(() => {
|
||||
hasInitializedNewMode.current = false;
|
||||
}, [selectedPresetId]);
|
||||
hasInitializedEditMode.current = false;
|
||||
}, [selectedPresetId, initialEnabled]);
|
||||
|
||||
// 初始化:从 config.json 加载,支持从 localStorage 迁移
|
||||
useEffect(() => {
|
||||
@@ -107,10 +112,43 @@ export function useCodexCommonConfig({
|
||||
typeof initialData.settingsConfig.config === "string"
|
||||
? initialData.settingsConfig.config
|
||||
: "";
|
||||
const hasCommon = hasTomlCommonConfigSnippet(config, commonConfigSnippet);
|
||||
const inferredHasCommon = hasTomlCommonConfigSnippet(
|
||||
config,
|
||||
commonConfigSnippet,
|
||||
);
|
||||
const hasCommon = initialEnabled ?? inferredHasCommon;
|
||||
setUseCommonConfig(hasCommon);
|
||||
|
||||
if (
|
||||
hasCommon &&
|
||||
!inferredHasCommon &&
|
||||
!hasInitializedEditMode.current
|
||||
) {
|
||||
hasInitializedEditMode.current = true;
|
||||
const { updatedConfig, error } = updateTomlCommonConfigSnippet(
|
||||
codexConfig,
|
||||
commonConfigSnippet,
|
||||
true,
|
||||
);
|
||||
if (!error) {
|
||||
isUpdatingFromCommonConfig.current = true;
|
||||
onConfigChange(updatedConfig);
|
||||
setTimeout(() => {
|
||||
isUpdatingFromCommonConfig.current = false;
|
||||
}, 0);
|
||||
}
|
||||
} else {
|
||||
hasInitializedEditMode.current = true;
|
||||
}
|
||||
}
|
||||
}, [initialData, commonConfigSnippet, isLoading]);
|
||||
}, [
|
||||
codexConfig,
|
||||
commonConfigSnippet,
|
||||
initialData,
|
||||
initialEnabled,
|
||||
isLoading,
|
||||
onConfigChange,
|
||||
]);
|
||||
|
||||
// 新建模式:如果通用配置片段存在且有效,默认启用
|
||||
useEffect(() => {
|
||||
|
||||
@@ -18,6 +18,7 @@ interface UseCommonConfigSnippetProps {
|
||||
initialData?: {
|
||||
settingsConfig?: Record<string, unknown>;
|
||||
};
|
||||
initialEnabled?: boolean;
|
||||
selectedPresetId?: string;
|
||||
/** When false, the hook skips all logic and returns disabled state. Default: true */
|
||||
enabled?: boolean;
|
||||
@@ -31,6 +32,7 @@ export function useCommonConfigSnippet({
|
||||
settingsConfig,
|
||||
onConfigChange,
|
||||
initialData,
|
||||
initialEnabled,
|
||||
selectedPresetId,
|
||||
enabled = true,
|
||||
}: UseCommonConfigSnippetProps) {
|
||||
@@ -47,12 +49,15 @@ export function useCommonConfigSnippet({
|
||||
const isUpdatingFromCommonConfig = useRef(false);
|
||||
// 用于跟踪新建模式是否已初始化默认勾选
|
||||
const hasInitializedNewMode = useRef(false);
|
||||
// 用于跟踪编辑模式是否已初始化显式开关/预览
|
||||
const hasInitializedEditMode = useRef(false);
|
||||
|
||||
// 当预设变化时,重置初始化标记,使新预设能够重新触发初始化逻辑
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
hasInitializedNewMode.current = false;
|
||||
}, [selectedPresetId, enabled]);
|
||||
hasInitializedEditMode.current = false;
|
||||
}, [selectedPresetId, enabled, initialEnabled]);
|
||||
|
||||
// 初始化:从 config.json 加载,支持从 localStorage 迁移
|
||||
useEffect(() => {
|
||||
@@ -115,13 +120,44 @@ export function useCommonConfigSnippet({
|
||||
if (!enabled) return;
|
||||
if (initialData && !isLoading) {
|
||||
const configString = JSON.stringify(initialData.settingsConfig, null, 2);
|
||||
const hasCommon = hasCommonConfigSnippet(
|
||||
const inferredHasCommon = hasCommonConfigSnippet(
|
||||
configString,
|
||||
commonConfigSnippet,
|
||||
);
|
||||
const hasCommon = initialEnabled ?? inferredHasCommon;
|
||||
setUseCommonConfig(hasCommon);
|
||||
|
||||
if (
|
||||
hasCommon &&
|
||||
!inferredHasCommon &&
|
||||
!hasInitializedEditMode.current
|
||||
) {
|
||||
hasInitializedEditMode.current = true;
|
||||
const { updatedConfig, error } = updateCommonConfigSnippet(
|
||||
settingsConfig,
|
||||
commonConfigSnippet,
|
||||
true,
|
||||
);
|
||||
if (!error) {
|
||||
isUpdatingFromCommonConfig.current = true;
|
||||
onConfigChange(updatedConfig);
|
||||
setTimeout(() => {
|
||||
isUpdatingFromCommonConfig.current = false;
|
||||
}, 0);
|
||||
}
|
||||
} else {
|
||||
hasInitializedEditMode.current = true;
|
||||
}
|
||||
}
|
||||
}, [enabled, initialData, commonConfigSnippet, isLoading]);
|
||||
}, [
|
||||
enabled,
|
||||
initialData,
|
||||
initialEnabled,
|
||||
commonConfigSnippet,
|
||||
isLoading,
|
||||
onConfigChange,
|
||||
settingsConfig,
|
||||
]);
|
||||
|
||||
// 新建模式:如果通用配置片段存在且有效,默认启用
|
||||
useEffect(() => {
|
||||
|
||||
@@ -19,6 +19,7 @@ interface UseGeminiCommonConfigProps {
|
||||
initialData?: {
|
||||
settingsConfig?: Record<string, unknown>;
|
||||
};
|
||||
initialEnabled?: boolean;
|
||||
selectedPresetId?: string;
|
||||
}
|
||||
|
||||
@@ -43,6 +44,7 @@ export function useGeminiCommonConfig({
|
||||
envStringToObj,
|
||||
envObjToString,
|
||||
initialData,
|
||||
initialEnabled,
|
||||
selectedPresetId,
|
||||
}: UseGeminiCommonConfigProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -58,11 +60,14 @@ export function useGeminiCommonConfig({
|
||||
const isUpdatingFromCommonConfig = useRef(false);
|
||||
// 用于跟踪新建模式是否已初始化默认勾选
|
||||
const hasInitializedNewMode = useRef(false);
|
||||
// 用于跟踪编辑模式是否已初始化显式开关/预览
|
||||
const hasInitializedEditMode = useRef(false);
|
||||
|
||||
// 当预设变化时,重置初始化标记,使新预设能够重新触发初始化逻辑
|
||||
useEffect(() => {
|
||||
hasInitializedNewMode.current = false;
|
||||
}, [selectedPresetId]);
|
||||
hasInitializedEditMode.current = false;
|
||||
}, [selectedPresetId, initialEnabled]);
|
||||
|
||||
const parseSnippetEnv = useCallback(
|
||||
(
|
||||
@@ -220,20 +225,46 @@ export function useGeminiCommonConfig({
|
||||
: {};
|
||||
const parsed = parseSnippetEnv(commonConfigSnippet);
|
||||
if (parsed.error) return;
|
||||
const hasCommon = hasEnvCommonConfigSnippet(
|
||||
const inferredHasCommon = hasEnvCommonConfigSnippet(
|
||||
env,
|
||||
parsed.env as Record<string, string>,
|
||||
);
|
||||
const hasCommon = initialEnabled ?? inferredHasCommon;
|
||||
setUseCommonConfig(hasCommon);
|
||||
|
||||
if (
|
||||
hasCommon &&
|
||||
!inferredHasCommon &&
|
||||
!hasInitializedEditMode.current
|
||||
) {
|
||||
hasInitializedEditMode.current = true;
|
||||
const currentEnv = envStringToObj(envValue);
|
||||
const merged = applySnippetToEnv(currentEnv, parsed.env);
|
||||
const nextEnvString = envObjToString(merged);
|
||||
|
||||
isUpdatingFromCommonConfig.current = true;
|
||||
onEnvChange(nextEnvString);
|
||||
setTimeout(() => {
|
||||
isUpdatingFromCommonConfig.current = false;
|
||||
}, 0);
|
||||
} else {
|
||||
hasInitializedEditMode.current = true;
|
||||
}
|
||||
} catch {
|
||||
// ignore parse error
|
||||
}
|
||||
}
|
||||
}, [
|
||||
applySnippetToEnv,
|
||||
commonConfigSnippet,
|
||||
envObjToString,
|
||||
envStringToObj,
|
||||
envValue,
|
||||
hasEnvCommonConfigSnippet,
|
||||
initialData,
|
||||
initialEnabled,
|
||||
isLoading,
|
||||
onEnvChange,
|
||||
parseSnippetEnv,
|
||||
]);
|
||||
|
||||
|
||||
@@ -126,6 +126,8 @@ export interface ProviderProxyConfig {
|
||||
export interface ProviderMeta {
|
||||
// 自定义端点:以 URL 为键,值为端点信息
|
||||
custom_endpoints?: Record<string, CustomEndpoint>;
|
||||
// 是否在切换/同步到 live 时应用通用配置片段
|
||||
commonConfigEnabled?: boolean;
|
||||
// 用量查询脚本配置
|
||||
usage_script?: UsageScript;
|
||||
// 请求地址管理:测速后自动选择最佳端点
|
||||
|
||||
Reference in New Issue
Block a user