diff --git a/docs/agents/config-profile-change.md b/docs/agents/config-profile-change.md index fa4ec93..e382c11 100644 --- a/docs/agents/config-profile-change.md +++ b/docs/agents/config-profile-change.md @@ -44,6 +44,7 @@ - `config list` 标识所有 Profile 与当前激活项。 - `config show`、`auth status` 只输出本次最终选择的 `config` 和 `config_file`,不重复携带激活状态。 - `config ui` 从持久化元数据读取激活项,提供显式激活操作,并在删除激活项后刷新为 `default`。 +- `config ui` 保存时只替换 UI 管理的字段;Profile 中未展示但仍属于 `ConfigFile` 的合法字段必须保留,不能因打开并保存 UI 而丢失。 - 同步 E2E topic routes、Skill setup 和自动生成 reference。 ## 6. 最小测试矩阵 @@ -57,6 +58,7 @@ - 登录、退出、`config set` 分别覆盖“当前激活项”和“显式不存在名称成功后创建”。 - Console token 自动刷新不从其他 Profile 借用 AK/SK,也不把新 token 写入其他 Profile。 - `config list/show/use/ui`、`auth status` 和依赖默认模型的消费命令覆盖对应 E2E。 +- `config ui` 覆盖保存时保留未管理字段,并继续允许空值清除 UI 管理字段。 ## 7. 完成检查 diff --git a/packages/commands/src/commands/config/ui.ts b/packages/commands/src/commands/config/ui.ts index 4d206d2..2c10c68 100644 --- a/packages/commands/src/commands/config/ui.ts +++ b/packages/commands/src/commands/config/ui.ts @@ -7,6 +7,7 @@ import { BailianError, ExitCode, normalizeConfigName, + readConfigFile, writeConfigFile, deleteConfigProfile, type ConfigStore, @@ -72,6 +73,19 @@ function buildProfilePatch(data: Record): Record, + managedPatch: Record, +): Record { + const managedKeys = new Set(VALID_KEYS); + const merged: Record = {}; + for (const [key, value] of Object.entries(existing)) { + if (!managedKeys.has(key)) merged[key] = value; + } + return { ...merged, ...managedPatch }; +} + /** * Build the config-UI http server. Exported for tests. The handler enforces: * - Host header must be a loopback name (anti DNS-rebinding). @@ -158,8 +172,10 @@ export function createConfigUiServer(token: string, configStore: ConfigStore): h sendJson(res, 400, { error: errMessage(err) }); return; } - await writeConfigFile(cleaned, normalized); - sendJson(res, 200, { saved: cleaned }); + const existing = readConfigFile(normalized) as Record; + const saved = mergeUnmanagedProfileFields(existing, cleaned); + await writeConfigFile(saved, normalized); + sendJson(res, 200, { saved }); return; } diff --git a/packages/commands/tests/config-ui.test.ts b/packages/commands/tests/config-ui.test.ts index 28bcf6e..5a91559 100644 --- a/packages/commands/tests/config-ui.test.ts +++ b/packages/commands/tests/config-ui.test.ts @@ -128,6 +128,47 @@ test("POST /api/profile 写命名 profile(timeout 强制为 number),空串 }); }); +test("POST /api/profile 保留 UI 未管理字段,同时替换 UI 管理字段", async () => { + await withServer(async (port) => { + await writeConfigFile( + { + api_key: "sk-old", + output: "json", + console_site: "international", + console_region: "ap-southeast-1", + console_switch_agent: 42, + telemetry: false, + }, + "stage", + ); + + const save = await httpJson(port, "POST", `/api/profile?token=${TOKEN}`, { + body: { name: "stage", data: { api_key: "sk-new" } }, + }); + expect(save.status).toBe(200); + + const profile = readConfigFile("stage"); + expect(profile).toMatchObject({ + api_key: "sk-new", + console_site: "international", + console_region: "ap-southeast-1", + console_switch_agent: 42, + telemetry: false, + }); + expect(profile.output).toBeUndefined(); + + const rawConfig = JSON.parse(readFileSync(getConfigPath(), "utf8")); + expect(rawConfig.stage).toMatchObject({ + api_key: "sk-new", + console_site: "international", + console_region: "ap-southeast-1", + console_switch_agent: 42, + telemetry: false, + }); + expect(rawConfig.stage.output).toBeUndefined(); + }); +}); + test("New profile 立即保存空 Profile,其他配置读取可以看到", async () => { await withServer(async (port) => { const create = await httpJson(port, "POST", `/api/profile?token=${TOKEN}`, {