diff --git a/apps/codex-plus-manager/src-tauri/src/commands.rs b/apps/codex-plus-manager/src-tauri/src/commands.rs index c70022a21..4a668d21a 100644 --- a/apps/codex-plus-manager/src-tauri/src/commands.rs +++ b/apps/codex-plus-manager/src-tauri/src/commands.rs @@ -783,6 +783,7 @@ pub fn load_settings() -> CommandResult { #[tauri::command] pub fn save_settings(settings: BackendSettings) -> CommandResult { + let original_settings = settings.clone(); let settings = normalize_settings_before_save(settings); let Ok(_guard) = relay_switch_mutex().lock() else { return failed( @@ -798,6 +799,20 @@ pub fn save_settings(settings: BackendSettings) -> CommandResult BackendSetti &settings.relay_context_config_contents, ); for profile in &mut settings.relay_profiles { - if let Err(error) = - codex_plus_core::relay_config::normalize_relay_profile_for_storage(profile) - { - log_manager_event( - "manager.normalize_relay_profile_for_storage.failed", - json!({ - "profileId": profile.id, - "profileName": profile.name, - "error": error.to_string() - }), - ); + let mut normalized = profile.clone(); + match codex_plus_core::relay_config::normalize_relay_profile_for_storage(&mut normalized) { + Ok(()) => *profile = normalized, + Err(error) => { + log_manager_event( + "manager.normalize_relay_profile_for_storage.failed", + json!({ + "profileId": profile.id, + "profileName": profile.name, + "error": error.to_string() + }), + ); + } } } let common_config = relay_combined_common_config(&settings); @@ -2065,6 +2082,41 @@ fn relay_config_set_goals_override(config: &str, enabled: bool) -> String { codex_plus_core::relay_config::normalize_config_text(&doc.to_string()) } +fn normalize_relay_profiles_strict( + settings: &mut BackendSettings, + previous: &BackendSettings, + always_validate_id: Option<&str>, +) -> anyhow::Result<()> { + for profile in &mut settings.relay_profiles { + let previous_profile = previous + .relay_profiles + .iter() + .find(|candidate| candidate.id == profile.id); + let per_model_changed = previous_profile.is_none_or(|previous_profile| { + profile.model_list != previous_profile.model_list + || profile.model_windows != previous_profile.model_windows + || profile.model_auto_compact != previous_profile.model_auto_compact + || profile.model_metadata != previous_profile.model_metadata + }); + let per_model_config_activated = previous_profile.is_some_and(|previous_profile| { + let previously_active = previous_profile.uses_api_mode(); + let now_active = profile.uses_api_mode(); + !previously_active && now_active + }); + if !per_model_changed + && !per_model_config_activated + && always_validate_id != Some(profile.id.as_str()) + { + continue; + } + let mut normalized = profile.clone(); + codex_plus_core::relay_config::normalize_relay_profile_for_storage(&mut normalized) + .map_err(|error| anyhow::anyhow!("{}:{error}", profile.name))?; + *profile = normalized; + } + Ok(()) +} + fn normalize_provider_sync_provider_list(values: Vec) -> Vec { let mut seen = std::collections::HashSet::new(); let mut result = Vec::new(); @@ -3127,9 +3179,29 @@ pub struct RelayProfileSwitchRequest { } #[tauri::command] -pub fn switch_relay_profile( +pub async fn switch_relay_profile( + request: RelayProfileSwitchRequest, +) -> CommandResult { + let fallback_settings = request.settings.clone(); + tauri::async_runtime::spawn_blocking(move || switch_relay_profile_blocking(request)) + .await + .unwrap_or_else(|error| { + let home = codex_plus_core::relay_config::default_codex_home_dir(); + failed( + &format!("后台切换供应商失败:{error}"), + relay_switch_payload( + fallback_settings, + codex_plus_core::relay_config::relay_status_from_home(&home), + None, + ), + ) + }) +} + +fn switch_relay_profile_blocking( request: RelayProfileSwitchRequest, ) -> CommandResult { + let started_at = std::time::Instant::now(); let Ok(_guard) = relay_switch_mutex().lock() else { let status = codex_plus_core::relay_config::default_relay_status(); return failed( @@ -3144,7 +3216,20 @@ pub fn switch_relay_profile( let home = codex_plus_core::relay_config::default_codex_home_dir(); let store = SettingsStore::default(); let previous_active_relay_id = request.previous_active_relay_id; - let settings = normalize_settings_before_save(request.settings); + let original_settings = request.settings.clone(); + let previous = store.load().unwrap_or_default(); + let mut settings = request.settings; + let target_relay_id = settings.active_relay_id.clone(); + if let Err(error) = + normalize_relay_profiles_strict(&mut settings, &previous, Some(&target_relay_id)) + { + let status = codex_plus_core::relay_config::relay_status_from_home(&home); + return failed( + &format!("每模型配置无效:{error}"), + relay_switch_payload(original_settings, status, None), + ); + } + let settings = normalize_settings_before_save(settings); log_manager_event( "manager.switch_relay_profile.start", json!({ @@ -3165,7 +3250,8 @@ pub fn switch_relay_profile( json!({ "targetRelayId": result.settings.active_relay_id, "configured": status.configured, - "backupPath": result.backup_path.as_ref() + "backupPath": result.backup_path.as_ref(), + "elapsedMs": started_at.elapsed().as_millis() }), ); ok( @@ -3181,7 +3267,8 @@ pub fn switch_relay_profile( json!({ "previousActiveRelayId": previous_active_relay_id, "activeRelayId": settings.active_relay_id, - "error": error.to_string() + "error": error.to_string(), + "elapsedMs": started_at.elapsed().as_millis() }), ); failed( @@ -3192,6 +3279,107 @@ pub fn switch_relay_profile( } } +#[tauri::command] +pub async fn reapply_active_relay_profile() -> CommandResult { + tauri::async_runtime::spawn_blocking(reapply_active_relay_profile_blocking) + .await + .unwrap_or_else(|error| { + let home = codex_plus_core::relay_config::default_codex_home_dir(); + failed( + &format!("后台重新应用供应商配置失败:{error}"), + relay_payload( + codex_plus_core::relay_config::relay_status_from_home(&home), + None, + ), + ) + }) +} + +fn reapply_active_relay_profile_blocking() -> CommandResult { + // 保存当前 profile 的轻量入口:持切换锁避免与真实切换竞争,但不触发 + // App State 钩子,也不走 apply_relay_injection 的 ChatGPT 回落分支。 + let Ok(_guard) = relay_switch_mutex().lock() else { + return failed( + "供应商配置锁已损坏,请重启管理器后再试。", + relay_payload(codex_plus_core::relay_config::default_relay_status(), None), + ); + }; + let home = codex_plus_core::relay_config::default_codex_home_dir(); + let settings = SettingsStore::default().load().unwrap_or_default(); + if !settings.relay_profiles_enabled { + return failed( + "供应商配置总开关已关闭,未写入 config.toml / auth.json。", + relay_payload( + codex_plus_core::relay_config::relay_status_from_home(&home), + None, + ), + ); + } + if settings.active_aggregate_relay_profile().is_some() { + return apply_aggregate_relay_injection_to_home(&home); + } + let relay = settings.active_relay_profile(); + log_relay_apply_request("manager.reapply_active_relay_profile", &settings, &relay); + apply_saved_relay_profile_to_home( + &home, + &settings, + &relay, + "manager.reapply_active_relay_profile", + "当前供应商配置已重新应用。", + "重新应用当前供应商配置失败", + None, + ) +} + +fn apply_saved_relay_profile_to_home( + home: &Path, + settings: &BackendSettings, + relay: &codex_plus_core::settings::RelayProfile, + event_prefix: &str, + success_message: &str, + failure_message: &str, + finish_app_state_source: Option<&str>, +) -> CommandResult { + match codex_plus_core::relay_config::apply_relay_profile_to_home_with_switch_rules_and_computer_use_guard( + home, + relay, + &relay_combined_common_config(settings), + settings.computer_use_guard_enabled, + ) { + Ok(result) => { + if let Some(source) = finish_app_state_source { + finish_codex_app_state_after_provider_switch(home, source); + } + let status = codex_plus_core::relay_config::relay_status_from_home(home); + log_relay_apply_result( + &format!("{event_prefix}.ok"), + relay, + &status, + result.backup_path.as_ref(), + None, + ); + ok( + success_message, + relay_payload(status, result.backup_path), + ) + } + Err(error) => { + let status = codex_plus_core::relay_config::relay_status_from_home(home); + log_relay_apply_result( + &format!("{event_prefix}.failed"), + relay, + &status, + None, + Some(error.to_string()), + ); + failed( + &format!("{failure_message}:{error}"), + relay_payload(status, None), + ) + } + } +} + #[tauri::command] pub fn write_diagnostic_event(event: String, detail: Value) -> CommandResult { let event = sanitize_manager_event(&event); @@ -3864,45 +4052,15 @@ pub fn apply_relay_injection() -> CommandResult { return response; } if relay_has_complete_files(&relay) { - return match codex_plus_core::relay_config::apply_relay_profile_to_home_with_switch_rules_and_computer_use_guard( + return apply_saved_relay_profile_to_home( &home, + &settings, &relay, - &relay_combined_common_config(&settings), - settings.computer_use_guard_enabled, - ) { - Ok(result) => { - finish_codex_app_state_after_provider_switch( - &home, - "manager.apply_relay_injection.profile", - ); - let status = codex_plus_core::relay_config::relay_status_from_home(&home); - log_relay_apply_result( - "manager.apply_relay_injection.ok", - &relay, - &status, - result.backup_path.as_ref(), - None, - ); - ok( - "已按兼容切换规则切换供应商。", - relay_payload(status, result.backup_path), - ) - } - Err(error) => { - let status = codex_plus_core::relay_config::relay_status_from_home(&home); - log_relay_apply_result( - "manager.apply_relay_injection.failed", - &relay, - &status, - None, - Some(error.to_string()), - ); - failed( - &format!("切换完整中转配置失败:{error}"), - relay_payload(status, None), - ) - } - }; + "manager.apply_relay_injection", + "已按兼容切换规则切换供应商。", + "切换完整中转配置失败", + Some("manager.apply_relay_injection.profile"), + ); } let auth = codex_plus_core::relay_config::chatgpt_auth_status_from_home(&home); @@ -5666,6 +5824,93 @@ mod tests { ); } + #[test] + fn normalize_relay_profiles_strict_rejects_invalid_model_auto_compact() { + let mut settings = BackendSettings { + relay_profiles: vec![RelayProfile { + name: "Invalid compact".to_string(), + relay_mode: codex_plus_core::settings::RelayMode::PureApi, + model_auto_compact: r#"{"deepseek-v4-pro":"101%"}"#.to_string(), + ..RelayProfile::default() + }], + ..BackendSettings::default() + }; + let previous = BackendSettings::default(); + + let error = normalize_relay_profiles_strict(&mut settings, &previous, None) + .expect_err("保存入口必须拒绝超范围自动压缩百分比"); + assert!(error.to_string().contains("model_auto_compact")); + } + + #[test] + fn normalize_relay_profiles_strict_rejects_invalid_model_metadata_transactionally() { + let mut settings = BackendSettings { + relay_profiles: vec![RelayProfile { + id: "changed".to_string(), + name: "Invalid metadata".to_string(), + relay_mode: codex_plus_core::settings::RelayMode::PureApi, + model_list: "deepseek-v4-pro[1M]".to_string(), + model_metadata: "{not json".to_string(), + ..RelayProfile::default() + }], + ..BackendSettings::default() + }; + let original = settings.relay_profiles[0].clone(); + + let error = + normalize_relay_profiles_strict(&mut settings, &BackendSettings::default(), None) + .expect_err("保存入口必须拒绝非法 model_metadata"); + + assert!(error.to_string().contains("model_metadata")); + assert_eq!(settings.relay_profiles[0], original); + } + + #[test] + fn normalize_relay_profiles_strict_skips_unchanged_invalid_profile() { + let previous = BackendSettings { + relay_profiles: vec![RelayProfile { + id: "legacy-invalid".to_string(), + model_auto_compact: r#"{"old-model":"101%"}"#.to_string(), + ..RelayProfile::default() + }], + ..BackendSettings::default() + }; + let mut settings = previous.clone(); + settings.launch_mode = codex_plus_core::settings::LaunchMode::Relay; + + normalize_relay_profiles_strict(&mut settings, &previous, None) + .expect("无关设置保存不应被未修改的旧 profile 阻断"); + assert_eq!(settings.relay_profiles, previous.relay_profiles); + } + + #[test] + fn normalize_relay_profiles_strict_revalidates_when_official_profile_enables_api_mode() { + let previous = BackendSettings { + relay_profiles: vec![RelayProfile { + id: "legacy-invalid".to_string(), + relay_mode: codex_plus_core::settings::RelayMode::Official, + official_mix_api_key: false, + model_windows: r#"{"old-model":"1.5M"}"#.to_string(), + ..RelayProfile::default() + }], + ..BackendSettings::default() + }; + + for (relay_mode, official_mix_api_key) in [ + (codex_plus_core::settings::RelayMode::PureApi, false), + (codex_plus_core::settings::RelayMode::MixedApi, false), + (codex_plus_core::settings::RelayMode::Official, true), + ] { + let mut settings = previous.clone(); + settings.relay_profiles[0].relay_mode = relay_mode; + settings.relay_profiles[0].official_mix_api_key = official_mix_api_key; + + let error = normalize_relay_profiles_strict(&mut settings, &previous, None) + .expect_err("纯 Official 启用 API 模式时必须重新校验历史 per-model 数据"); + assert!(error.to_string().contains("model_windows")); + } + } + #[test] fn normalize_settings_before_save_preserves_manual_relay_mode_for_pure_api_profile() { let settings = BackendSettings { diff --git a/apps/codex-plus-manager/src-tauri/src/lib.rs b/apps/codex-plus-manager/src-tauri/src/lib.rs index da987661a..7ddd41e46 100644 --- a/apps/codex-plus-manager/src-tauri/src/lib.rs +++ b/apps/codex-plus-manager/src-tauri/src/lib.rs @@ -150,6 +150,7 @@ pub fn run() { commands::fetch_relay_profile_models, commands::fetch_sub2api_billing, commands::switch_relay_profile, + commands::reapply_active_relay_profile, commands::apply_relay_injection, commands::apply_pure_api_injection, commands::clear_relay_injection, diff --git a/apps/codex-plus-manager/src-tauri/tests/windows_subsystem.rs b/apps/codex-plus-manager/src-tauri/tests/windows_subsystem.rs index 3cda81083..c603297d8 100644 --- a/apps/codex-plus-manager/src-tauri/tests/windows_subsystem.rs +++ b/apps/codex-plus-manager/src-tauri/tests/windows_subsystem.rs @@ -216,12 +216,20 @@ fn relay_settings_keeps_profile_config_and_auth_files_isolated() { let app_tsx = std::fs::read_to_string(&app_tsx).expect("read manager App.tsx"); let commands_rs = manifest_dir.join("src/commands.rs"); let commands_rs = std::fs::read_to_string(&commands_rs).expect("read manager commands.rs"); + let relay_switch_rs = manifest_dir + .parent() + .and_then(std::path::Path::parent) + .and_then(std::path::Path::parent) + .unwrap() + .join("crates/codex-plus-core/src/relay_switch.rs"); + let relay_switch_rs = + std::fs::read_to_string(&relay_switch_rs).expect("read relay switch core"); - assert!(app_tsx.contains("snapshotActiveRelayFilesBeforeSwitch")); - assert!(app_tsx.contains("backfill_relay_profile_from_live")); + assert!(!app_tsx.contains("snapshotActiveRelayFilesBeforeSwitch")); + assert!(!app_tsx.contains("backfill_relay_profile_from_live")); assert!(app_tsx.contains("relayProfileSwitchValidation(selectedBeforeSave, switchSettings)")); assert!(app_tsx.contains("缺少独立 config.toml")); - assert!(app_tsx.contains("const command = relayProfileSwitchCommand(selectedAfterSave)")); + assert!(app_tsx.contains("const command = relayProfileSwitchCommand(selectedProfile)")); assert!(app_tsx.contains("function relayProfileSwitchCommand")); assert!(app_tsx.contains("return \"apply_pure_api_injection\"")); assert!(app_tsx.contains("return \"apply_relay_injection\"")); @@ -231,6 +239,10 @@ fn relay_settings_keeps_profile_config_and_auth_files_isolated() { assert!(!commands_rs.contains("缺少独立 auth.json")); assert!(commands_rs.contains("backfill_relay_profile_from_live")); assert!(commands_rs.contains("apply_relay_profile_to_home_with_switch_rules")); + assert!(relay_switch_rs.contains("backfill_profile_before_switch")); + assert!( + relay_switch_rs.contains("previous_active_relay_id != selected_settings.active_relay_id") + ); } #[test] diff --git a/apps/codex-plus-manager/src/App.tsx b/apps/codex-plus-manager/src/App.tsx index 2a7aadfaa..309929376 100644 --- a/apps/codex-plus-manager/src/App.tsx +++ b/apps/codex-plus-manager/src/App.tsx @@ -80,6 +80,7 @@ import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { codexGoalsFeatureState, setCodexGoalsFeatureInConfig } from "./goals-config"; import { isGitHubRepositoryHomepage } from "./github-repository"; +import { normalizeAutoCompactPercent } from "./auto-compact"; import { findRelayModelRouteIssue, modelRouteSaveRequiresRestart, @@ -90,11 +91,24 @@ import { import { mergeModelWindowRows, modelWindowRowsFromProfile, + modelWindowRowsValidationError, serializeModelWindowRows, type ImageHandling, + type ModelWindowRowsValidationIssue, type ModelWindowRow, } from "./model-windows"; import { relayAuthForLiveDraft } from "./relay-live-files"; +import { + clearModelMetadataForSlug, + parseModelMetadataDocument, + parseModelMetadataMap, + remapModelMetadataSlugs, + replaceModelMetadataForSlug, + retainModelMetadataForSlugs, + serializeModelMetadataDocument, + synchronizeModelMetadataDocumentLimitsPreview, + type ImportedModelMetadata, +} from "./model-metadata"; import { resolveProviderSyncCompletion } from "./provider-sync-flow"; import { defaultDreamSkinTheme, @@ -126,6 +140,15 @@ const dreamSkinMacPreviewUrl = new URL("../../../assets/inject/upstream/dream-sk const dreamSkinCompanionDataUrlLimit = 240_000; const dreamSkinCompanionMimeTypes = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]); +function modelWindowRowsValidationMessage(issue: ModelWindowRowsValidationIssue | null): string | null { + if (!issue) return null; + if (issue.code === "duplicateModel") return tf("模型名称重复:{0}", [issue.model]); + if (issue.code === "invalidWindow") { + return tf("模型 {0} 的上下文窗口无效;请输入正整数,或使用 K/M 整数后缀。", [issue.model]); + } + return tf("模型 {0} 的自动压缩百分比无效;请输入 0 到 100 之间、最多 6 位小数的十进制数。", [issue.model]); +} + type Status = "ok" | "failed" | "not_implemented" | "not_checked" | string; type CommandResult = T & { @@ -279,6 +302,8 @@ export type RelayProfile = { autoCompactLimit: string; modelList: string; modelWindows: string; + modelAutoCompact: string; + modelMetadata: string; modelVlm: string; vlmApiKey: string; vlmModel: string; @@ -461,10 +486,6 @@ type RelaySwitchResult = CommandResult<{ relay: RelayPayload; }>; -type SettingsBackfillResult = CommandResult<{ - settings: BackendSettings; -}>; - type RelayProfileTestResult = CommandResult<{ httpStatus: number; endpoint: string; @@ -866,6 +887,8 @@ const defaultSettings: BackendSettings = { autoCompactLimit: "", modelList: "", modelWindows: "", + modelAutoCompact: "", + modelMetadata: "", modelVlm: "", vlmApiKey: "", vlmModel: "", @@ -960,6 +983,7 @@ export function App() { const [selectedProviderSyncTarget, setSelectedProviderSyncTarget] = useState(""); const [removeOwnedData, setRemoveOwnedData] = useState(false); const [relaySwitching, setRelaySwitching] = useState(false); + const relaySwitchingRef = useRef(false); const dreamSkinDraftDirty = Boolean( savedDreamSkinThemeDraft && dreamSkinThemeDraft @@ -2011,13 +2035,16 @@ export function App() { const next = normalizeSettings(settingsForm); const result = await run(() => call("save_settings", { settings: next })); if (result) { - setSettings(result); - setSettingsForm(normalizeSettings(result.settings)); + if (isSuccessStatus(result.status)) { + setSettings(result); + setSettingsForm(normalizeSettings(result.settings)); + } showNotice(t("设置保存"), result.message, result.status); } }; const saveSettingsValue = async (next: BackendSettings, silent = true) => { + const previous = settingsForm; const normalized = normalizeSettings(next); const result = await run(() => call("save_settings", { settings: normalized })); if (result && isSuccessStatus(result.status)) { @@ -2241,6 +2268,18 @@ export function App() { return !!result && isSuccessStatus(result.status) && !result.configured; }; + const reapplyActiveRelayProfile = async (silent = false) => { + const result = await run(() => call("reapply_active_relay_profile")); + if (result) { + setRelay(result); + await refreshRelayFiles(true); + if (!silent || !isSuccessStatus(result.status)) { + showNotice(t("保存供应商"), result.message, result.status); + } + } + return !!result && isSuccessStatus(result.status); + }; + const saveRelayFile = async (kind: "config" | "auth", contents: string, silent = false) => { const result = await run(() => call("save_relay_file", { request: { kind, contents } })); if (result) { @@ -2250,6 +2289,7 @@ export function App() { } await refreshRelay(true); } + return !!result && isSuccessStatus(result.status); }; const upsertContextEntry = async (next: BackendSettings, kind: ContextKind, id: string, tomlBody: string) => { @@ -2341,7 +2381,7 @@ export function App() { }; const switchRelayProfile = async (next: BackendSettings, previousActiveRelayId = settingsForm.activeRelayId) => { - if (relaySwitching) { + if (relaySwitchingRef.current) { showNotice(t("供应商切换中"), t("上一次切换还没有完成,请稍后再试。"), "failed"); return; } @@ -2368,16 +2408,16 @@ export function App() { showNotice(t("供应商配置可能不正确"), validationError, "failed"); return; } - switchSettings = await snapshotActiveRelayFilesBeforeSwitch(switchSettings, previousActiveRelayId); - const selectedAfterSave = activeRelayProfile(switchSettings); - const command = relayProfileSwitchCommand(selectedAfterSave); + const selectedProfile = activeRelayProfile(switchSettings); + const command = relayProfileSwitchCommand(selectedProfile); logDiagnostic("switchRelayProfile.apply_start", { - targetRelayId: selectedAfterSave.id, - targetRelayName: selectedAfterSave.name, + targetRelayId: selectedProfile.id, + targetRelayName: selectedProfile.name, previousActiveRelayId, command, }); + relaySwitchingRef.current = true; setRelaySwitching(true); try { const result = await run(() => @@ -2387,19 +2427,11 @@ export function App() { ); if (!result) { logDiagnostic("switchRelayProfile.apply_no_result", { - targetRelayId: selectedAfterSave.id, + targetRelayId: selectedProfile.id, }); return; } const selectedSettings = normalizeSettings(result.settings); - setSettings({ - status: result.status, - message: result.message, - settings: selectedSettings, - settings_path: result.settingsPath, - user_scripts: result.user_scripts as UserScriptInventory, - }); - setSettingsForm(selectedSettings); setRelay({ status: result.status, message: result.message, @@ -2408,7 +2440,7 @@ export function App() { await refreshRelayFiles(true); if (!isSuccessStatus(result.status)) { logDiagnostic("switchRelayProfile.apply_failed", { - targetRelayId: selectedAfterSave.id, + targetRelayId: selectedProfile.id, status: result.status, message: result.message, activeRelayId: selectedSettings.activeRelayId, @@ -2416,6 +2448,14 @@ export function App() { showNotice(t("供应商切换"), result.message, result.status); return; } + setSettings({ + status: result.status, + message: result.message, + settings: selectedSettings, + settings_path: result.settingsPath, + user_scripts: result.user_scripts as UserScriptInventory, + }); + setSettingsForm(selectedSettings); const currentSelected = activeRelayProfile(selectedSettings); logDiagnostic("switchRelayProfile.ok", { targetRelayId: currentSelected.id, @@ -2423,30 +2463,11 @@ export function App() { status: result.status, }); } finally { + relaySwitchingRef.current = false; setRelaySwitching(false); } }; - const snapshotActiveRelayFilesBeforeSwitch = async ( - next: BackendSettings, - previousActiveRelayId: string, - ): Promise => { - const profileId = previousActiveRelayId.trim(); - if (!profileId) return next; - const result = await run(() => - call("backfill_relay_profile_from_live", { - request: { settings: next, profileId }, - }), - ); - if (!result) return next; - const normalized = normalizeSettings(result.settings); - if (!isSuccessStatus(result.status)) { - showNotice(t("供应商切换"), result.message, result.status); - return next; - } - return normalized; - }; - const copyText = async (text: string, message: string) => { try { await navigator.clipboard.writeText(text); @@ -2768,6 +2789,7 @@ export function App() { applyRelayInjection, applyPureApiInjection, clearRelayInjection, + reapplyActiveRelayProfile, saveRelayFile, upsertContextEntry, deleteContextEntry, @@ -3124,7 +3146,8 @@ type Actions = { applyRelayInjection: () => Promise; applyPureApiInjection: () => Promise; clearRelayInjection: () => Promise; - saveRelayFile: (kind: "config" | "auth", contents: string, silent?: boolean) => Promise; + reapplyActiveRelayProfile: (silent?: boolean) => Promise; + saveRelayFile: (kind: "config" | "auth", contents: string, silent?: boolean) => Promise; upsertContextEntry: ( settings: BackendSettings, kind: ContextKind, @@ -5901,8 +5924,10 @@ function RelayProfileDetail({ actions: Actions; }) { const [draft, setDraft] = useState(profile); + const [saving, setSaving] = useState(false); + const savingRef = useRef(false); const [modelWindowRows, setModelWindowRows] = useState( - modelWindowRowsFromProfile(profile.modelList, profile.modelWindows || "", profile.modelVlm), + modelWindowRowsFromProfile(profile.modelList, profile.modelWindows || "", profile.modelVlm, profile.modelAutoCompact), ); const [doctorResult, setDoctorResult] = useState(null); const [doctorOpen, setDoctorOpen] = useState(false); @@ -5927,55 +5952,73 @@ function RelayProfileDetail({ ? applyRelayProfilePatchToFiles(liveDraft, { apiKey: storedApiKey }) : liveDraft; setDraft(nextDraft); - setModelWindowRows(modelWindowRowsFromProfile(nextDraft.modelList, nextDraft.modelWindows || "", nextDraft.modelVlm)); - }, [profile.id, profile.modelList, profile.modelWindows, profileUsesLiveFiles, isActive, isNew, relayFiles?.configContents, relayFiles?.authContents]); + setModelWindowRows(modelWindowRowsFromProfile(nextDraft.modelList, nextDraft.modelWindows || "", nextDraft.modelVlm, nextDraft.modelAutoCompact)); + }, [profile.id, profile.modelList, profile.modelWindows, profile.modelAutoCompact, profile.modelMetadata, profile.modelVlm, profileUsesLiveFiles, isActive, isNew, relayFiles?.configContents, relayFiles?.authContents]); const validationSettings = relaySettingsWithDraft(form, profile.id, draft, isNew); const validationError = isAggregateRelayProfile(draft) ? aggregateRelayProfileValidation(draft) - : relayModelRoutesSettingsValidation(validationSettings); + : modelWindowRowsValidationMessage(modelWindowRowsValidationError(modelWindowRows)) + ?? relayModelRoutesSettingsValidation(validationSettings); const draftWithModelRows = () => { const serializedRows = serializeModelWindowRows(modelWindowRows); - return { ...draft, modelList: serializedRows.modelList, modelWindows: serializedRows.modelWindows, modelVlm: serializedRows.modelVlm }; + const validSlugs = serializedRows.modelList.split("\n").filter(Boolean); + return { + ...draft, + modelList: serializedRows.modelList, + modelWindows: serializedRows.modelWindows, + modelAutoCompact: serializedRows.modelAutoCompact, + modelMetadata: retainModelMetadataForSlugs(draft.modelMetadata, validSlugs), + modelVlm: serializedRows.modelVlm, + }; }; const saveDraft = async () => { - if (validationError) return; - const draftWithWindows = draftWithModelRows(); - const normalizedDraft = isAggregateRelayProfile(draftWithWindows) ? normalizeAggregateRelayProfile(draftWithWindows, form) : deriveRelayProfileFromFiles(draftWithWindows); - const next = normalizeSettings(isNew - ? addRelayProfile(form, normalizedDraft) - : updateRelayProfile(form, profile.id, normalizedDraft)); - const settingsValidationError = relayModelRoutesSettingsValidation(next); - if (settingsValidationError) return; - const activeLiveBaseUrl = codexBaseUrlFromConfig( - relayFiles?.configContents ?? profile.configContents, - ); - const requiresRestart = isActive && modelRouteSaveRequiresRestart( - normalizeSettings(form), - next, - activeLiveBaseUrl, - ); - if (requiresRestart && !window.confirm(t("首次启用单模型路由需要启动本地协议代理。保存后将立即重启 Codex,使路由安全生效。是否继续?"))) { - return; - } - const savedSettings = await onFormChange(next); - if (!savedSettings) return; - if (requiresRestart) { - const restarted = await actions.restart(true); - if (!restarted) return; - onSaved?.(); - return; - } - const savedProfile = savedSettings.relayProfiles.find((candidate) => candidate.id === normalizedDraft.id) - ?? normalizedDraft; - if (isActive && savedSettings.relayProfilesEnabled && relayProfileUsesLiveFiles(savedProfile)) { - await actions.saveRelayFile( - "config", - effectiveRelayConfigPreview(savedProfile, savedSettings, savedProfile), - true, + if (validationError || savingRef.current) return; + savingRef.current = true; + setSaving(true); + try { + const draftWithWindows = draftWithModelRows(); + const normalizedDraft = isAggregateRelayProfile(draftWithWindows) ? normalizeAggregateRelayProfile(draftWithWindows, form) : deriveRelayProfileFromFiles(draftWithWindows); + const next = normalizeSettings(isNew + ? addRelayProfile(form, normalizedDraft) + : updateRelayProfile(form, profile.id, normalizedDraft)); + const settingsValidationError = relayModelRoutesSettingsValidation(next); + if (settingsValidationError) return; + const activeLiveBaseUrl = codexBaseUrlFromConfig( + relayFiles?.configContents ?? profile.configContents, + ); + const requiresRestart = isActive && modelRouteSaveRequiresRestart( + normalizeSettings(form), + next, + activeLiveBaseUrl, ); - await actions.saveRelayFile("auth", savedProfile.authContents, true); + if (requiresRestart && !window.confirm(t("首次启用单模型路由需要启动本地协议代理。保存后将立即重启 Codex,使路由安全生效。是否继续?"))) { + return; + } + const savedSettings = await onFormChange(next); + if (!savedSettings) return; + if (requiresRestart) { + const restarted = await actions.restart(true); + if (!restarted) return; + onSaved?.(); + return; + } + const savedProfile = savedSettings.relayProfiles.find((candidate) => candidate.id === normalizedDraft.id) + ?? normalizedDraft; + if (isActive && savedSettings.relayProfilesEnabled && relayProfileUsesLiveFiles(savedProfile)) { + const configSaved = await actions.saveRelayFile( + "config", + effectiveRelayConfigPreview(savedProfile, savedSettings, savedProfile), + true, + ); + if (!configSaved) return; + const authSaved = await actions.saveRelayFile("auth", savedProfile.authContents, true); + if (!authSaved) return; + } + onSaved?.(); + } finally { + savingRef.current = false; + setSaving(false); } - onSaved?.(); }; const switchDraft = () => { if (isNew || !form.relayProfilesEnabled || validationError) return; @@ -6036,9 +6079,9 @@ function RelayProfileDetail({ {actions.relaySwitching ? t("切换中") : draft.id === form.activeRelayId ? t("使用中") : t("设为当前")} )} - @@ -6123,6 +6166,23 @@ function RelayProfileEditor({ setModelWindowRows: (value: ModelWindowRow[]) => void; }) { const [showAdvanced, setShowAdvanced] = useState(false); + const [metadataImportTarget, setMetadataImportTarget] = useState<{ + index: number; + slug: string; + originalWindow: string; + originalAutoCompact: string; + } | null>(null); + const [metadataImportDocument, setMetadataImportDocument] = useState(""); + const [metadataImportError, setMetadataImportError] = useState(""); + const [metadataImportPreview, setMetadataImportPreview] = useState(null); + const modelSlugOriginsRef = useRef(modelWindowRows.map((row) => row.model.trim())); + useEffect(() => { + modelSlugOriginsRef.current = modelWindowRows.map((row) => row.model.trim()); + }, [profile.id, profile.modelList]); + const importedModelMetadata = useMemo( + () => parseModelMetadataMap(profile.modelMetadata), + [profile.modelMetadata], + ); // 纯 Responses 模式(非聚合)下 VLM/Strip 不生效,禁用下拉 const vlmUnsupportedProtocol = profile.protocol === "responses" && !isAggregateRelayProfile(profile); if (isAggregateRelayProfile(profile)) { @@ -6141,6 +6201,9 @@ function RelayProfileEditor({ form.relayCommonConfigContents, profile.useCommonConfig, ); + const modelRowsError = showApiFields + ? modelWindowRowsValidationMessage(modelWindowRowsValidationError(modelWindowRows)) + : null; const sub2apiBaseUrl = profile.upstreamBaseUrl.trim() || profile.baseUrl.trim(); const canFetchSub2ApiRate = profile.sub2apiEnabled && Boolean(sub2apiBaseUrl && profile.apiKey.trim()); const updateDraft = (patch: Partial) => { @@ -6155,17 +6218,140 @@ function RelayProfileEditor({ modelRoutes: modelRoutes.map((route, routeIndex) => (routeIndex === index ? { ...route, ...patch } : route)), }); }; + const commitModelMetadata = (modelMetadata: string) => { + updateDraft({ modelMetadata }); + }; const updateModelWindowRow = (index: number, patch: Partial) => { setModelWindowRows( modelWindowRows.map((row, rowIndex) => (rowIndex === index ? { ...row, ...patch } : row)), ); }; + const updateModelSlug = (index: number, model: string) => { + updateModelWindowRow(index, { model }); + }; + const resolvePendingModelSlugRenames = ( + rows: ModelWindowRow[], + origins: string[], + modelMetadata: string, + ) => { + const slugs = rows.map((row) => row.model.trim()).filter(Boolean); + if (new Set(slugs).size !== slugs.length) return { modelMetadata, origins }; + const nextOrigins = rows.map((row, index) => row.model.trim() || origins[index] || ""); + return { + modelMetadata: remapModelMetadataSlugs( + modelMetadata, + rows.map((row, index) => ({ + previousSlug: origins[index] || "", + nextSlug: row.model, + })), + ), + origins: nextOrigins, + }; + }; + const commitModelSlug = (index: number) => { + const nextSlug = modelWindowRows[index]?.model.trim() ?? ""; + if (!nextSlug) return; + const resolved = resolvePendingModelSlugRenames( + modelWindowRows, + modelSlugOriginsRef.current, + profile.modelMetadata, + ); + if (resolved.modelMetadata !== profile.modelMetadata) commitModelMetadata(resolved.modelMetadata); + modelSlugOriginsRef.current = resolved.origins; + }; const removeModelWindowRow = (index: number) => { + const removedSlug = modelWindowRows[index]?.model.trim() + || modelSlugOriginsRef.current[index] + || ""; const nextRows = modelWindowRows.filter((_, rowIndex) => rowIndex !== index); - setModelWindowRows(nextRows.length ? nextRows : [{ model: "", window: "", imageHandling: "" }]); + const nextOrigins = modelSlugOriginsRef.current.filter((_, rowIndex) => rowIndex !== index); + const resolved = resolvePendingModelSlugRenames(nextRows, nextOrigins, profile.modelMetadata); + modelSlugOriginsRef.current = resolved.origins; + setModelWindowRows(nextRows.length ? nextRows : [{ model: "", window: "", autoCompact: "", imageHandling: "" }]); + const slugStillPresent = nextRows.some((row) => row.model.trim() === removedSlug) + || resolved.origins.includes(removedSlug); + const nextMetadata = removedSlug && !slugStillPresent + ? clearModelMetadataForSlug(resolved.modelMetadata, removedSlug) + : resolved.modelMetadata; + if (nextMetadata !== profile.modelMetadata) commitModelMetadata(nextMetadata); + if (metadataImportTarget?.index === index) { + closeModelMetadataImport(); + } else if (metadataImportTarget && metadataImportTarget.index > index) { + setMetadataImportTarget({ ...metadataImportTarget, index: metadataImportTarget.index - 1 }); + } }; const addModelWindowRows = (rows: ModelWindowRow[]) => { - setModelWindowRows(mergeModelWindowRows(modelWindowRows, rows)); + const merged = mergeModelWindowRows(modelWindowRows, rows); + modelSlugOriginsRef.current = merged.map((row) => { + const currentIndex = modelWindowRows.findIndex((current) => current.model.trim() === row.model.trim()); + return currentIndex >= 0 + ? modelSlugOriginsRef.current[currentIndex] ?? row.model.trim() + : row.model.trim(); + }); + setModelWindowRows(merged); + }; + const appendEmptyModelRow = () => { + modelSlugOriginsRef.current = [...modelSlugOriginsRef.current, ""]; + setModelWindowRows([...modelWindowRows, { model: "", window: "", autoCompact: "", imageHandling: "" }]); + }; + const beginModelMetadataImport = (index: number, slug: string) => { + const existingMetadata = importedModelMetadata[slug]; + const existingDocument = existingMetadata + ? serializeModelMetadataDocument( + slug, + existingMetadata, + modelWindowRows[index]?.window ?? "", + modelWindowRows[index]?.autoCompact ?? "", + ) + : ""; + const existingPreview = existingDocument + ? parseModelMetadataDocument(existingDocument, slug) + : null; + setMetadataImportTarget({ + index, + slug, + originalWindow: modelWindowRows[index]?.window ?? "", + originalAutoCompact: modelWindowRows[index]?.autoCompact ?? "", + }); + setMetadataImportDocument(existingDocument); + setMetadataImportError(""); + setMetadataImportPreview(existingPreview?.ok ? existingPreview.value : null); + }; + const closeModelMetadataImport = () => { + setMetadataImportTarget(null); + setMetadataImportDocument(""); + setMetadataImportError(""); + setMetadataImportPreview(null); + }; + const cancelModelMetadataImport = () => { + if (metadataImportTarget) { + updateModelWindowRow(metadataImportTarget.index, { + window: metadataImportTarget.originalWindow, + autoCompact: metadataImportTarget.originalAutoCompact, + }); + } + closeModelMetadataImport(); + }; + const applyModelMetadataImport = () => { + if (!metadataImportTarget || !metadataImportPreview) return; + commitModelMetadata(replaceModelMetadataForSlug( + profile.modelMetadata, + metadataImportPreview.slug, + metadataImportPreview.metadata, + )); + updateModelWindowRow(metadataImportTarget.index, { + window: metadataImportPreview.contextWindow ?? metadataImportTarget.originalWindow, + autoCompact: metadataImportPreview.autoCompactPercent ?? metadataImportTarget.originalAutoCompact, + }); + closeModelMetadataImport(); + }; + const clearImportedModelMetadata = () => { + if (!metadataImportTarget) return; + commitModelMetadata(clearModelMetadataForSlug( + profile.modelMetadata, + metadataImportTarget.slug, + )); + closeModelMetadataImport(); }; const fetchSub2ApiRate = async () => { const result = await actions.fetchSub2ApiBilling(deriveRelayProfileFromFiles(profile)); @@ -6268,19 +6454,23 @@ function RelayProfileEditor({ updateDraft({ contextWindow: event.currentTarget.value.replace(/[^\d]/g, "") })} placeholder={t("留空不改写,例如 200000")} /> +

+ {t("留空不写入全局值;填写后作为该供应商下所有模型的全局上下文上限,并受 catalog 中 max_context_window 约束。使用逐模型窗口时建议留空。")} +

updateDraft({ autoCompactLimit: event.currentTarget.value.replace(/[^\d]/g, "") })} placeholder={t("留空不改写,例如 160000")} /> +

