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:
Jason
2026-03-08 21:45:20 +08:00
parent fc6f2af4c6
commit 6d078e7f33
10 changed files with 982 additions and 34 deletions
+82 -10
View File
@@ -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,
+3
View File
@@ -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>,
+676 -2
View File
@@ -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(&current_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(&current_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"]);
}
}
+78 -7
View File
@@ -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(&current_id).cloned() {
current_provider.settings_config = live_config;
current_provider.settings_config = strip_common_config_from_live_settings(
state.db.as_ref(),
&app_type,
&current_provider,
live_config,
);
if let Err(e) =
state.db.save_provider(app_type.as_str(), &current_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
+2 -2
View File
@@ -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,
]);
+2
View File
@@ -126,6 +126,8 @@ export interface ProviderProxyConfig {
export interface ProviderMeta {
// 自定义端点:以 URL 为键,值为端点信息
custom_endpoints?: Record<string, CustomEndpoint>;
// 是否在切换/同步到 live 时应用通用配置片段
commonConfigEnabled?: boolean;
// 用量查询脚本配置
usage_script?: UsageScript;
// 请求地址管理:测速后自动选择最佳端点