mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
fix(codex): move common-config TOML merge off smol-toml to backend toml_edit
updateTomlCommonConfigSnippet re-serialized the whole document through
smol-toml (parse -> deepMerge -> stringify): comments dropped, keys
reordered, and empty parent table headers synthesized -- the
long-standing "config.toml keeps getting reordered" symptom (audit C5,
introduced in 083e48bf).
Replace it with a backend command backed by the same
merge_toml_table_like / remove_toml_table_like used when writing live
configs, so the form preview and the live write share one merge
semantic and user formatting survives edit-time merges. The frontend
sync helper is deleted outright to keep the pattern from coming back.
Making the form operations async exposes them to interleaving, so a
result is discarded unless it is still current when it lands:
- a per-hook sequence number (last operation wins) covers rapid
toggle/save races where an earlier merge resolves after a later
removal and would flip the switch back;
- a config-baseline check covers the user hand-editing the TOML while
a merge is in flight -- the stale result must not clobber their edit.
The checkbox state self-heals via the existing inference effect.
Regression tests pin both by resolving a suspended merge after a newer
operation / an external edit, plus backend tests locking comment and
key-order preservation, scalar override, and value-matched removal.
This commit is contained in:
@@ -275,6 +275,23 @@ pub async fn get_common_config_snippet(
|
|||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 对前端编辑器里的 config.toml 文本做通用配置片段的合并/剥离。
|
||||||
|
/// 放后端是为了走 toml_edit(保注释、保键序);前端 smol-toml 的
|
||||||
|
/// 整文档重序列化会破坏用户手写格式。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn update_toml_common_config_snippet(
|
||||||
|
config_toml: String,
|
||||||
|
snippet_toml: String,
|
||||||
|
enabled: bool,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
crate::services::provider::update_toml_common_config_snippet(
|
||||||
|
&config_toml,
|
||||||
|
&snippet_toml,
|
||||||
|
enabled,
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn set_common_config_snippet(
|
pub async fn set_common_config_snippet(
|
||||||
app_type: String,
|
app_type: String,
|
||||||
|
|||||||
@@ -1215,6 +1215,7 @@ pub fn run() {
|
|||||||
commands::set_claude_common_config_snippet,
|
commands::set_claude_common_config_snippet,
|
||||||
commands::get_common_config_snippet,
|
commands::get_common_config_snippet,
|
||||||
commands::set_common_config_snippet,
|
commands::set_common_config_snippet,
|
||||||
|
commands::update_toml_common_config_snippet,
|
||||||
commands::extract_common_config_snippet,
|
commands::extract_common_config_snippet,
|
||||||
commands::read_live_provider_settings,
|
commands::read_live_provider_settings,
|
||||||
commands::get_settings,
|
commands::get_settings,
|
||||||
|
|||||||
@@ -306,6 +306,40 @@ fn remove_toml_table_like(target: &mut dyn TableLike, source: &dyn TableLike) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 前端表单勾选/取消"使用通用配置"时,对编辑器里的 config.toml 文本做
|
||||||
|
/// 结构化合并/剥离。必须在后端用 toml_edit 做:前端 smol-toml 只能
|
||||||
|
/// parse → merge → 整文档重序列化,注释全丢、键序重排,还会生成多余的
|
||||||
|
/// 空父表头(如 `[model_providers]`)。
|
||||||
|
pub fn update_toml_common_config_snippet(
|
||||||
|
config_toml: &str,
|
||||||
|
snippet_toml: &str,
|
||||||
|
enabled: bool,
|
||||||
|
) -> Result<String, AppError> {
|
||||||
|
let trimmed = snippet_toml.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Ok(config_toml.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
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: {e}")))?
|
||||||
|
};
|
||||||
|
let source_doc = trimmed
|
||||||
|
.parse::<DocumentMut>()
|
||||||
|
.map_err(|e| AppError::Message(format!("Invalid Codex common config snippet: {e}")))?;
|
||||||
|
|
||||||
|
if enabled {
|
||||||
|
merge_toml_table_like(target_doc.as_table_mut(), source_doc.as_table());
|
||||||
|
} else {
|
||||||
|
remove_toml_table_like(target_doc.as_table_mut(), source_doc.as_table());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(target_doc.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
fn settings_contain_common_config(app_type: &AppType, settings: &Value, snippet: &str) -> bool {
|
fn settings_contain_common_config(app_type: &AppType, settings: &Value, snippet: &str) -> bool {
|
||||||
let trimmed = snippet.trim();
|
let trimmed = snippet.trim();
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
@@ -1678,6 +1712,71 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
|
/// C5 回归锁:前端表单的合并/剥离必须走 toml_edit 文档模型。
|
||||||
|
/// smol-toml 的 parse→merge→stringify 整文档重序列化会丢注释、
|
||||||
|
/// 按字母序重排键、并为 dotted 表生成多余的空父表头。
|
||||||
|
#[test]
|
||||||
|
fn update_toml_common_config_snippet_preserves_comments_and_key_order() {
|
||||||
|
// 刻意非字母序的键序 + 注释,模拟用户手写格式
|
||||||
|
let config = r#"# my precious comment
|
||||||
|
model = "gpt-5.5"
|
||||||
|
model_provider = "aprov"
|
||||||
|
disable_response_storage = true
|
||||||
|
|
||||||
|
[model_providers.aprov]
|
||||||
|
# provider comment
|
||||||
|
name = "A Prov"
|
||||||
|
base_url = "https://a.example/v1"
|
||||||
|
"#;
|
||||||
|
let snippet = "[tui]\nnotifications = true\n";
|
||||||
|
|
||||||
|
let merged = update_toml_common_config_snippet(config, snippet, true).unwrap();
|
||||||
|
assert!(merged.contains("# my precious comment"));
|
||||||
|
assert!(merged.contains("# provider comment"));
|
||||||
|
let model_pos = merged.find("model = ").unwrap();
|
||||||
|
let provider_pos = merged.find("model_provider = ").unwrap();
|
||||||
|
let disable_pos = merged.find("disable_response_storage").unwrap();
|
||||||
|
assert!(
|
||||||
|
model_pos < provider_pos && provider_pos < disable_pos,
|
||||||
|
"merge must not reorder user keys, got: {merged}"
|
||||||
|
);
|
||||||
|
assert!(merged.contains("[tui]"));
|
||||||
|
assert!(merged.contains("notifications = true"));
|
||||||
|
assert!(
|
||||||
|
!merged.contains("[model_providers]\n"),
|
||||||
|
"merge must not synthesize an empty parent table header, got: {merged}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let removed = update_toml_common_config_snippet(&merged, snippet, false).unwrap();
|
||||||
|
assert!(!removed.contains("[tui]"), "snippet keys must be stripped");
|
||||||
|
assert!(removed.contains("# my precious comment"));
|
||||||
|
assert!(removed.contains("disable_response_storage = true"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 合并时标量=片段覆盖供应商值(与 Claude 侧 deepMerge 一致);
|
||||||
|
/// 剥离按值匹配:用户改过的值不删(与 strip 路径的
|
||||||
|
/// toml_value_is_subset 语义一致)。
|
||||||
|
#[test]
|
||||||
|
fn update_toml_common_config_snippet_scalar_override_and_value_matched_removal() {
|
||||||
|
let snippet = "[tui]\nnotifications = true\n";
|
||||||
|
|
||||||
|
let merged =
|
||||||
|
update_toml_common_config_snippet("[tui]\nnotifications = false\n", snippet, true)
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
merged.contains("notifications = true"),
|
||||||
|
"snippet scalar should override provider value, got: {merged}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let removed =
|
||||||
|
update_toml_common_config_snippet("[tui]\nnotifications = false\n", snippet, false)
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
removed.contains("notifications = false"),
|
||||||
|
"user-modified value must survive removal, got: {removed}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn claude_common_config_apply_and_remove_roundtrip_for_non_overlapping_fields() {
|
fn claude_common_config_apply_and_remove_roundtrip_for_non_overlapping_fields() {
|
||||||
let settings = json!({
|
let settings = json!({
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ pub use live::{
|
|||||||
import_default_config, import_hermes_providers_from_live, import_openclaw_providers_from_live,
|
import_default_config, import_hermes_providers_from_live, import_openclaw_providers_from_live,
|
||||||
import_opencode_providers_from_live, read_live_settings,
|
import_opencode_providers_from_live, read_live_settings,
|
||||||
should_import_default_config_on_startup, sync_current_to_live,
|
should_import_default_config_on_startup, sync_current_to_live,
|
||||||
|
update_toml_common_config_snippet,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Internal re-exports (pub(crate))
|
// Internal re-exports (pub(crate))
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ interface CodexCommonConfigModalProps {
|
|||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
value: string;
|
value: string;
|
||||||
onSave: (value: string) => boolean;
|
onSave: (value: string) => boolean | Promise<boolean>;
|
||||||
error?: string;
|
error?: string;
|
||||||
onExtract?: () => void;
|
onExtract?: () => void;
|
||||||
isExtracting?: boolean;
|
isExtracting?: boolean;
|
||||||
@@ -58,8 +58,8 @@ export const CodexCommonConfigModal: React.FC<CodexCommonConfigModalProps> = ({
|
|||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = async () => {
|
||||||
if (onSave(draftValue)) {
|
if (await onSave(draftValue)) {
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,11 +22,11 @@ interface CodexConfigEditorProps {
|
|||||||
|
|
||||||
useCommonConfig: boolean;
|
useCommonConfig: boolean;
|
||||||
|
|
||||||
onCommonConfigToggle: (checked: boolean) => void;
|
onCommonConfigToggle: (checked: boolean) => void | Promise<void>;
|
||||||
|
|
||||||
commonConfigSnippet: string;
|
commonConfigSnippet: string;
|
||||||
|
|
||||||
onCommonConfigSnippetChange: (value: string) => boolean;
|
onCommonConfigSnippetChange: (value: string) => boolean | Promise<boolean>;
|
||||||
|
|
||||||
onCommonConfigErrorClear: () => void;
|
onCommonConfigErrorClear: () => void;
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,31 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { parse as parseToml } from "smol-toml";
|
import { parse as parseToml } from "smol-toml";
|
||||||
import {
|
import { hasTomlCommonConfigSnippet } from "@/utils/providerConfigUtils";
|
||||||
updateTomlCommonConfigSnippet,
|
|
||||||
hasTomlCommonConfigSnippet,
|
|
||||||
} from "@/utils/providerConfigUtils";
|
|
||||||
import { configApi } from "@/lib/api";
|
import { configApi } from "@/lib/api";
|
||||||
import { normalizeTomlText } from "@/utils/textNormalization";
|
import { normalizeTomlText } from "@/utils/textNormalization";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合并/剥离通用配置片段(走后端 toml_edit,保注释、保键序)。
|
||||||
|
* 失败时返回原文本 + error,调用方据此决定是否回退 UI 状态。
|
||||||
|
*/
|
||||||
|
const applyTomlSnippet = async (
|
||||||
|
configToml: string,
|
||||||
|
snippetToml: string,
|
||||||
|
enabled: boolean,
|
||||||
|
): Promise<{ updatedConfig: string; error?: string }> => {
|
||||||
|
try {
|
||||||
|
const updatedConfig = await configApi.updateTomlCommonConfigSnippet(
|
||||||
|
configToml,
|
||||||
|
snippetToml,
|
||||||
|
enabled,
|
||||||
|
);
|
||||||
|
return { updatedConfig };
|
||||||
|
} catch (e) {
|
||||||
|
return { updatedConfig: configToml, error: String(e) };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const LEGACY_STORAGE_KEY = "cc-switch:codex-common-config-snippet";
|
const LEGACY_STORAGE_KEY = "cc-switch:codex-common-config-snippet";
|
||||||
const DEFAULT_CODEX_COMMON_CONFIG_SNIPPET = `# Common Codex config
|
const DEFAULT_CODEX_COMMON_CONFIG_SNIPPET = `# Common Codex config
|
||||||
# Add your common TOML configuration here`;
|
# Add your common TOML configuration here`;
|
||||||
@@ -48,6 +66,26 @@ export function useCodexCommonConfig({
|
|||||||
const hasInitializedNewMode = useRef(false);
|
const hasInitializedNewMode = useRef(false);
|
||||||
// 用于跟踪编辑模式是否已初始化显式开关/预览
|
// 用于跟踪编辑模式是否已初始化显式开关/预览
|
||||||
const hasInitializedEditMode = useRef(false);
|
const hasInitializedEditMode = useRef(false);
|
||||||
|
// 后端 TOML 合并是异步的:连续操作(快速点开关、连点保存)可能乱序
|
||||||
|
// 返回。每个写 config 的异步操作在发起时领取递增序号,结果落地前
|
||||||
|
// 校验自己仍是最新一次;过期结果直接丢弃,保证最后一次操作胜出。
|
||||||
|
const tomlOpSeqRef = useRef(0);
|
||||||
|
// 镜像最新的 codexConfig:用户在编辑器手动改动走父组件 onChange,
|
||||||
|
// 不经过本 hook(序号不变)。异步结果落地前还要校验发起时的 config
|
||||||
|
// 基线未被外部改写,否则会覆盖用户在请求在飞期间的手动编辑。
|
||||||
|
const latestCodexConfigRef = useRef(codexConfig);
|
||||||
|
useEffect(() => {
|
||||||
|
latestCodexConfigRef.current = codexConfig;
|
||||||
|
}, [codexConfig]);
|
||||||
|
|
||||||
|
// 结果落地前的过期判定:hook 内有更新的操作(序号变了),或发起时的
|
||||||
|
// config 基线已被外部改写(用户手动编辑为准)。任一成立即丢弃结果。
|
||||||
|
const isTomlOpStale = useCallback(
|
||||||
|
(seq: number, baseConfig: string) =>
|
||||||
|
seq !== tomlOpSeqRef.current ||
|
||||||
|
baseConfig !== latestCodexConfigRef.current,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
// 当预设变化时,重置初始化标记,使新预设能够重新触发初始化逻辑
|
// 当预设变化时,重置初始化标记,使新预设能够重新触发初始化逻辑
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -168,25 +206,34 @@ export function useCodexCommonConfig({
|
|||||||
|
|
||||||
// 如果应该启用通用配置但配置中还没有,则自动添加
|
// 如果应该启用通用配置但配置中还没有,则自动添加
|
||||||
if (hasCommon && !inferredHasCommon && parsedSnippet.hasContent) {
|
if (hasCommon && !inferredHasCommon && parsedSnippet.hasContent) {
|
||||||
const { updatedConfig, error } = updateTomlCommonConfigSnippet(
|
let cancelled = false;
|
||||||
codexConfig,
|
const seq = ++tomlOpSeqRef.current;
|
||||||
commonConfigSnippet,
|
(async () => {
|
||||||
true,
|
const { updatedConfig, error } = await applyTomlSnippet(
|
||||||
);
|
codexConfig,
|
||||||
if (error) {
|
commonConfigSnippet,
|
||||||
setCommonConfigError(error);
|
true,
|
||||||
setUseCommonConfig(false);
|
);
|
||||||
return;
|
if (cancelled || isTomlOpStale(seq, codexConfig)) {
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
if (error) {
|
||||||
|
setCommonConfigError(error);
|
||||||
|
setUseCommonConfig(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setCommonConfigError("");
|
setCommonConfigError("");
|
||||||
setUseCommonConfig(true);
|
setUseCommonConfig(true);
|
||||||
isUpdatingFromCommonConfig.current = true;
|
isUpdatingFromCommonConfig.current = true;
|
||||||
onConfigChange(updatedConfig);
|
onConfigChange(updatedConfig);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
isUpdatingFromCommonConfig.current = false;
|
isUpdatingFromCommonConfig.current = false;
|
||||||
}, 0);
|
}, 0);
|
||||||
return;
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
setCommonConfigError("");
|
setCommonConfigError("");
|
||||||
@@ -197,6 +244,7 @@ export function useCodexCommonConfig({
|
|||||||
initialData,
|
initialData,
|
||||||
initialEnabled,
|
initialEnabled,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
isTomlOpStale,
|
||||||
onConfigChange,
|
onConfigChange,
|
||||||
parseCommonConfigSnippet,
|
parseCommonConfigSnippet,
|
||||||
]);
|
]);
|
||||||
@@ -221,28 +269,39 @@ export function useCodexCommonConfig({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { updatedConfig, error } = updateTomlCommonConfigSnippet(
|
let cancelled = false;
|
||||||
codexConfig,
|
const seq = ++tomlOpSeqRef.current;
|
||||||
commonConfigSnippet,
|
(async () => {
|
||||||
true,
|
const { updatedConfig, error } = await applyTomlSnippet(
|
||||||
);
|
codexConfig,
|
||||||
if (error) {
|
commonConfigSnippet,
|
||||||
setCommonConfigError(error);
|
true,
|
||||||
setUseCommonConfig(false);
|
);
|
||||||
return;
|
if (cancelled || isTomlOpStale(seq, codexConfig)) {
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
if (error) {
|
||||||
|
setCommonConfigError(error);
|
||||||
|
setUseCommonConfig(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setCommonConfigError("");
|
setCommonConfigError("");
|
||||||
setUseCommonConfig(true);
|
setUseCommonConfig(true);
|
||||||
isUpdatingFromCommonConfig.current = true;
|
isUpdatingFromCommonConfig.current = true;
|
||||||
onConfigChange(updatedConfig);
|
onConfigChange(updatedConfig);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
isUpdatingFromCommonConfig.current = false;
|
isUpdatingFromCommonConfig.current = false;
|
||||||
}, 0);
|
}, 0);
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
}, [
|
}, [
|
||||||
initialData,
|
initialData,
|
||||||
commonConfigSnippet,
|
commonConfigSnippet,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
isTomlOpStale,
|
||||||
codexConfig,
|
codexConfig,
|
||||||
onConfigChange,
|
onConfigChange,
|
||||||
parseCommonConfigSnippet,
|
parseCommonConfigSnippet,
|
||||||
@@ -250,7 +309,10 @@ export function useCodexCommonConfig({
|
|||||||
|
|
||||||
// 处理通用配置开关
|
// 处理通用配置开关
|
||||||
const handleCommonConfigToggle = useCallback(
|
const handleCommonConfigToggle = useCallback(
|
||||||
(checked: boolean) => {
|
async (checked: boolean) => {
|
||||||
|
// 在同步校验之前领号:即使本次走同步早退分支,也要让更早发出、
|
||||||
|
// 仍在飞的异步结果作废,避免它晚到后把开关翻回去。
|
||||||
|
const seq = ++tomlOpSeqRef.current;
|
||||||
const parsedSnippet = parseCommonConfigSnippet(commonConfigSnippet);
|
const parsedSnippet = parseCommonConfigSnippet(commonConfigSnippet);
|
||||||
if (parsedSnippet.error) {
|
if (parsedSnippet.error) {
|
||||||
setCommonConfigError(parsedSnippet.error);
|
setCommonConfigError(parsedSnippet.error);
|
||||||
@@ -267,12 +329,14 @@ export function useCodexCommonConfig({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { updatedConfig, error: snippetError } =
|
const { updatedConfig, error: snippetError } = await applyTomlSnippet(
|
||||||
updateTomlCommonConfigSnippet(
|
codexConfig,
|
||||||
codexConfig,
|
commonConfigSnippet,
|
||||||
commonConfigSnippet,
|
checked,
|
||||||
checked,
|
);
|
||||||
);
|
if (isTomlOpStale(seq, codexConfig)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (snippetError) {
|
if (snippetError) {
|
||||||
setCommonConfigError(snippetError);
|
setCommonConfigError(snippetError);
|
||||||
@@ -293,6 +357,7 @@ export function useCodexCommonConfig({
|
|||||||
[
|
[
|
||||||
codexConfig,
|
codexConfig,
|
||||||
commonConfigSnippet,
|
commonConfigSnippet,
|
||||||
|
isTomlOpStale,
|
||||||
onConfigChange,
|
onConfigChange,
|
||||||
parseCommonConfigSnippet,
|
parseCommonConfigSnippet,
|
||||||
t,
|
t,
|
||||||
@@ -301,7 +366,10 @@ export function useCodexCommonConfig({
|
|||||||
|
|
||||||
// 处理通用配置片段变化
|
// 处理通用配置片段变化
|
||||||
const handleCommonConfigSnippetChange = useCallback(
|
const handleCommonConfigSnippetChange = useCallback(
|
||||||
(value: string): boolean => {
|
async (value: string): Promise<boolean> => {
|
||||||
|
// 与 handleCommonConfigToggle 同一套序号:连续保存或保存与开关
|
||||||
|
// 交错时,只允许最后一次操作的结果落地。
|
||||||
|
const seq = ++tomlOpSeqRef.current;
|
||||||
const previousSnippet = commonConfigSnippet;
|
const previousSnippet = commonConfigSnippet;
|
||||||
|
|
||||||
if (!value.trim()) {
|
if (!value.trim()) {
|
||||||
@@ -312,11 +380,14 @@ export function useCodexCommonConfig({
|
|||||||
let updatedConfig = codexConfig;
|
let updatedConfig = codexConfig;
|
||||||
|
|
||||||
if (!previousParsed.error && previousParsed.hasContent) {
|
if (!previousParsed.error && previousParsed.hasContent) {
|
||||||
const removeResult = updateTomlCommonConfigSnippet(
|
const removeResult = await applyTomlSnippet(
|
||||||
codexConfig,
|
codexConfig,
|
||||||
previousSnippet,
|
previousSnippet,
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
if (isTomlOpStale(seq, codexConfig)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (removeResult.error) {
|
if (removeResult.error) {
|
||||||
setCommonConfigError(removeResult.error);
|
setCommonConfigError(removeResult.error);
|
||||||
return false;
|
return false;
|
||||||
@@ -352,11 +423,14 @@ export function useCodexCommonConfig({
|
|||||||
const previousParsed = parseCommonConfigSnippet(previousSnippet);
|
const previousParsed = parseCommonConfigSnippet(previousSnippet);
|
||||||
|
|
||||||
if (!previousParsed.error && previousParsed.hasContent) {
|
if (!previousParsed.error && previousParsed.hasContent) {
|
||||||
const removeResult = updateTomlCommonConfigSnippet(
|
const removeResult = await applyTomlSnippet(
|
||||||
codexConfig,
|
codexConfig,
|
||||||
previousSnippet,
|
previousSnippet,
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
if (isTomlOpStale(seq, codexConfig)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (removeResult.error) {
|
if (removeResult.error) {
|
||||||
setCommonConfigError(removeResult.error);
|
setCommonConfigError(removeResult.error);
|
||||||
return false;
|
return false;
|
||||||
@@ -364,11 +438,11 @@ export function useCodexCommonConfig({
|
|||||||
nextConfig = removeResult.updatedConfig;
|
nextConfig = removeResult.updatedConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
const addResult = updateTomlCommonConfigSnippet(
|
const addResult = await applyTomlSnippet(nextConfig, value, true);
|
||||||
nextConfig,
|
// nextConfig 派生自发起时的 codexConfig,基线校验仍对 codexConfig 做
|
||||||
value,
|
if (isTomlOpStale(seq, codexConfig)) {
|
||||||
true,
|
return false;
|
||||||
);
|
}
|
||||||
|
|
||||||
if (addResult.error) {
|
if (addResult.error) {
|
||||||
setCommonConfigError(addResult.error);
|
setCommonConfigError(addResult.error);
|
||||||
@@ -400,6 +474,7 @@ export function useCodexCommonConfig({
|
|||||||
[
|
[
|
||||||
commonConfigSnippet,
|
commonConfigSnippet,
|
||||||
codexConfig,
|
codexConfig,
|
||||||
|
isTomlOpStale,
|
||||||
onConfigChange,
|
onConfigChange,
|
||||||
parseCommonConfigSnippet,
|
parseCommonConfigSnippet,
|
||||||
t,
|
t,
|
||||||
|
|||||||
@@ -48,6 +48,29 @@ export async function setCommonConfigSnippet(
|
|||||||
return invoke("set_common_config_snippet", { appType, snippet });
|
return invoke("set_common_config_snippet", { appType, snippet });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对编辑器里的 config.toml 文本做通用配置片段的合并/剥离
|
||||||
|
*
|
||||||
|
* 合并/剥离在后端用 toml_edit 完成(保注释、保键序);前端 smol-toml
|
||||||
|
* 的整文档重序列化会破坏用户手写格式,禁止在前端做这类结构化改写。
|
||||||
|
*
|
||||||
|
* @param configToml - 编辑器当前的 config.toml 文本
|
||||||
|
* @param snippetToml - 通用配置片段(TOML 字符串)
|
||||||
|
* @param enabled - true 合并片段,false 按值匹配剥离片段
|
||||||
|
* @returns 更新后的 config.toml 文本
|
||||||
|
*/
|
||||||
|
export async function updateTomlCommonConfigSnippet(
|
||||||
|
configToml: string,
|
||||||
|
snippetToml: string,
|
||||||
|
enabled: boolean,
|
||||||
|
): Promise<string> {
|
||||||
|
return invoke<string>("update_toml_common_config_snippet", {
|
||||||
|
configToml,
|
||||||
|
snippetToml,
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 提取通用配置片段
|
* 提取通用配置片段
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import type { TemplateValueConfig } from "../config/claudeProviderPresets";
|
import type { TemplateValueConfig } from "../config/claudeProviderPresets";
|
||||||
import { deepClone } from "@/utils/deepClone";
|
import { deepClone } from "@/utils/deepClone";
|
||||||
import { normalizeTomlText } from "@/utils/textNormalization";
|
import { normalizeTomlText } from "@/utils/textNormalization";
|
||||||
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
|
import { parse as parseToml } from "smol-toml";
|
||||||
|
|
||||||
const isPlainObject = (value: unknown): value is Record<string, any> => {
|
const isPlainObject = (value: unknown): value is Record<string, any> => {
|
||||||
return Object.prototype.toString.call(value) === "[object Object]";
|
return Object.prototype.toString.call(value) === "[object Object]";
|
||||||
@@ -343,40 +343,9 @@ export const setApiKeyInConfig = (
|
|||||||
|
|
||||||
// ========== TOML Config Utilities ==========
|
// ========== TOML Config Utilities ==========
|
||||||
|
|
||||||
export interface UpdateTomlCommonConfigResult {
|
// TOML 片段的合并/剥离必须走后端命令(configApi.updateTomlCommonConfigSnippet,
|
||||||
updatedConfig: string;
|
// toml_edit 保注释保键序)。禁止在前端用 smol-toml parse→merge→stringify
|
||||||
error?: string;
|
// 整文档重序列化:注释全丢、键序重排、还会生成多余的空父表头。
|
||||||
}
|
|
||||||
|
|
||||||
// Write/remove common config snippet to/from TOML config (structural merge)
|
|
||||||
export const updateTomlCommonConfigSnippet = (
|
|
||||||
tomlString: string,
|
|
||||||
snippetString: string,
|
|
||||||
enabled: boolean,
|
|
||||||
): UpdateTomlCommonConfigResult => {
|
|
||||||
if (!snippetString.trim()) {
|
|
||||||
return { updatedConfig: tomlString };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const config = parseToml(normalizeTomlText(tomlString || ""));
|
|
||||||
const snippet = parseToml(normalizeTomlText(snippetString));
|
|
||||||
|
|
||||||
if (enabled) {
|
|
||||||
const merged = deepMerge(
|
|
||||||
deepClone(config) as Record<string, any>,
|
|
||||||
deepClone(snippet) as Record<string, any>,
|
|
||||||
);
|
|
||||||
return { updatedConfig: stringifyToml(merged) };
|
|
||||||
} else {
|
|
||||||
const result = deepClone(config) as Record<string, any>;
|
|
||||||
deepRemove(result, snippet as Record<string, any>);
|
|
||||||
return { updatedConfig: stringifyToml(result) };
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
return { updatedConfig: tomlString, error: String(e) };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check if TOML config already contains the common config snippet (structural subset check)
|
// Check if TOML config already contains the common config snippet (structural subset check)
|
||||||
export const hasTomlCommonConfigSnippet = (
|
export const hasTomlCommonConfigSnippet = (
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useGeminiCommonConfig } from "@/components/providers/forms/hooks/useGem
|
|||||||
const getCommonConfigSnippetMock = vi.fn();
|
const getCommonConfigSnippetMock = vi.fn();
|
||||||
const setCommonConfigSnippetMock = vi.fn();
|
const setCommonConfigSnippetMock = vi.fn();
|
||||||
const extractCommonConfigSnippetMock = vi.fn();
|
const extractCommonConfigSnippetMock = vi.fn();
|
||||||
|
const updateTomlCommonConfigSnippetMock = vi.fn();
|
||||||
|
|
||||||
vi.mock("@/lib/api", () => ({
|
vi.mock("@/lib/api", () => ({
|
||||||
configApi: {
|
configApi: {
|
||||||
@@ -15,6 +16,8 @@ vi.mock("@/lib/api", () => ({
|
|||||||
setCommonConfigSnippetMock(...args),
|
setCommonConfigSnippetMock(...args),
|
||||||
extractCommonConfigSnippet: (...args: unknown[]) =>
|
extractCommonConfigSnippet: (...args: unknown[]) =>
|
||||||
extractCommonConfigSnippetMock(...args),
|
extractCommonConfigSnippetMock(...args),
|
||||||
|
updateTomlCommonConfigSnippet: (...args: unknown[]) =>
|
||||||
|
updateTomlCommonConfigSnippetMock(...args),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -23,6 +26,9 @@ describe("common config snippet saving", () => {
|
|||||||
getCommonConfigSnippetMock.mockResolvedValue("");
|
getCommonConfigSnippetMock.mockResolvedValue("");
|
||||||
setCommonConfigSnippetMock.mockResolvedValue(undefined);
|
setCommonConfigSnippetMock.mockResolvedValue(undefined);
|
||||||
extractCommonConfigSnippetMock.mockResolvedValue("");
|
extractCommonConfigSnippetMock.mockResolvedValue("");
|
||||||
|
updateTomlCommonConfigSnippetMock.mockImplementation(
|
||||||
|
async (configToml: string) => configToml,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not persist an invalid Codex common config snippet", async () => {
|
it("does not persist an invalid Codex common config snippet", async () => {
|
||||||
@@ -36,9 +42,9 @@ describe("common config snippet saving", () => {
|
|||||||
|
|
||||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||||
|
|
||||||
let saved = false;
|
let saved = true;
|
||||||
act(() => {
|
await act(async () => {
|
||||||
saved = result.current.handleCommonConfigSnippetChange(
|
saved = await result.current.handleCommonConfigSnippetChange(
|
||||||
"base_url = https://bad.example/v1",
|
"base_url = https://bad.example/v1",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -49,6 +55,99 @@ describe("common config snippet saving", () => {
|
|||||||
expect(result.current.commonConfigError).toContain("invalid value");
|
expect(result.current.commonConfigError).toContain("invalid value");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("discards stale toggle results when a newer toggle finishes first", async () => {
|
||||||
|
getCommonConfigSnippetMock.mockResolvedValue(
|
||||||
|
"[tui]\nnotifications = true\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
const onConfigChange = vi.fn();
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useCodexCommonConfig({
|
||||||
|
codexConfig: 'model = "gpt-5"',
|
||||||
|
onConfigChange,
|
||||||
|
initialData: { settingsConfig: { config: 'model = "gpt-5"' } },
|
||||||
|
initialEnabled: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||||
|
await waitFor(() => expect(result.current.useCommonConfig).toBe(false));
|
||||||
|
|
||||||
|
// 第一次调用(勾选 on 的 merge)挂起,第二次(取消勾选的剥离)立即返回:
|
||||||
|
// 模拟后端乱序完成
|
||||||
|
let resolveMerge: ((value: string) => void) | undefined;
|
||||||
|
updateTomlCommonConfigSnippetMock
|
||||||
|
.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise<string>((resolve) => {
|
||||||
|
resolveMerge = resolve;
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mockImplementationOnce(async (configToml: string) => configToml);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
const mergePending = result.current.handleCommonConfigToggle(true);
|
||||||
|
const removeDone = result.current.handleCommonConfigToggle(false);
|
||||||
|
await removeDone;
|
||||||
|
// on 的合并结果此时才姗姗来迟——必须被序号守卫丢弃
|
||||||
|
resolveMerge?.('model = "gpt-5"\n\n[tui]\nnotifications = true\n');
|
||||||
|
await mergePending;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 用户最后一次操作是 off:过期的 on 结果不得翻转开关或改写配置
|
||||||
|
expect(result.current.useCommonConfig).toBe(false);
|
||||||
|
const lastConfig = onConfigChange.mock.calls.at(-1)?.[0] as string;
|
||||||
|
expect(lastConfig).not.toContain("[tui]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("discards async merge results when the user edited the config while in flight", async () => {
|
||||||
|
getCommonConfigSnippetMock.mockResolvedValue(
|
||||||
|
"[tui]\nnotifications = true\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
const initialData = { settingsConfig: { config: 'model = "gpt-5"' } };
|
||||||
|
const onConfigChange = vi.fn();
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ config }: { config: string }) =>
|
||||||
|
useCodexCommonConfig({
|
||||||
|
codexConfig: config,
|
||||||
|
onConfigChange,
|
||||||
|
initialData,
|
||||||
|
initialEnabled: false,
|
||||||
|
}),
|
||||||
|
{ initialProps: { config: 'model = "gpt-5"' } },
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||||
|
await waitFor(() => expect(result.current.useCommonConfig).toBe(false));
|
||||||
|
|
||||||
|
let resolveMerge: ((value: string) => void) | undefined;
|
||||||
|
updateTomlCommonConfigSnippetMock.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise<string>((resolve) => {
|
||||||
|
resolveMerge = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let togglePending: Promise<void> = Promise.resolve();
|
||||||
|
act(() => {
|
||||||
|
togglePending = result.current.handleCommonConfigToggle(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// merge 在飞期间,用户在编辑器里手动改了 config(不经过 hook,
|
||||||
|
// 序号不变,只有 codexConfig prop 变化)
|
||||||
|
rerender({ config: 'model = "gpt-6-user-edit"' });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
resolveMerge?.('model = "gpt-5"\n\n[tui]\nnotifications = true\n');
|
||||||
|
await togglePending;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 基于陈旧基线的合并结果必须被丢弃,不得覆盖用户的手动编辑
|
||||||
|
expect(onConfigChange).not.toHaveBeenCalled();
|
||||||
|
expect(result.current.useCommonConfig).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("does not persist an invalid Gemini common config snippet", async () => {
|
it("does not persist an invalid Gemini common config snippet", async () => {
|
||||||
const onEnvChange = vi.fn();
|
const onEnvChange = vi.fn();
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
|
|||||||
Reference in New Issue
Block a user