+ {t("留空不写入全局值;填写后覆盖该供应商下所有模型的压缩触发 token 数。使用逐模型自动压缩时建议留空。")} +

) : null} @@ -6382,7 +6572,7 @@ function RelayProfileEditor({
- {t("模型名称")} - {t("上下文窗口")} - {t("图片处理方式")} + {t("模型名称")} + {t("上下文窗口")} + {t("自动压缩")} + {t("模型配置")}
- {modelWindowRows.map((row, index) => ( -
- updateModelWindowRow(index, { model: event.currentTarget.value })} - placeholder="deepseek/deepseek-v4-flash" - /> - updateModelWindowRow(index, { window: event.currentTarget.value })} - placeholder="1M" - /> - updateModelWindowRow(index, { imageHandling: value })} - options={[ - { value: "", label: t("纯文本模型请配置此项"), disabled: true }, - { value: "send-as-is", label: "send-as-is", title: t("原样发送图片") }, - { value: "strip", label: "strip images", title: t("为纯文本模型移除消息中的图片") }, - { value: "vlm", label: "VLM analysis", title: t("为纯文本模型配置图片分析路由") }, - ]} - title={vlmUnsupportedProtocol ? t("VLM 仅支持 Chat Completions 协议和聚合模式") : t("多模态模型(支持图片输入的模型)请保持 send-as-is。")} - /> - + {modelWindowRows.map((row, index) => { + const slug = row.model.trim(); + const importing = metadataImportTarget?.index === index && metadataImportTarget.slug === slug; + const imported = Boolean(importedModelMetadata[slug]); + return ( +
+
+ updateModelSlug(index, event.currentTarget.value)} + onBlur={() => commitModelSlug(index)} + placeholder="deepseek/deepseek-v4-flash" + /> + { + const window = event.currentTarget.value; + updateModelWindowRow(index, { window }); + if (!importing) return; + const synchronized = synchronizeModelMetadataDocumentLimitsPreview( + metadataImportDocument, + slug, + window, + metadataImportPreview?.autoCompactCalculationPercent ?? metadataImportPreview?.autoCompactPercent ?? row.autoCompact, + ); + if (synchronized === null) { + setMetadataImportPreview(null); + setMetadataImportError(t("上下文窗口与自动压缩值无效,无法同步模型配置。")); + return; + } + setMetadataImportDocument(synchronized.document); + setMetadataImportPreview(synchronized.preview); + setMetadataImportError(""); + }} + placeholder="1M" + /> + { + const autoCompact = event.currentTarget.value; + updateModelWindowRow(index, { autoCompact }); + if (!importing) return; + const synchronized = synchronizeModelMetadataDocumentLimitsPreview( + metadataImportDocument, + slug, + row.window, + autoCompact, + ); + if (synchronized === null) { + setMetadataImportPreview(null); + setMetadataImportError(t("上下文窗口与自动压缩值无效,无法同步模型配置。")); + return; + } + setMetadataImportDocument(synchronized.document); + setMetadataImportPreview(synchronized.preview); + setMetadataImportError(""); + }} + onBlur={(event) => { + const normalized = normalizeAutoCompactPercent(event.currentTarget.value); + if (normalized !== row.autoCompact) { + updateModelWindowRow(index, { autoCompact: normalized }); + } + }} + placeholder="90%" + /> + + +
+
+ updateModelWindowRow(index, { imageHandling: value })} + options={[ + { value: "", label: t("纯文本模型请配置此项"), disabled: true }, + { value: "send-as-is", label: "send-as-is", title: t("原样发送图片") }, + { value: "strip", label: "strip images", title: t("为纯文本模型移除消息中的图片") }, + { value: "vlm", label: "VLM analysis", title: t("为纯文本模型配置图片分析路由") }, + ]} + title={vlmUnsupportedProtocol ? t("VLM 仅支持 Chat Completions 协议和聚合模式") : ""} + /> + {t("多模态模型(支持图片输入的模型)请保持 send-as-is。")} +
+ {importing ? ( +
+