diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index f88639b..269ad95 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -7726,7 +7726,7 @@ async fn install_core_version_inner( .strip_prefix(&staging_dir) .map_err(|err| format!("计算内核二进制相对路径失败: {err}"))? .to_path_buf(); - preserve_core_runtime_files(&install_dir, &staging_dir)?; + migrate_core_config_for_update(&install_dir, &staging_dir)?; preserve_bundled_core_assets(&install_dir, &staging_dir)?; write_core_metadata( &staging_dir, @@ -7789,7 +7789,7 @@ fn install_bundled_core_inner( .strip_prefix(&staging_dir) .map_err(|error| format!("计算内置内核二进制路径失败: {error}"))? .to_path_buf(); - preserve_core_runtime_files(&install_dir, &staging_dir)?; + migrate_core_config_for_update(&install_dir, &staging_dir)?; preserve_bundled_core_assets(&install_dir, &staging_dir)?; preserve_selected_bundled_core_asset(archive_path, &staging_dir)?; write_core_metadata( @@ -10526,19 +10526,124 @@ fn merge_core_config_yaml( template: &str, current: Option<&str>, config: &GuiConfigFile, +) -> Result { + let merged = merge_core_config_fields(template, current)?; + apply_gui_managed_settings(&merged, config) +} + +fn merge_core_config_fields(template: &str, current: Option<&str>) -> Result { + let current_value = current + .map(|current| { + let current = serde_norway::from_str::(current) + .map_err(|err| format!("解析现有内核配置失败,为避免配置丢失已停止写入: {err}"))?; + if !current.is_mapping() { + return Err("现有内核配置根节点必须是 YAML 映射,已停止写入".to_string()); + } + Ok(current) + }) + .transpose()?; + merge_core_config_value(template, current_value) +} + +fn merge_core_config_fields_tolerant(template: &str, current: &str) -> Result { + let current = match serde_norway::from_str::(current) { + Ok(current) if current.is_mapping() => current, + _ => recover_parseable_top_level_yaml_fields(current), + }; + merge_core_config_value(template, Some(current)) +} + +fn merge_core_config_value( + template: &str, + current: Option, ) -> Result { let template_value = serde_norway::from_str::(template) .map_err(|err| format!("解析内核配置模板失败: {err}"))?; + if !template_value.is_mapping() { + return Err("内核配置模板根节点必须是 YAML 映射".to_string()); + } let mut merged = template_value.clone(); if let Some(current) = current { - if let Ok(current) = serde_norway::from_str::(current) { - merge_yaml_values(&mut merged, current); + merge_yaml_values(&mut merged, current); + } + + let rendered = render_yaml_value_changes(template, &template_value, &merged)?; + let rendered_value = serde_norway::from_str::(&rendered) + .map_err(|err| format!("验证迁移后的内核配置失败: {err}"))?; + if !rendered_value.is_mapping() { + return Err("迁移后的内核配置根节点必须是 YAML 映射".to_string()); + } + Ok(rendered) +} + +fn recover_parseable_top_level_yaml_fields(content: &str) -> serde_norway::Value { + let lines = yaml_line_ranges(content); + let boundaries = lines + .iter() + .copied() + .filter(|range| is_top_level_yaml_boundary(yaml_line_content(content, *range))) + .collect::>(); + let mut recovered = serde_norway::Mapping::new(); + + for (index, (start, _)) in boundaries.iter().copied().enumerate() { + let line = yaml_line_content(content, boundaries[index]); + if !is_top_level_yaml_mapping_field(line) { + continue; + } + let end = boundaries + .get(index + 1) + .map(|(next_start, _)| *next_start) + .unwrap_or(content.len()); + let Ok(value) = serde_norway::from_str::(&content[start..end]) else { + continue; + }; + let Some(mapping) = value.as_mapping() else { + continue; + }; + for (key, value) in mapping { + recovered.insert(key.clone(), value.clone()); } } - let merged = render_yaml_value_changes(template, &template_value, &merged)?; - apply_gui_managed_settings(&merged, config) + serde_norway::Value::Mapping(recovered) +} + +fn is_top_level_yaml_boundary(line: &str) -> bool { + if line.is_empty() + || line.chars().next().is_some_and(char::is_whitespace) + || line.starts_with('#') + || is_indentationless_yaml_sequence_item(line) + { + return false; + } + !matches!(line.trim(), "---" | "...") && !line.starts_with('%') +} + +fn is_top_level_yaml_mapping_field(line: &str) -> bool { + let mut single_quoted = false; + let mut double_quoted = false; + let mut escaped = false; + for (index, character) in line.char_indices() { + if escaped { + escaped = false; + continue; + } + if double_quoted && character == '\\' { + escaped = true; + continue; + } + match character { + '\'' if !double_quoted => single_quoted = !single_quoted, + '"' if !single_quoted => double_quoted = !double_quoted, + ':' if !single_quoted && !double_quoted => { + let rest = &line[index + character.len_utf8()..]; + return rest.is_empty() || rest.chars().next().is_some_and(char::is_whitespace); + } + _ => {} + } + } + false } fn patch_core_network_yaml( @@ -11628,15 +11733,46 @@ fn preserve_selected_bundled_core_asset( Ok(()) } -fn preserve_core_runtime_files(source_dir: &Path, target_dir: &Path) -> Result<(), String> { +fn migrate_core_config_for_update(source_dir: &Path, target_dir: &Path) -> Result<(), String> { if !source_dir.is_dir() { return Ok(()); } - let source = source_dir.join(CORE_CONFIG_FILE); - if source.is_file() { - fs::copy(&source, target_dir.join(CORE_CONFIG_FILE)) - .map_err(|error| format!("保留内核配置文件失败: {error}"))?; + let old_config_path = source_dir.join(CORE_CONFIG_FILE); + if !old_config_path.is_file() { + return Ok(()); } + + let template_path = target_dir.join(CORE_EXAMPLE_CONFIG_FILE); + if !template_path.is_file() { + return Err(format!( + "新版内核缺少配置模板,已取消更新: {}", + path_to_string(&template_path) + )); + } + let old_config = match fs::read(&old_config_path) { + Ok(content) => String::from_utf8_lossy(&content).into_owned(), + Err(error) => { + eprintln!( + "读取旧版内核配置失败,将使用新版默认配置继续更新 {}: {error}", + path_to_string(&old_config_path) + ); + String::new() + } + }; + let template = fs::read_to_string(&template_path).map_err(|error| { + format!( + "读取新版内核配置模板失败 {}: {error}", + path_to_string(&template_path) + ) + })?; + let migrated = merge_core_config_fields_tolerant(&template, &old_config)?; + let config_path = target_dir.join(CORE_CONFIG_FILE); + fs::write(&config_path, migrated).map_err(|error| { + format!( + "写入迁移后的新版内核配置失败 {}: {error}", + path_to_string(&config_path) + ) + })?; Ok(()) } @@ -16372,18 +16508,67 @@ custom_option = "keep-original" } #[test] - fn replacing_a_core_preserves_the_existing_configuration_bytes() { - let root = agent_test_home("core-config-preserve"); + fn replacing_a_core_migrates_old_fields_into_the_new_template() { + let root = agent_test_home("core-config-migrate"); let source = root.join("source"); let target = root.join("target"); fs::create_dir_all(&source).unwrap(); fs::create_dir_all(&target).unwrap(); - let original = b"host: 127.0.0.1\n# keep this comment\ndebug: true\n"; - fs::write(source.join(CORE_CONFIG_FILE), original).unwrap(); + let old_config = "# Old comment\nhost: 127.0.0.1\nport: 9527\nnested:\n keep: old\n old-only: retained\nlist:\n - old-a\n - old-b\nextra: true\n"; + let new_template = "# New template\nhost: \"\"\nport: 8317\nnested:\n keep: new-default\n added: new-field\nlist:\n - new-default\nnew-option: true\n"; + fs::write(source.join(CORE_CONFIG_FILE), old_config).unwrap(); + fs::write(target.join(CORE_EXAMPLE_CONFIG_FILE), new_template).unwrap(); - preserve_core_runtime_files(&source, &target).unwrap(); + migrate_core_config_for_update(&source, &target).unwrap(); - assert_eq!(fs::read(target.join(CORE_CONFIG_FILE)).unwrap(), original); + let migrated = fs::read_to_string(target.join(CORE_CONFIG_FILE)).unwrap(); + let document = serde_norway::from_str::(&migrated).unwrap(); + assert!(migrated.contains("# New template")); + assert_eq!(document["host"], "127.0.0.1"); + assert_eq!(document["port"], 9527); + assert_eq!(document["nested"]["keep"], "old"); + assert_eq!(document["nested"]["added"], "new-field"); + assert_eq!(document["nested"]["old-only"], "retained"); + assert_eq!(document["list"][0], "old-a"); + assert_eq!(document["list"][1], "old-b"); + assert_eq!(document["new-option"], true); + assert_eq!(document["extra"], true); + assert_eq!( + fs::read_to_string(target.join(CORE_EXAMPLE_CONFIG_FILE)).unwrap(), + new_template + ); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn replacing_a_core_discards_invalid_fields_and_continues_migration() { + let root = agent_test_home("core-config-migrate-invalid"); + let source = root.join("source"); + let target = root.join("target"); + fs::create_dir_all(&source).unwrap(); + fs::create_dir_all(&target).unwrap(); + fs::write( + source.join(CORE_CONFIG_FILE), + "host: 127.0.0.1\nbroken: [invalid\nport: 9527\napi-keys:\n- old-a\n- old-b\n", + ) + .unwrap(); + fs::write( + target.join(CORE_EXAMPLE_CONFIG_FILE), + "host: \"\"\nbroken: new-default\nport: 8317\napi-keys:\n - new-default\nnew-option: true\n", + ) + .unwrap(); + fs::write(target.join(CORE_CONFIG_FILE), "staged: untouched\n").unwrap(); + + migrate_core_config_for_update(&source, &target).unwrap(); + + let migrated = fs::read_to_string(target.join(CORE_CONFIG_FILE)).unwrap(); + let document = serde_norway::from_str::(&migrated).unwrap(); + assert_eq!(document["host"], "127.0.0.1"); + assert_eq!(document["broken"], "new-default"); + assert_eq!(document["port"], 9527); + assert_eq!(document["api-keys"][0], "old-a"); + assert_eq!(document["api-keys"][1], "old-b"); + assert_eq!(document["new-option"], true); fs::remove_dir_all(root).unwrap(); } diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index f528ce5..9aefe1e 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -522,7 +522,7 @@ export const jaOverrides = { 'apiAccess.error.headerMissingColon': 'カスタムヘッダーの {number} 行目にコロンがありません', 'apiAccess.error.headerInvalid': 'カスタムヘッダーの {number} 行目の形式が無効です', 'apiAccess.error.partialLoad': '一部の接続を読み込めませんでした:{errors}', - 'apiAccess.error.requiredAll': '名前、Base URL、キーを入力してください', + 'apiAccess.error.requiredAll': 'メモ、Base URL、キーを入力してください', 'apiAccess.error.requiredBaseKey': 'Base URL と API キーを入力してください', 'apiAccess.error.requiredKey': 'API キーを入力してください', 'apiAccess.error.baseRequired': '{provider} 接続には Base URL が必要です', @@ -532,7 +532,7 @@ export const jaOverrides = { 'apiAccess.error.remarkInvalid': 'メモは 80 文字以内で、改行や制御文字を含めないでください', 'apiAccess.notice.updated': '接続を更新しました', 'apiAccess.notice.added': '接続を追加しました', - 'apiAccess.deleteConfirm': '「{name}」を削除しますか?', + 'apiAccess.deleteConfirm': '「{remark}」を削除しますか?', 'apiAccess.notice.deleted': '接続を削除しました', 'apiAccess.notice.enabled': '接続を有効にしました', 'apiAccess.notice.disabled': '接続を無効にしました', @@ -540,7 +540,7 @@ export const jaOverrides = { 'apiAccess.count': '接続 {count} 件', 'apiAccess.add': '追加', 'apiAccess.matches': '一致する接続 {count} 件', - 'apiAccess.search': '名前、キー、アドレスを検索', + 'apiAccess.search': 'メモ、キー、アドレスを検索', 'apiAccess.loading': '設定を読み込んでいます', 'apiAccess.empty.filtered': '一致する接続がありません', 'apiAccess.empty.none': '接続設定がありません', @@ -557,7 +557,7 @@ export const jaOverrides = { 'apiAccess.disable': '接続を無効化', 'apiAccess.status.disabled': '無効', 'apiAccess.status.enabled': '有効', - 'apiAccess.toggleAria': '{name} 接続の{action}スイッチ', + 'apiAccess.toggleAria': '{remark} 接続の{action}スイッチ', 'common.edit': '編集', 'apiAccess.error.enterBaseUrl': '先に Base URL を入力してください', 'apiAccess.error.noAvailableModels': '利用可能なモデルが見つかりません', @@ -571,7 +571,6 @@ export const jaOverrides = { 'apiAccess.models.allHint': '現在、上流のすべてのモデルを公開しています', 'apiAccess.dialog.edit': 'API 接続を編集', 'apiAccess.dialog.add': 'API 接続を追加', - 'apiAccess.field.name': '名前', 'apiAccess.field.keysMany': 'API キー(1 行に 1 件)', 'apiAccess.field.key': 'API キー', 'apiAccess.field.remark': 'メモ', diff --git a/src/i18n/resources.ts b/src/i18n/resources.ts index e510e89..a67ce26 100644 --- a/src/i18n/resources.ts +++ b/src/i18n/resources.ts @@ -525,7 +525,7 @@ export const zhCN = { 'apiAccess.error.headerMissingColon': '自定义请求头第 {number} 行缺少冒号', 'apiAccess.error.headerInvalid': '自定义请求头第 {number} 行格式无效', 'apiAccess.error.partialLoad': '部分接入读取失败:{errors}', - 'apiAccess.error.requiredAll': '名称、Base URL 和密钥不能为空', + 'apiAccess.error.requiredAll': '备注、Base URL 和密钥不能为空', 'apiAccess.error.requiredBaseKey': 'Base URL 和 API 密钥不能为空', 'apiAccess.error.requiredKey': 'API 密钥不能为空', 'apiAccess.error.baseRequired': '{provider} 接入必须填写 Base URL', @@ -535,7 +535,7 @@ export const zhCN = { 'apiAccess.error.remarkInvalid': '备注不能超过 80 个字符,且不能包含换行或控制字符', 'apiAccess.notice.updated': '接入已更新', 'apiAccess.notice.added': '接入已新增', - 'apiAccess.deleteConfirm': '确定删除“{name}”吗?', + 'apiAccess.deleteConfirm': '确定删除“{remark}”吗?', 'apiAccess.notice.deleted': '接入已删除', 'apiAccess.notice.enabled': '接入已启用', 'apiAccess.notice.disabled': '接入已停用', @@ -543,7 +543,7 @@ export const zhCN = { 'apiAccess.count': '{count} 个接入', 'apiAccess.add': '新增', 'apiAccess.matches': '{count} 个匹配接入', - 'apiAccess.search': '搜索名称、密钥或地址', + 'apiAccess.search': '搜索备注、密钥或地址', 'apiAccess.loading': '读取配置中', 'apiAccess.empty.filtered': '没有匹配的接入', 'apiAccess.empty.none': '暂无接入配置', @@ -560,7 +560,7 @@ export const zhCN = { 'apiAccess.disable': '停用接入', 'apiAccess.status.disabled': '已停用', 'apiAccess.status.enabled': '已启用', - 'apiAccess.toggleAria': '{name} 接入{action}开关', + 'apiAccess.toggleAria': '{remark} 接入{action}开关', 'common.edit': '编辑', 'apiAccess.error.enterBaseUrl': '请先填写 Base URL', 'apiAccess.error.noAvailableModels': '未发现可用模型', @@ -574,7 +574,6 @@ export const zhCN = { 'apiAccess.models.allHint': '当前开放上游全部模型', 'apiAccess.dialog.edit': '编辑 API 接入', 'apiAccess.dialog.add': '新增 API 接入', - 'apiAccess.field.name': '名称', 'apiAccess.field.keysMany': 'API 密钥(每行一个)', 'apiAccess.field.key': 'API 密钥', 'apiAccess.field.remark': '备注', @@ -1341,7 +1340,7 @@ export const en: Record = { 'apiAccess.error.headerMissingColon': 'Custom header line {number} is missing a colon', 'apiAccess.error.headerInvalid': 'Custom header line {number} is invalid', 'apiAccess.error.partialLoad': 'Some connections could not be loaded: {errors}', - 'apiAccess.error.requiredAll': 'Name, Base URL, and key are required', + 'apiAccess.error.requiredAll': 'Remark, Base URL, and key are required', 'apiAccess.error.requiredBaseKey': 'Base URL and API key are required', 'apiAccess.error.requiredKey': 'API key is required', 'apiAccess.error.baseRequired': 'A Base URL is required for {provider}', @@ -1351,7 +1350,7 @@ export const en: Record = { 'apiAccess.error.remarkInvalid': 'The remark cannot exceed 80 characters or contain line breaks or control characters', 'apiAccess.notice.updated': 'Connection updated', 'apiAccess.notice.added': 'Connection added', - 'apiAccess.deleteConfirm': 'Delete “{name}”?', + 'apiAccess.deleteConfirm': 'Delete “{remark}”?', 'apiAccess.notice.deleted': 'Connection deleted', 'apiAccess.notice.enabled': 'Connection enabled', 'apiAccess.notice.disabled': 'Connection disabled', @@ -1359,7 +1358,7 @@ export const en: Record = { 'apiAccess.count': '{count} connections', 'apiAccess.add': 'Add', 'apiAccess.matches': '{count} matching connections', - 'apiAccess.search': 'Search names, keys, or addresses', + 'apiAccess.search': 'Search remarks, keys, or addresses', 'apiAccess.loading': 'Loading configuration', 'apiAccess.empty.filtered': 'No matching connections', 'apiAccess.empty.none': 'No API connections', @@ -1376,7 +1375,7 @@ export const en: Record = { 'apiAccess.disable': 'Disable connection', 'apiAccess.status.disabled': 'Disabled', 'apiAccess.status.enabled': 'Enabled', - 'apiAccess.toggleAria': '{name} connection {action} switch', + 'apiAccess.toggleAria': '{remark} connection {action} switch', 'common.edit': 'Edit', 'apiAccess.error.enterBaseUrl': 'Enter a Base URL first', 'apiAccess.error.noAvailableModels': 'No available models found', @@ -1390,7 +1389,6 @@ export const en: Record = { 'apiAccess.models.allHint': 'All upstream models are currently open', 'apiAccess.dialog.edit': 'Edit API Connection', 'apiAccess.dialog.add': 'Add API Connection', - 'apiAccess.field.name': 'Name', 'apiAccess.field.keysMany': 'API Keys (one per line)', 'apiAccess.field.key': 'API Key', 'apiAccess.field.remark': 'Remark', diff --git a/src/pages/ApiAccessPage.tsx b/src/pages/ApiAccessPage.tsx index f0b532c..835d154 100644 --- a/src/pages/ApiAccessPage.tsx +++ b/src/pages/ApiAccessPage.tsx @@ -323,7 +323,7 @@ const thinkingLevelsFromModels = (models: ModelOption[]): string[] => { const draftFromRow = (row: ProviderRow): ProviderDraft => ({ name: row.name, apiKey: definitionFor(row.section).openAi ? row.apiKeys.join('\n') : row.apiKey, - remark: row.remark, + remark: row.remark || (definitionFor(row.section).openAi ? row.name : ''), baseUrl: row.baseUrl, priority: row.priority === null ? '' : String(row.priority), models: row.models, @@ -383,11 +383,19 @@ export const createProviderDraft = (category: ProviderCategory): ProviderDraft = return { ...draft, name: 'DeepSeek', + remark: 'DeepSeek', baseUrl: DEEPSEEK_BASE_URL, thinkingLevels: [...DEEPSEEK_THINKING_LEVELS], }; }; +export const applyProviderRemarkIdentity = ( + category: ProviderCategory, + draft: ProviderDraft, +): ProviderDraft => definitionFor(category).openAi + ? { ...draft, name: draft.remark.trim() } + : draft; + export const applyProviderPreset = ( category: ProviderCategory, draft: ProviderDraft, @@ -696,7 +704,7 @@ export function ApiAccessPage() { .filter((row) => { const query = filter.trim().toLowerCase(); if (!query) return true; - return [row.name, row.remark, row.apiKey, row.baseUrl, row.models.map((model) => model.name).join(' ')] + return [row.remark || row.name, row.apiKey, row.baseUrl, row.models.map((model) => model.name).join(' ')] .join(' ') .toLowerCase() .includes(query); @@ -721,7 +729,10 @@ export function ApiAccessPage() { const saveProvider = async (nextDraft: ProviderDraft): Promise => { const definition = activeDefinition; - const preparedDraft = applyProviderPreset(activeCategory, nextDraft); + const preparedDraft = applyProviderRemarkIdentity( + activeCategory, + applyProviderPreset(activeCategory, nextDraft), + ); const baseUrlRequired = definition.openAi || definition.section === 'codex-api-key'; const parsedApiKeys = preparedDraft.apiKey .split(/\r?\n/) @@ -729,7 +740,7 @@ export function ApiAccessPage() { .filter(Boolean); if ( parsedApiKeys.length === 0 - || (definition.openAi && !preparedDraft.name.trim()) + || (definition.openAi && !preparedDraft.remark.trim()) || (baseUrlRequired && !preparedDraft.baseUrl.trim()) ) { setError( @@ -827,7 +838,7 @@ export function ApiAccessPage() { }; const deleteRow = async (row: ProviderRow) => { - if (!window.confirm(t('apiAccess.deleteConfirm', { name: row.name }))) return; + if (!window.confirm(t('apiAccess.deleteConfirm', { remark: row.remark || row.name }))) return; setBusy(true); setError(''); try { @@ -992,7 +1003,7 @@ export function ApiAccessPage() { checked={!row.disabled} onChange={() => void toggleProvider(row)} disabled={busy} - aria-label={t('apiAccess.toggleAria', { name: row.name, action: row.disabled ? t('common.enable') : t('common.disable') })} + aria-label={t('apiAccess.toggleAria', { remark: row.remark || row.name, action: row.disabled ? t('common.enable') : t('common.disable') })} /> @@ -1077,7 +1088,7 @@ function ApiProviderDialog({ && visibleModelOptions.every((model) => selectedModelNames.has(model.name.toLowerCase())); const updateTextField = ( - field: 'name' | 'apiKey' | 'remark' | 'baseUrl' | 'priority' | 'prefix' | 'headersText' | 'excludedModelsText' | 'testModel' | 'cloakMode' | 'cloakSensitiveWordsText', + field: 'apiKey' | 'remark' | 'baseUrl' | 'priority' | 'prefix' | 'headersText' | 'excludedModelsText' | 'testModel' | 'cloakMode' | 'cloakSensitiveWordsText', value: string, ) => { setDraft((current) => ({ ...current, [field]: value })); @@ -1239,18 +1250,15 @@ function ApiProviderDialog({ - {definition.openAi ? ( - - ) : null} +