From 267143d4917280da00f9c190f3ccd1f56ef19228 Mon Sep 17 00:00:00 2001 From: xiaomakuaiz Date: Sun, 2 Aug 2026 19:06:26 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(desktop):=20=E5=90=8C=E6=AD=A5=E7=9A=84?= =?UTF-8?q?=E4=BC=9A=E5=91=98=E6=A8=A1=E5=9E=8B=E4=B8=8D=E5=85=A8(?= =?UTF-8?q?=E4=B8=89=E6=9D=A1=E9=9D=99=E9=BB=98=E4=B8=A2=E5=BC=83=E8=B7=AF?= =?UTF-8?q?=E5=BE=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 服务端有、同步过来没有,查下来是三处各自静默丢条目: 1. 归属缺失即丢。后端 Model.Owner 是 omitempty,且只在模型挂着 user 边时 才填(backend domain/model.go Model::From),而会员内置模型走内部 hook, 那一层不保证带上。壳这边第一道判定就要求 owner.type ∈ {public,private, team},否则 continue——无 note、无计数,凭空消失。改为 owner 整个缺席按 public 收(UI 的 memberCategory 早就把无 owner 归「付费」,两侧口径对齐), 只有认不出的归属类型才跳过且出 note。 2. 同批重名即丢。会员条目的名字取自 remark(后台人起的备注),重复很正常, 撞了就丢第二条。现在原样收下,靠服务端配置 id 区分(见下)。 3. 跨来源重名即丢。名字是引擎寻址键(settings.models 以名字为键), replaceSourceGroup 跨组撞名先到先得,百智云那路往往先落地,同名的会员 条目整条跳过。而两边目录撞名是常态——同一批底座模型,remark 起的名字 往往一模一样。 后两条同一个解法:同步条目的落盘名带后缀,展示层一律剥掉。 百智云: <模型id>@baizhi MonkeyCode: <备注名>@monkeycode#<服务端配置id> 恒定加、不按"撞了才加":后缀若取决于同批/对面组此刻有没有同名条目,名字就会 随目录增删漂移,而会话与记忆都按名引用,漂移即失联。id 由条目自身决定,既 唯一又稳定。百智云条目的 name 本就是模型 id,组内天然唯一,不再缀 id。 展示面全部走 stripSourceSuffix:设置页行名与名称输入框(剥后缀显示、写回 自动补回,不变量不靠用户维持)、选择器条目与触发器、两处 tooltip、四条报错 文案、搜索匹配。 存量引用的兜底(升级后第一次同步会重命名所有同步条目):壳侧 model_id_of 精确没中时按剥后缀比对——漏了会员条目的 `#id` 尾巴就等于所有绑会员模型的 老会话开不起来;UI 侧默认项定位、modelMenuList、modelDisplayByName、会话 模型解析、lastTaskModel 记忆同款。两侧剥法口径一致(`#` 之后放行,id 的 字符集不归壳管)。 顺带:模型清单显式要 limit=200(服务端缺省 100),还有下一页出 note; 归属不明/占位残缺各出一条计数,同步条数对不上时消息里说得清差额去哪了 (服务端标隐藏的静默跳过——那是刻意不给用户看的,报数只是噪音)。 已知:两条同名的会员模型在界面上完全一样(展示名与药丸全同),底层模型串 不同但未外显;在服务端改备注名会换掉 alias,老会话靠上面那条宽松匹配兜。 --- desktop/src/baizhi/monkeycode.rs | 87 +++++++++++++++++++++++------ desktop/src/baizhi/tests.rs | 30 +++++----- desktop/src/driver/session.rs | 43 ++++++++++++++ desktop/ui/src/App.tsx | 8 ++- desktop/ui/src/appView.ts | 4 +- desktop/ui/src/chat.tsx | 5 +- desktop/ui/src/modelMenu.test.ts | 31 ++++++++++ desktop/ui/src/modelMenu.ts | 33 +++++++++-- desktop/ui/src/modelPicker.test.tsx | 18 +++++- desktop/ui/src/newtask.tsx | 9 ++- desktop/ui/src/settings.test.ts | 20 +++++++ desktop/ui/src/settings.tsx | 34 ++++++++--- desktop/ui/src/settingsConfig.ts | 46 ++++++++++++++- desktop/ui/src/types.ts | 3 + 14 files changed, 318 insertions(+), 53 deletions(-) diff --git a/desktop/src/baizhi/monkeycode.rs b/desktop/src/baizhi/monkeycode.rs index f5c045692..6a593657b 100644 --- a/desktop/src/baizhi/monkeycode.rs +++ b/desktop/src/baizhi/monkeycode.rs @@ -751,18 +751,32 @@ fn local_model_entries(items: &[Value], plan: &str) -> (Vec, Vec) let mut keyed: Vec<(u8, i64, String, Value)> = Vec::new(); let mut notes = Vec::new(); let mut seen = std::collections::HashSet::new(); + let mut dup_names = 0usize; // 同批重名:不再丢弃,只提示(落盘名靠 id 区分) + // 跳过计数:同步条数对不上时,消息里要能说清差额去哪了(逐条列会太长) + let (mut alien_owner, mut placeholder) = (0usize, 0usize); for it in items { let s = |k: &str| it.get(k).and_then(Value::as_str).unwrap_or("").trim().to_string(); let owner = match it.pointer("/owner/type").and_then(Value::as_str) { Some(o @ ("public" | "private" | "team")) => o, - _ => continue, + // owner 整个缺席按 public 收:服务端该字段是 omitempty,且只在模型 + // 挂着 user 边时才填(backend domain/model.go Model::From),会员内置 + // 模型来自内部 hook,这一层不保证带上——丢掉它们就是"同步的模型不全"。 + // UI 早就容缺(memberCategory 把无 owner 归「付费」),两侧口径就此对齐 + None if it.get("owner").map_or(true, Value::is_null) => "public", + // 认不出的归属类型(将来新增的第四种)才跳过,且要出 note + _ => { + alien_owner += 1; + continue; + } }; + // 服务端标了隐藏就是刻意不给用户看的,静默跳过——报个数只是噪音 if it.get("is_hidden").and_then(Value::as_bool).unwrap_or(false) { continue; } let (id, model) = (s("id"), s("model")); if id.is_empty() || model.is_empty() || is_builtin_placeholder(&model) { - continue; // 占位/残缺条目不是可调用模型,静默跳过 + placeholder += 1; // 占位/残缺条目不是可调用模型 + continue; } // 超档 ≠ 排除:展示但禁选(静默,不出 note——菜单灰态即是外显) let locked = !plan_allows_model(&model, plan); @@ -771,13 +785,13 @@ fn local_model_entries(items: &[Value], plan: &str) -> (Vec, Vec) notes.push(format!("模型 {model} 使用了本版本不支持的协议「{itype}」,已跳过")); continue; }; + // remark 是后台人起的备注,同批重复很正常。以前撞了就丢第二条(表现 + // 为"同步的模型不全");现在原样收下,由 UI 侧 syncedName 用这里带出去 + // 的服务端配置 id 拼出唯一落盘名(展示层剥掉),不再有条目因重名蒸发 let name = { let n = s("remark"); if n.is_empty() { model.clone() } else { n } }; - if !seen.insert(name.clone()) { - notes.push(format!("条目 {name} 与同批条目重名,已跳过")); - continue; - } let mut entry = json!({ "name": name.clone(), + "id": id.clone(), "provider": provider, "base_url": "", "api_key": "", @@ -814,9 +828,21 @@ fn local_model_entries(items: &[Value], plan: &str) -> (Vec, Vec) if owner != "public" && it.get("thinking_enabled").and_then(Value::as_bool) == Some(false) { entry["think"] = json!("off"); } + if !seen.insert(name.clone()) { + dup_names += 1; + } let weight = it.get("weight").and_then(Value::as_i64).unwrap_or(0); keyed.push((member_section_rank(&model, owner), weight, name, entry)); } + if dup_names > 0 { + notes.push(format!("{dup_names} 条模型与同批条目重名,已按服务端配置区分收录")); + } + if alien_owner > 0 { + notes.push(format!("{alien_owner} 条模型的归属类型无法识别,已跳过")); + } + if placeholder > 0 { + notes.push(format!("{placeholder} 条档位占位/字段残缺的条目未同步")); + } keyed.sort_by(|a, b| a.0.cmp(&b.0).then(b.1.cmp(&a.1)).then(a.2.cmp(&b.2))); (keyed.into_iter().map(|(_, _, _, e)| e).collect(), notes) } @@ -826,13 +852,18 @@ fn local_model_entries(items: &[Value], plan: &str) -> (Vec, Vec) /// note,重新同步可恢复)。返回 {models, notes}(与 baizhi_sync 返回形状 /// 平行;不碰 config.json)。 pub async fn mc_member_models_sync(svc: &Service) -> BzResult { - let out = mc_call(svc, reqwest::Method::GET, "/api/v1/users/models", None).await?; + // 服务端是游标分页,limit 缺省只给 100(backend handler List);显式要 200, + // 还有下一页就出 note——宁可说清楚,也不让用户对着少掉的条目猜 + let out = mc_call(svc, reqwest::Method::GET, "/api/v1/users/models?limit=200", None).await?; let items = out .get("models") .and_then(Value::as_array) .cloned() .ok_or_else(|| other("模型列表响应格式异常"))?; let mut notes = Vec::new(); + if out.pointer("/page/has_next_page").and_then(Value::as_bool) == Some(true) { + notes.push("服务端模型超过 200 条,本次只同步了前 200 条".to_string()); + } let plan = match mc_call(svc, reqwest::Method::GET, "/api/v1/users/subscription", None).await { Ok(v) => v.get("plan").and_then(Value::as_str).unwrap_or("").to_string(), Err(e) => { @@ -1133,9 +1164,10 @@ mod local_models_tests { // 隐藏条目 → 静默跳过 json!({ "id": "cfg-6", "remark": "隐藏", "model": "hidden-model", "interface_type": "anthropic", "owner": pub_owner(), "is_hidden": true }), - // 与首条重名 → 跳过 + note + // 与首条重名 → 照常收录(落盘名由 UI 用 id 区分),只出提示 json!({ "id": "cfg-7", "remark": "旗舰模型", "model": "m-dup", "interface_type": "anthropic", "owner": pub_owner() }), - // 团队条目 → 收录;归属缺失/未知 → 跳过 + // 团队条目 → 收录;owner 整个缺席 → 按 public 收录(服务端 omitempty, + // 内部 hook 的会员内置模型不保证带);认不出的归属类型 → 跳过 + note json!({ "id": "cfg-8", "remark": "团队", "model": "team-model", "interface_type": "anthropic", "owner": { "type": "team", "name": "翼龙组" } }), json!({ "id": "cfg-9", "remark": "无主", "model": "orphan", "interface_type": "anthropic" }), @@ -1143,7 +1175,15 @@ mod local_models_tests { "owner": { "type": "galaxy" } }), ]; let (models, notes) = local_model_entries(&items, "ultra"); - assert_eq!(models.len(), 4, "{models:?}"); + assert_eq!(models.len(), 6, "同批重名不再丢条目: {models:?}"); + // 服务端配置 id 随条目带出:UI 侧靠它拼唯一落盘名 + assert_eq!(models[0].get("id").and_then(Value::as_str), Some("cfg-1")); + let dups: Vec<_> = models + .iter() + .filter(|m| m.get("name").and_then(Value::as_str) == Some("旗舰模型")) + .filter_map(|m| m.get("id").and_then(Value::as_str)) + .collect(); + assert_eq!(dups, vec!["cfg-1", "cfg-7"], "重名两条都在,靠 id 区分"); let m0 = &models[0]; assert_eq!(m0.get("name").and_then(Value::as_str), Some("旗舰模型")); assert_eq!(m0.get("provider").and_then(Value::as_str), Some("anthropic")); @@ -1156,7 +1196,13 @@ mod local_models_tests { assert_eq!(m0.get("context_window").and_then(Value::as_i64), Some(200_000)); assert_eq!(m0.get("max_output").and_then(Value::as_i64), Some(16_384)); assert_eq!(m0.get("vision").and_then(Value::as_bool), Some(true)); - let m1 = &models[1]; + let by = |name: &str| { + models + .iter() + .find(|m| m.get("name").and_then(Value::as_str) == Some(name)) + .unwrap_or_else(|| panic!("条目 {name} 应被收录: {models:?}")) + }; + let m1 = by("mc-gpt"); assert_eq!(m1.get("name").and_then(Value::as_str), Some("mc-gpt")); assert_eq!(m1.get("model").and_then(Value::as_str), Some("mc-gpt"), "remark 空时 name==model,与百智云同构"); assert_eq!(m1.get("provider").and_then(Value::as_str), Some("openai")); @@ -1166,18 +1212,27 @@ mod local_models_tests { // 对 private/team(用户自配)标 false 时须写 off assert!(m1.get("think").is_none(), "public 标 false 也不压 off"); assert!(m0.get("think").is_none(), "未标注的模型跟随产品默认档"); - let m2 = &models[2]; + // 无主条目按 public 收:排在付费节(节序 3),在私有/团队之前 + let orphan = models.iter().find(|m| m.get("name").and_then(Value::as_str) == Some("无主")).expect("无主条目应被收录"); + assert_eq!(orphan.get("owner").and_then(Value::as_str), Some("public")); + let m2 = by("私有"); assert_eq!(m2.get("name").and_then(Value::as_str), Some("私有")); assert_eq!(m2.get("owner").and_then(Value::as_str), Some("private")); assert_eq!(m2.get("think").and_then(Value::as_str), Some("off"), "私有条目标 false 须尊重"); - let m3 = &models[3]; + let m3 = by("团队"); assert_eq!(m3.get("owner").and_then(Value::as_str), Some("team")); assert_eq!(m0.get("owner").and_then(Value::as_str), Some("public")); assert!( - !models.iter().any(|m| matches!(m.get("name").and_then(Value::as_str), Some("无主" | "未知主"))), - "归属缺失/未知必须跳过: {models:?}" + !models.iter().any(|m| m.get("name").and_then(Value::as_str) == Some("未知主")), + "认不出的归属类型仍要跳过: {models:?}" ); - assert_eq!(notes.len(), 2, "未知协议/重名各一条 note: {notes:?}"); + // 未知协议 1 + 重名提示 1 + 两类跳过计数(归属不明 1 / 占位 1); + // 隐藏条目静默跳过,不出 note + assert_eq!(notes.len(), 4, "{notes:?}"); + assert!(notes.iter().any(|n| n.contains("与同批条目重名,已按服务端配置区分收录")), "{notes:?}"); + assert!(!notes.iter().any(|n| n.contains("隐藏")), "隐藏条目不该出 note: {notes:?}"); + assert!(notes.iter().any(|n| n.contains("归属类型无法识别")), "{notes:?}"); + assert!(notes.iter().any(|n| n.contains("占位")), "{notes:?}"); } #[test] diff --git a/desktop/src/baizhi/tests.rs b/desktop/src/baizhi/tests.rs index 8035fbf81..99cead069 100644 --- a/desktop/src/baizhi/tests.rs +++ b/desktop/src/baizhi/tests.rs @@ -841,7 +841,8 @@ async fn mc_member_models_sync_and_revoke_contract() { "owner": { "type": "private" } }, { "id": "cfg-6", "remark": "团队的", "model": "team-model", "interface_type": "anthropic", "owner": { "type": "team", "name": "翼龙组" } }, - // 归属缺失/未知 → 形状不明,仍跳过(静默) + // owner 整个缺席 → 按 public 收(服务端 omitempty,内部 hook 的 + // 会员内置模型不保证带);认不出的归属类型才跳过 { "id": "cfg-7", "remark": "无主", "model": "orphan", "interface_type": "anthropic" }, { "id": "cfg-8", "remark": "未知主", "model": "alien", "interface_type": "anthropic", "owner": { "type": "galaxy" } } @@ -867,7 +868,7 @@ async fn mc_member_models_sync_and_revoke_contract() { let out = super::sync_member_models(&svc, &tmp.0).await.map_err(|e| e.msg()).unwrap(); let models = out.get("models").and_then(Value::as_array).unwrap(); // 输出按节序排序:专业(档位)→ 旗舰(档位,locked)→ 付费 → 我的 → 团队 - assert_eq!(models.len(), 5, "仅协议/占位/无主条目被过滤,超档转 locked、私有/团队收录: {models:?}"); + assert_eq!(models.len(), 6, "仅协议/占位/归属不明被过滤,超档转 locked、私有/团队/无主收录: {models:?}"); let m0 = &models[0]; assert_eq!(m0.get("name").and_then(Value::as_str), Some("专业模型")); assert_eq!(m0.get("provider").and_then(Value::as_str), Some("anthropic")); @@ -896,21 +897,24 @@ async fn mc_member_models_sync_and_revoke_contract() { assert_eq!(models[2].get("model").and_then(Value::as_str), Some("mc-gpt")); assert_eq!(models[2].get("provider").and_then(Value::as_str), Some("openai")); assert_eq!(models[2].get("api_key").and_then(Value::as_str), Some("")); + // owner 缺席的条目按 public 收,与 mc-gpt 同在付费节(按 name 排序在其后) + assert_eq!(models[3].get("name").and_then(Value::as_str), Some("无主")); + assert_eq!(models[3].get("owner").and_then(Value::as_str), Some("public")); // 私有/团队条目:收录、归属标注、不锁(非内置命名不受档位门限) - let m3 = &models[3]; - assert_eq!(m3.get("name").and_then(Value::as_str), Some("我的")); - assert_eq!(m3.get("owner").and_then(Value::as_str), Some("private")); - assert!(m3.get("locked").is_none()); - assert_eq!(models[4].get("name").and_then(Value::as_str), Some("团队的")); - assert_eq!(models[4].get("owner").and_then(Value::as_str), Some("team")); - // 归属缺失/未知(cfg-7/8)不得出现 + let m4 = &models[4]; + assert_eq!(m4.get("name").and_then(Value::as_str), Some("我的")); + assert_eq!(m4.get("owner").and_then(Value::as_str), Some("private")); + assert!(m4.get("locked").is_none()); + assert_eq!(models[5].get("name").and_then(Value::as_str), Some("团队的")); + assert_eq!(models[5].get("owner").and_then(Value::as_str), Some("team")); + // 认不出的归属类型(cfg-8)仍跳过 assert!( - !models.iter().any(|m| m.get("name").and_then(Value::as_str) == Some("无主") - || m.get("name").and_then(Value::as_str) == Some("未知主")), - "归属缺失/未知的条目必须跳过" + !models.iter().any(|m| m.get("name").and_then(Value::as_str) == Some("未知主")), + "认不出的归属类型必须跳过" ); let notes = out.get("notes").and_then(Value::as_array).unwrap(); - assert_eq!(notes.len(), 1, "仅未知协议一条 note(锁定与私有收录都静默): {notes:?}"); + assert_eq!(notes.len(), 2, "未知协议 + 归属不明各一条(锁定与私有收录都静默): {notes:?}"); + assert!(notes.iter().any(|n| n.as_str().is_some_and(|s| s.contains("归属类型无法识别"))), "{notes:?}"); // 本机记录承载物化所需的全部字段(含 base_url 快照,同源 /v1) let stored = super::stored_ohmyagent_key(&tmp.0).unwrap(); assert_eq!(stored.get("api_key").and_then(Value::as_str), Some("omk-1")); diff --git a/desktop/src/driver/session.rs b/desktop/src/driver/session.rs index af8f4bc14..b007e0c85 100644 --- a/desktop/src/driver/session.rs +++ b/desktop/src/driver/session.rs @@ -144,6 +144,24 @@ fn valid_think(level: &str) -> bool { matches!(level, "" | "off" | "low" | "medium" | "high") } +/// 剥同步条目落盘名的来源后缀:`@baizhi` / `@monkeycode[#<服务端配置 id>]` +/// (为什么有这个后缀见 ui/src/settingsConfig.ts syncedName)。 +/// 只用于**宽松比对**存量引用,寻址仍以配置里的原名为准。 +fn strip_source_suffix(name: &str) -> &str { + for marker in ["@baizhi", "@monkeycode"] { + // 从右往左找:名字本身可能含 @,只认结尾那一段后缀 + if let Some(at) = name.rfind(marker) { + let (base, tail) = name.split_at(at); + let rest = &tail[marker.len()..]; + // 后缀之后要么到头,要么只剩 `#`(会员条目的区分位) + if !base.is_empty() && (rest.is_empty() || rest.starts_with('#')) { + return base; + } + } + } + name +} + /// 档位 → session/setThinking 参数。 fn think_rpc_params(engine_id: &str, level: &str) -> Value { if level == "off" { @@ -161,6 +179,26 @@ fn check_session_id(id: &str) -> Result<(), String> { } } +#[cfg(test)] +mod source_suffix_tests { + use super::strip_source_suffix; + + /// 存量引用(加后缀之前建的会话、记忆里的默认模型)记的都是裸名, + /// 全靠这层比对落到新条目上——会员条目的 `#<配置 id>` 尾巴必须一起剥, + /// 漏了它等于所有会员模型的老会话开不起来。 + #[test] + fn strips_source_marker_with_optional_config_id() { + assert_eq!(strip_source_suffix("深度求索@monkeycode#cfg-9"), "深度求索"); + assert_eq!(strip_source_suffix("深度求索@monkeycode"), "深度求索"); + assert_eq!(strip_source_suffix("deepseek-v3@baizhi"), "deepseek-v3"); + // 手工条目原样;名字里本来就有 @ 或形似后缀的不误伤 + assert_eq!(strip_source_suffix("my@model"), "my@model"); + assert_eq!(strip_source_suffix("@baizhi"), "@baizhi"); + assert_eq!(strip_source_suffix("x@monkeycode-plus"), "x@monkeycode-plus"); + assert_eq!(strip_source_suffix("普通模型"), "普通模型"); + } +} + #[cfg(test)] mod session_id_tests { use super::valid_session_id; @@ -1539,11 +1577,16 @@ impl OhmyDriver { .map(|m| m.name.clone()) .ok_or_else(|| "尚未配置可用模型,请先在设置中添加".into()); } + // 精确没中再按宽松口径找一次:同步条目的落盘名带来源后缀 + //(ui/src/settingsConfig.ts syncedName),而加后缀之前建的会话、 + // 记忆里的默认模型记的都是裸名——不兜这一次,升级后老会话一律 + // "未知模型",连恢复都进不去 let m = self .0 .models .iter() .find(|m| m.name == name) + .or_else(|| self.0.models.iter().find(|m| strip_source_suffix(&m.name) == strip_source_suffix(name))) .ok_or_else(|| format!("未知模型 {name:?}"))?; if m.locked { return Err(format!("模型 {name:?} 当前会员档不可用,升级后重新同步")); diff --git a/desktop/ui/src/App.tsx b/desktop/ui/src/App.tsx index d09ccc56a..b35b91f57 100644 --- a/desktop/ui/src/App.tsx +++ b/desktop/ui/src/App.tsx @@ -45,6 +45,7 @@ import { CHANGE_KIND, changeTag, FilesDrawer, type FsAdapter } from "./filesdraw import { IconFolder, IconX } from "./icons"; import { inspectMcAccount } from "./mcaccount"; import { workspaceRelativePath } from "./markdownPaths"; +import { sameModelName } from "./modelMenu"; import { NewTaskView, type NewTaskPrefill } from "./newtask"; import { isProjectArchived, readArchivedProjects, updateArchivedProjects } from "./projectArchive"; import { initialChat, reduceBatch, type ChatState } from "./reduce"; @@ -676,7 +677,12 @@ export default function App() { // ===== 派生状态 ===== const currentMeta = sessions.find((m) => m.id === session.id); - const currentModel = session.model || models.find((m) => m.default && !m.locked)?.name || ""; + // 会话记的名字可能是加来源后缀之前的裸名:先落到当下的真实条目上, + // 选择器高亮、思考档回查、切模型才不会齐齐落空(壳侧 model_id_of 同款兜底) + const sessionModelEntry = session.model + ? models.find((m) => m.name === session.model) ?? models.find((m) => sameModelName(m.name, session.model)) + : undefined; + const currentModel = sessionModelEntry?.name || session.model || models.find((m) => m.default && !m.locked)?.name || ""; const menuModels: ModelInfo[] = modelMenuList(models, session.model); // 下线模型兜底,无 source 归「自定义」组 const openPerm = [...session.chat.items].reverse().find((it) => it.kind === "perm" && it.state === "open") as | Extract diff --git a/desktop/ui/src/appView.ts b/desktop/ui/src/appView.ts index 608c17859..5620f560c 100644 --- a/desktop/ui/src/appView.ts +++ b/desktop/ui/src/appView.ts @@ -2,6 +2,7 @@ // 快捷键路由。全部是无副作用函数(不触 React/DOM),供 App.tsx 与单测共用 // ——快捷键这一块尤其吃亏于"藏在 860 行组件里的 40 行 if 链":⏎ 允许 / // esc 拒绝都是不可逆动作,守卫改错没有任何东西会拦住(见 appView.test.ts)。 +import { sameModelName } from "./modelMenu"; import type { CloudTask, ModelInfo, SessionMeta } from "./types"; export type AppMainView = "new" | "session" | "settings" | "cloud"; @@ -34,7 +35,8 @@ export function windowContextLabel( /** 模型菜单清单:会话在用的模型已从配置里下线时补一条兜底项, * 否则下拉里选不中当前模型(无 source 归「自定义」组) */ export function modelMenuList(models: ModelInfo[], sessionModel: string): ModelInfo[] { - return sessionModel && !models.some((m) => m.name === sessionModel) + // 宽松比较:老会话记的是加来源后缀之前的裸名,那不算"模型已下线" + return sessionModel && !models.some((m) => m.name === sessionModel || sameModelName(m.name, sessionModel)) ? [...models, { name: sessionModel, default: false }] : models; } diff --git a/desktop/ui/src/chat.tsx b/desktop/ui/src/chat.tsx index 2f82a346f..1bb889c9d 100644 --- a/desktop/ui/src/chat.tsx +++ b/desktop/ui/src/chat.tsx @@ -40,6 +40,7 @@ import { modelDisplayByName, modelMenuTabs, shouldShowModelExtras, + stripSourceSuffix, } from "./modelMenu"; import { useNativeFileDrop } from "./nativeDrop"; import { workspaceRelativePath } from "./markdownPaths"; @@ -319,7 +320,7 @@ export function ModelPicker({ key={m.name} label={d.label} tag={noTag ? undefined : d.tier} - title={m.locked ? `${m.name} · 当前会员档不可用,升级后重新同步` : m.name} + title={m.locked ? `${stripSourceSuffix(m.name)} · 当前会员档不可用,升级后重新同步` : stripSourceSuffix(m.name)} selected={m.name === current} disabled={m.locked} hint={m.default ? "默认" : undefined} @@ -337,7 +338,7 @@ export function ModelPicker({ label={modelDisplayByName(models, current).label} open={open} disabled={disabled} - title={disabled ? "轮次执行中,结束后可切换" : `${current || "选择模型"} · 点击切换(下一轮生效)`} + title={disabled ? "轮次执行中,结束后可切换" : `${stripSourceSuffix(current) || "选择模型"} · 点击切换(下一轮生效)`} onClick={() => { if (disabled) return; setFilter(""); diff --git a/desktop/ui/src/modelMenu.test.ts b/desktop/ui/src/modelMenu.test.ts index 5ceabbf26..10b6a4180 100644 --- a/desktop/ui/src/modelMenu.test.ts +++ b/desktop/ui/src/modelMenu.test.ts @@ -2,6 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { filterModels, + sameModelName, + stripSourceSuffix, groupMemberSections, memberCategory, modelDisplay, @@ -112,6 +114,35 @@ describe("filterModels(tab 内过滤)", () => { }); }); +describe("同步条目落盘名的来源后缀(寻址用,展示一律剥掉)", () => { + it("stripSourceSuffix 剥来源后缀与会员条目的 #配置 id;剥空回落原名", () => { + expect(stripSourceSuffix("deepseek-v3@baizhi")).toBe("deepseek-v3"); + expect(stripSourceSuffix("深度求索@monkeycode#cfg-9")).toBe("深度求索"); + expect(stripSourceSuffix("深度求索@monkeycode")).toBe("深度求索"); + // 手工条目原样;名字里本来就有 @ 的、形似后缀的都不误伤 + expect(stripSourceSuffix("my@model")).toBe("my@model"); + expect(stripSourceSuffix("@baizhi")).toBe("@baizhi"); + expect(stripSourceSuffix("x@monkeycode-plus")).toBe("x@monkeycode-plus"); + // id 的字符集不归我们管,# 之后放行(与壳侧 strip_source_suffix 同口径) + expect(stripSourceSuffix("深度求索@monkeycode#a@b")).toBe("深度求索"); + }); + + it("sameModelName:带不带后缀算同一条(存量引用靠它落到新条目上)", () => { + expect(sameModelName("深度求索", "深度求索@monkeycode#cfg-9")).toBe(true); + expect(sameModelName("deepseek-v3@baizhi", "deepseek-v3")).toBe(true); + expect(sameModelName("深度求索", "别的模型")).toBe(false); + }); + + it("modelDisplay 剥后缀后再剥档位前缀;filterModels 按展示名匹配", () => { + expect( + modelDisplay({ name: "monkeycode-pro/deepseek@monkeycode#c1", model: "monkeycode-pro/deepseek", source: SOURCE_MONKEYCODE }), + ).toEqual({ label: "deepseek", tier: "专业" }); + const items = [{ ...m("深度求索@monkeycode#c1", SOURCE_MONKEYCODE), model: "mc-ds" }]; + expect(filterModels(items, "深度")).toEqual(items); + expect(filterModels(items, "monkeycode")).toEqual([]); // 后缀不参与匹配 + }); +}); + describe("memberCategory(设置页药丸与分节共用的分类词汇)", () => { it("档位优先于 owner;公共非档位与旧同步条目都归付费", () => { expect(memberCategory({ model: "monkeycode-basic/b", owner: "public" })).toBe("基础"); diff --git a/desktop/ui/src/modelMenu.ts b/desktop/ui/src/modelMenu.ts index 9448d88b3..359e7da78 100644 --- a/desktop/ui/src/modelMenu.ts +++ b/desktop/ui/src/modelMenu.ts @@ -39,6 +39,23 @@ export function stripTierPrefix(name: string): string { return name.replace(/^monkeycode-[^/]+\//i, "") || name; } +/** 同步条目的落盘名带来源后缀(为什么加见 settingsConfig.syncedName), + * 它只是引擎寻址键的一部分,**任何展示面都必须剥掉**。剥空回落原名。 + * 会员条目的后缀还带 `#<服务端配置 id>`(同来源内重名靠它区分)。 */ +// `#` 之后放行任意字符:服务端配置 id 的字符集不归我们管(壳侧 +// driver/session.rs strip_source_suffix 同款口径,两处必须一致) +const SOURCE_SUFFIX_RE = new RegExp(`@(?:${SOURCE_BAIZHI}|${SOURCE_MONKEYCODE})(?:#.*)?$`, "i"); +export function stripSourceSuffix(name: string): string { + return name.replace(SOURCE_SUFFIX_RE, "") || name; +} + +/** 名字比较的宽松口径:带不带来源后缀都算同一条。存量引用(旧会话记的 + * 模型名、lastTaskModel、加后缀前落盘的 default)靠它平滑落到新条目上。 */ +export function sameModelName(a: string, b: string): boolean { + const norm = (s: string) => stripSourceSuffix(s.trim()).toLocaleLowerCase(); + return norm(a) === norm(b); +} + /** 模型条目的展示投影:短名 + 档位(基础/专业/旗舰)。**纯展示层**—— * name 是引擎键/lastTaskModel 记忆键,onPick 仍必须用原始 name。 * 只对会员来源生效(手工条目取名 monkeycode-pro-x 不该被误打会员档); @@ -47,17 +64,19 @@ export function modelDisplay(m: Pick): { label: string; tier?: string; } { - if (m.source !== SOURCE_MONKEYCODE) return { label: m.name }; - return { label: stripTierPrefix(m.name), tier: builtinTierLabel(m.model) }; + const short = stripSourceSuffix(m.name); + if (m.source !== SOURCE_MONKEYCODE) return { label: short }; + return { label: stripTierPrefix(short), tier: builtinTierLabel(m.model) }; } -/** 触发器用:按 name 回查条目做展示投影;查不到(下线模型兜底项)原样。 */ +/** 触发器用:按 name 回查条目做展示投影;查不到(下线模型兜底项)原样。 + * 精确没中再按宽松口径找一次——存量引用记的是加后缀之前的裸名。 */ export function modelDisplayByName(models: readonly ModelInfo[], name: string): { label: string; tier?: string; } { - const m = models.find((x) => x.name === name); - return m ? modelDisplay(m) : { label: name }; + const m = models.find((x) => x.name === name) ?? models.find((x) => sameModelName(x.name, name)); + return m ? modelDisplay(m) : { label: stripSourceSuffix(name) }; } /** 来源固定优先级(tab 序与设置页分组排序的单一出处):会员 → 百智云 → @@ -90,8 +109,10 @@ export function modelMenuTabs(models: ModelInfo[]): ModelMenuTab[] { export function filterModels(items: ModelInfo[], filter: string): ModelInfo[] { const q = filter.trim().toLowerCase(); if (!q) return items; + // 按展示名匹配:落盘名尾巴上的 @来源#id 是寻址用的,搜「monkeycode」 + // 不该把整组会员模型都捞出来 return items.filter( - (m) => m.name.toLowerCase().includes(q) || (m.model || "").toLowerCase().includes(q), + (m) => stripSourceSuffix(m.name).toLowerCase().includes(q) || (m.model || "").toLowerCase().includes(q), ); } diff --git a/desktop/ui/src/modelPicker.test.tsx b/desktop/ui/src/modelPicker.test.tsx index 24ae62042..85e73ef64 100644 --- a/desktop/ui/src/modelPicker.test.tsx +++ b/desktop/ui/src/modelPicker.test.tsx @@ -56,7 +56,7 @@ describe("ModelPicker 关闭态(静态渲染,不触 localStorage)", () => { expect(html).toContain("一个非常非常非常长的模型名字 · 点击切换(下一轮生效)"); }); - it("会员长名:trigger 显示剥前缀短名,title 保留完整原名", () => { + it("会员长名:trigger 显示剥前缀短名,title 保留完整原名(仅剥寻址后缀)", () => { const full = "monkeycode-pro/deepseek-pro"; const html = renderToStaticMarkup( { expect(html).toContain(">deepseek-pro<"); expect(html).toContain(`${full} · 点击切换(下一轮生效)`); }); + + it("落盘名的来源后缀不出现在任何展示面(短名与 title 都剥掉)", () => { + const stored = "monkeycode-pro/deepseek-pro@monkeycode#cfg-9"; + const html = renderToStaticMarkup( + , + ); + + expect(html).not.toContain("@monkeycode"); + expect(html).not.toContain("cfg-9"); + expect(html).toContain(">deepseek-pro<"); + expect(html).toContain("monkeycode-pro/deepseek-pro · 点击切换(下一轮生效)"); + }); }); diff --git a/desktop/ui/src/newtask.tsx b/desktop/ui/src/newtask.tsx index ca95e1e8d..8d4f3546e 100644 --- a/desktop/ui/src/newtask.tsx +++ b/desktop/ui/src/newtask.tsx @@ -12,7 +12,7 @@ import { mcTaskCreate, mcTaskOptions } from "./cloudapi"; import { inDesktopShell, pickDirectory, workdirPickBase } from "./host"; import { useUpwardMenuHeight } from "./menuPosition"; import { useNativeFileDrop } from "./nativeDrop"; -import { readLastTaskModel, rememberLastTaskModel } from "./modelMenu"; +import { readLastTaskModel, rememberLastTaskModel, sameModelName } from "./modelMenu"; import { createSession } from "./session"; import type { CloudTask } from "./types"; import { @@ -166,8 +166,13 @@ export function NewTaskView({ // 时常为空,初始化时校验会让记忆永远失效。locked(超会员档展示专用) // 条目全链路跳过——它不在引擎 settings 里,派生到它会话建不起来 const [pickedModel, setPickedModel] = useState(() => readLastTaskModel()); + // 记忆里的名字可能是加来源后缀之前的裸名:精确没中按宽松口径再找一次, + // 找到就用**当下条目的真名**(记忆值本身已不是有效的引擎寻址键) + const rememberedEntry = pickedModel + ? models.find((m) => m.name === pickedModel) ?? models.find((m) => sameModelName(m.name, pickedModel)) + : undefined; const model = - (pickedModel && models.some((m) => m.name === pickedModel && !m.locked) ? pickedModel : "") || + (rememberedEntry && !rememberedEntry.locked ? rememberedEntry.name : "") || models.find((m) => m.default && !m.locked)?.name || models.find((m) => !m.locked)?.name || ""; diff --git a/desktop/ui/src/settings.test.ts b/desktop/ui/src/settings.test.ts index f000cc2c3..137cdf677 100644 --- a/desktop/ui/src/settings.test.ts +++ b/desktop/ui/src/settings.test.ts @@ -6,6 +6,7 @@ import { modelsToConfig, replaceSourceGroup, sortModelsBySource, + syncedName, validateMcpNames, } from "./settingsConfig"; import { SOURCE_BAIZHI, SOURCE_MONKEYCODE, type HostModel } from "./types"; @@ -63,6 +64,25 @@ describe("modelsToConfig", () => { }); }); +describe("syncedName(同步条目的落盘名)", () => { + it("百智云缀来源;会员再缀服务端配置 id(同批重名靠它区分)", () => { + expect(syncedName("deepseek-v3", SOURCE_BAIZHI)).toBe("deepseek-v3@baizhi"); + expect(syncedName("深度求索", SOURCE_MONKEYCODE, "cfg-9")).toBe("深度求索@monkeycode#cfg-9"); + // 同名两条 → 落盘名互不相同,不再有条目因重名被丢 + expect(syncedName("同名", SOURCE_MONKEYCODE, "c1")).not.toBe(syncedName("同名", SOURCE_MONKEYCODE, "c2")); + // 服务端没给 id(不该发生)时退回只缀来源,至少跨来源不撞 + expect(syncedName("无 id", SOURCE_MONKEYCODE)).toBe("无 id@monkeycode"); + }); + + it("重同步不叠加后缀;自定义条目与空名不动", () => { + expect(syncedName("深度求索@monkeycode#cfg-9", SOURCE_MONKEYCODE, "cfg-9")).toBe("深度求索@monkeycode#cfg-9"); + // 服务端换了配置 id:按新 id 重缀,不是叠加 + expect(syncedName("深度求索@monkeycode#old", SOURCE_MONKEYCODE, "new")).toBe("深度求索@monkeycode#new"); + expect(syncedName("手工模型")).toBe("手工模型"); + expect(syncedName(" ", SOURCE_BAIZHI)).toBe(""); + }); +}); + describe("replaceSourceGroup", () => { const entry = (name: string, source?: string) => ({ name, source }); diff --git a/desktop/ui/src/settings.tsx b/desktop/ui/src/settings.tsx index 7ff90c104..cf2adca5a 100644 --- a/desktop/ui/src/settings.tsx +++ b/desktop/ui/src/settings.tsx @@ -31,15 +31,17 @@ import { BaizhiLogo } from "./baizhi"; import logoUrl from "./logo.png"; import { Field, Section, input, select, whiteBtn } from "./settings-ui"; import { mcModelsRevoke, mcModelsSync } from "./cloudapi"; -import { memberCategory, stripTierPrefix } from "./modelMenu"; +import { memberCategory, sameModelName, stripSourceSuffix, stripTierPrefix } from "./modelMenu"; import { dedupeModelsByName, emptyMcp, emptyModel, payloadOf, + renameKeepingSuffix, replaceSourceGroup, serversToMcps, sortModelsBySource, + syncedName, validateMcpNames, type McpEntry, } from "./settingsConfig"; @@ -857,7 +859,9 @@ export function SettingsView({ const models = formRef.current.models; const defaultName = models[formRef.current.defaultIdx]?.name?.trim() ?? ""; const synced: HostModel[] = syncedModels.map((sm) => ({ - name: sm.name, + // 落盘名带来源后缀(展示层剥掉):两个同步来源撞名是常态,而名字是引擎 + // 寻址键——不加后缀就只能丢掉后来者,即"同步的模型不全"。见 syncedName + name: syncedName(sm.name, source, sm.id), provider: sm.provider, base_url: sm.base_url, api_key: sm.api_key, @@ -871,14 +875,18 @@ export function SettingsView({ owner: sm.owner, })); const outside = new Set(models.filter((m) => m.source !== source && m.name.trim()).map((m) => m.name.trim())); - const skipped = synced.map((m) => m.name.trim()).filter((n) => outside.has(n)); + // 跳过名单外显给用户看,报的是展示名(带 @source 的落盘名是实现细节) + const skipped = synced.filter((m) => outside.has(m.name.trim())).map((m) => stripSourceSuffix(m.name.trim())); // 整组替换后同样按来源排序:会员组不落在列表尾部,与载入/选择器一致 const next = sortModelsBySource(replaceSourceGroup(models, synced, source)); setModels(next); // 索引大位移:默认模型按名字重新定位(被移除则回退第一项),折叠态复位; // 降档重同步后原默认可能变锁定 → 挪到首个未锁条目(锁定条目不物化, // default 落它头上等于没有默认) + // 精确没中再按宽松口径找一次:加后缀前落盘的默认项记的是裸名,不这么兜 + // 一次,升级后第一次同步会把默认模型悄悄挪到列表第一条 let di = next.findIndex((m) => m.name.trim() === defaultName); + if (di < 0) di = next.findIndex((m) => sameModelName(m.name, defaultName)); if (di < 0 || next[di]?.locked) di = next.findIndex((m) => !m.locked); di = di >= 0 ? di : 0; setDefaultIdx(di); @@ -1114,7 +1122,7 @@ export function SettingsView({ for (const m of ms) { const managed = m.source === SOURCE_MONKEYCODE; if (!m.name.trim() || !m.model.trim() || (!managed && (!m.base_url.trim() || !m.api_key.trim()))) { - setErr(`模型「${m.name.trim() || "未命名"}」信息不完整(需名称/接口地址/API Key/模型标识)`); + setErr(`模型「${stripSourceSuffix(m.name.trim()) || "未命名"}」信息不完整(需名称/接口地址/API Key/模型标识)`); setActive("models"); return false; } @@ -1129,7 +1137,7 @@ export function SettingsView({ } for (const [n, list] of byName) { if (list.length > 1) { - setErr(`模型名称重复: ${n}(${list.map((m) => modelSourceLabel(m.source)).join("、")}各一条;名称是模型的唯一标识,请删除或改名其一)`); + setErr(`模型名称重复: ${stripSourceSuffix(n)}(${list.map((m) => modelSourceLabel(m.source)).join("、")}各一条;名称是模型的唯一标识,请删除或改名其一)`); setActive("models"); return false; } @@ -1140,7 +1148,7 @@ export function SettingsView({ const cw = m.context_window ?? 200000; if (m.max_output && m.max_output >= cw * 0.1) { setErr( - `模型「${m.name.trim()}」的最大输出(${m.max_output.toLocaleString()})需小于上下文窗口(${cw.toLocaleString()})的 10%,建议不超过 8%(${Math.floor(cw * 0.08).toLocaleString()})`, + `模型「${stripSourceSuffix(m.name.trim())}」的最大输出(${m.max_output.toLocaleString()})需小于上下文窗口(${cw.toLocaleString()})的 10%,建议不超过 8%(${Math.floor(cw * 0.08).toLocaleString()})`, ); setActive("models"); return false; @@ -1233,7 +1241,9 @@ export function SettingsView({ className="ellipsis" style={{ fontSize: 12.5, fontFamily: MONO, color: m.name.trim() ? "var(--t1)" : "var(--t5)", minWidth: 0 }} > - {(managed ? stripTierPrefix(m.name.trim()) : m.name.trim()) || "未命名模型"} + {/* 同步条目一律剥来源后缀,会员条目再剥档位前缀:落盘名是寻址键, + 不往用户脸上糊。手工条目的名字原样(用户自己取的) */} + {(managed ? stripTierPrefix(stripSourceSuffix(m.name.trim())) : stripSourceSuffix(m.name.trim())) || "未命名模型"} {m.provider || "anthropic"} {managed && ( @@ -1312,7 +1322,15 @@ export function SettingsView({
- patchModel(i, { name: e.target.value })} className="hv-bd" /> + {/* 同步条目(百智云组可展开编辑)在输入框里也只见短名:剥后缀显示、 + 写回时按来源补回去,落盘名恒带后缀这一不变量不靠用户维持 */} + patchModel(i, { name: renameKeepingSuffix(m.name, e.target.value) })} + className="hv-bd" + />