Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 71 additions & 16 deletions desktop/src/baizhi/monkeycode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -751,18 +751,32 @@ fn local_model_entries(items: &[Value], plan: &str) -> (Vec<Value>, Vec<String>)
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);
Expand All @@ -771,13 +785,13 @@ fn local_model_entries(items: &[Value], plan: &str) -> (Vec<Value>, Vec<String>)
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": "",
Expand Down Expand Up @@ -814,9 +828,21 @@ fn local_model_entries(items: &[Value], plan: &str) -> (Vec<Value>, Vec<String>)
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)
}
Expand All @@ -826,13 +852,18 @@ fn local_model_entries(items: &[Value], plan: &str) -> (Vec<Value>, Vec<String>)
/// note,重新同步可恢复)。返回 {models, notes}(与 baizhi_sync 返回形状
/// 平行;不碰 config.json)。
pub async fn mc_member_models_sync(svc: &Service) -> BzResult<Value> {
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) => {
Expand Down Expand Up @@ -1133,17 +1164,26 @@ 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" }),
json!({ "id": "cfg-10", "remark": "未知主", "model": "alien", "interface_type": "anthropic",
"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"));
Expand All @@ -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"));
Expand All @@ -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]
Expand Down
30 changes: 17 additions & 13 deletions desktop/src/baizhi/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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" } }
Expand All @@ -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"));
Expand Down Expand Up @@ -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"));
Expand Down
43 changes: 43 additions & 0 deletions desktop/src/driver/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()..];
// 后缀之后要么到头,要么只剩 `#<id>`(会员条目的区分位)
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" {
Expand All @@ -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;
Expand Down Expand Up @@ -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:?} 当前会员档不可用,升级后重新同步"));
Expand Down
8 changes: 7 additions & 1 deletion desktop/ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<LogItem, { kind: "perm" }>
Expand Down
Loading
Loading