From 9d5411acc01d8e75429816a8681186907f6586af Mon Sep 17 00:00:00 2001 From: mojie126 Date: Mon, 9 Nov 2026 22:41:30 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20models.dev=20=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E8=BA=AB=E4=BB=BD=E7=BB=91=E5=AE=9A=E4=B8=8E=E4=BB=A3?= =?UTF-8?q?=E7=90=86=E9=80=89=E6=8B=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 为 models.dev 能力补齐增加冲突安全索引、精确身份绑定和同提供商名称对齐 - 模型能力弹窗支持选择 canonical 模型、上游展示名与名称复用开关 - 自定义提供商别名继承目标模型元数据 - 模型目录更新增加代理选择、压缩下载、超时与可诊断错误 - 同步多语言文案与前端构建产物 --- desktop-tauri/i18n/i18n-src/en/models.json | 13 +- desktop-tauri/i18n/i18n-src/ja/models.json | 13 +- desktop-tauri/i18n/i18n-src/ko/models.json | 13 +- desktop-tauri/i18n/i18n-src/pt-BR/models.json | 13 +- .../i18n/i18n-src/zh-Hant/models.json | 13 +- .../server/src/server/api/model_manage.rs | 133 +++++++- .../server/src/server/api/model_meta.rs | 33 +- .../server/src/server/core/agents/catalog.rs | 3 +- .../server/core/custom_providers/bindings.rs | 16 +- .../server/src/server/core/model_meta/fill.rs | 231 ++++++++++++-- .../server/src/server/core/model_meta/mod.rs | 8 +- .../src/server/core/model_meta/refresh.rs | 166 +++++++--- .../src/server/core/model_meta/snapshot.rs | 287 ++++++++++++++++-- .../server/src/server/core/model_rules/mod.rs | 200 +++++++++++- .../src/server/core/model_rules/mutations.rs | 43 ++- .../src/server/core/providers/catalog.rs | 3 +- .../src/server/core/providers/catalog/view.rs | 9 + .../server/src/server/core/routing_groups.rs | 3 +- .../src-tauri/server/src/server/http.rs | 5 + .../src-tauri/server/src/web_shim.rs | 11 + desktop-tauri/src-tauri/src/bridge.rs | 6 + .../src/islands/model-capability-dialog.tsx | 98 +++++- .../src/islands/models-custom-source.ts | 5 + .../src/islands/models-panel-state.ts | 39 +++ .../ui-shared/maintenance-summary.tsx | 43 ++- desktop-tauri/ui/i18n/combined/en.js | 2 +- desktop-tauri/ui/i18n/combined/ja.js | 2 +- desktop-tauri/ui/i18n/combined/ko.js | 2 +- desktop-tauri/ui/i18n/combined/pt-BR.js | 2 +- desktop-tauri/ui/i18n/combined/zh-Hant.js | 2 +- desktop-tauri/ui/i18n/en/models.js | 2 +- desktop-tauri/ui/i18n/ja/models.js | 2 +- desktop-tauri/ui/i18n/ko/models.js | 2 +- desktop-tauri/ui/i18n/pt-BR/models.js | 2 +- desktop-tauri/ui/i18n/zh-Hant/models.js | 2 +- desktop-tauri/ui/islands/ui.css | 2 +- desktop-tauri/ui/islands/ui.js | 162 +++++----- desktop-tauri/ui/m/mobile.css | 2 +- desktop-tauri/ui/m/mobile.js | 78 ++--- desktop-tauri/ui/sw.js | 2 +- scripts/sync-modelsdev.mjs | 26 +- 41 files changed, 1401 insertions(+), 298 deletions(-) diff --git a/desktop-tauri/i18n/i18n-src/en/models.json b/desktop-tauri/i18n/i18n-src/en/models.json index d34c8ce4b..48d0be637 100644 --- a/desktop-tauri/i18n/i18n-src/en/models.json +++ b/desktop-tauri/i18n/i18n-src/en/models.json @@ -479,5 +479,16 @@ ":": ": ", ":这里的模型不会出现在其他家,别名也只在这一家内生效。改名称 / 协议 / Base URL 在账号页该家账号的「设置」→ 提供商一栏;整家不要了,鼠标移到左栏这家上点 × 删除(连同名下账号)。": ": the models here won't appear under other providers, and aliases take effect only within this provider. To change the name / protocol / Base URL, use the “Settings” → Provider section of this provider's account on the Accounts page; to remove the whole provider, hover over it in the left rail and click × to delete it (along with its accounts).", ";其中 {keys} 由 models.dev 补齐": "; {keys} filled in by models.dev", - ";首个失败:{error}": "; first failure: {error}" + ";首个失败:{error}": "; first failure: {error}", + "搜索 models.dev…": "Searching models.dev…", + "没有找到匹配的 models.dev 模型": "No matching models.dev models found", + "搜索失败:{message}": "Search failed: {message}", + "models.dev 身份": "models.dev identity", + "搜索模型名称或 canonical ID": "Search model name or canonical ID", + "搜索": "Search", + "已选择的 canonical ID(留空 = 未绑定)": "Selected canonical ID (leave blank for no binding)", + "用于名称对齐的上游展示名": "Upstream display name used for name alignment", + "允许同一提供商的新模型 ID 按名称复用此映射": "Allow new model IDs from the same provider to reuse this mapping by name", + "名称只在当前提供商内参与唯一候选匹配;精确 ID 绑定始终优先,出现同名冲突时不会自动补齐。": "Names are matched only within the current provider and only when the candidate is unique. Exact ID bindings always take priority, and same-name conflicts are not filled automatically.", + "✅ 已保存 {id} 的能力位与 models.dev 身份({provider})": "✅ Saved capabilities and models.dev identity for {id} ({provider})" } diff --git a/desktop-tauri/i18n/i18n-src/ja/models.json b/desktop-tauri/i18n/i18n-src/ja/models.json index 7a0a6fb6d..1637f48ea 100644 --- a/desktop-tauri/i18n/i18n-src/ja/models.json +++ b/desktop-tauri/i18n/i18n-src/ja/models.json @@ -479,5 +479,16 @@ ":": ":", ":这里的模型不会出现在其他家,别名也只在这一家内生效。改名称 / 协议 / Base URL 在账号页该家账号的「设置」→ 提供商一栏;整家不要了,鼠标移到左栏这家上点 × 删除(连同名下账号)。": ":ここのモデルは他のプロバイダーには表示されず、エイリアスもこのプロバイダー内でのみ有効です。名前 / プロトコル / Base URL の変更は、アカウントページのこのプロバイダーのアカウントの「設定」→ プロバイダー欄で行います;プロバイダーを丸ごと不要にした場合は、左カラムのこのプロバイダーにマウスを合わせて × をクリックして削除します(配下のアカウントも含む)。", ";其中 {keys} 由 models.dev 补齐": ";うち {keys} は models.dev による補完", - ";首个失败:{error}": ";最初の失敗:{error}" + ";首个失败:{error}": ";最初の失敗:{error}", + "搜索 models.dev…": "models.dev を検索中…", + "没有找到匹配的 models.dev 模型": "一致する models.dev モデルが見つかりません", + "搜索失败:{message}": "検索に失敗しました:{message}", + "models.dev 身份": "models.dev の識別情報", + "搜索模型名称或 canonical ID": "モデル名または canonical ID を検索", + "搜索": "検索", + "已选择的 canonical ID(留空 = 未绑定)": "選択した canonical ID(空欄 = 未バインド)", + "用于名称对齐的上游展示名": "名前照合に使う上流の表示名", + "允许同一提供商的新模型 ID 按名称复用此映射": "同じプロバイダーの新しいモデル ID でこのマッピングを名前から再利用できるようにする", + "名称只在当前提供商内参与唯一候选匹配;精确 ID 绑定始终优先,出现同名冲突时不会自动补齐。": "名前照合は現在のプロバイダー内だけ、かつ候補が一意な場合のみ行います。完全一致の ID バインドが常に優先され、同名の衝突がある場合は自動補完しません。", + "✅ 已保存 {id} 的能力位与 models.dev 身份({provider})": "✅ {id} の能力と models.dev の識別情報を保存しました({provider})" } diff --git a/desktop-tauri/i18n/i18n-src/ko/models.json b/desktop-tauri/i18n/i18n-src/ko/models.json index fd6388ab8..51e2f0fec 100644 --- a/desktop-tauri/i18n/i18n-src/ko/models.json +++ b/desktop-tauri/i18n/i18n-src/ko/models.json @@ -479,5 +479,16 @@ ":": ":", ":这里的模型不会出现在其他家,别名也只在这一家内生效。改名称 / 协议 / Base URL 在账号页该家账号的「设置」→ 提供商一栏;整家不要了,鼠标移到左栏这家上点 × 删除(连同名下账号)。": ": 여기의 모델은 다른 제공자에 나타나지 않으며 별칭도 이 제공자 안에서만 유효합니다. 이름 / 프로토콜 / Base URL 변경은 계정 페이지에서 이 제공자 계정의 '설정' → 제공자 항목에서 합니다; 제공자 전체가 필요 없으면 왼쪽 레일에서 이 제공자에 마우스를 올리고 × 를 눌러 삭제하세요(소속 계정 포함).", ";其中 {keys} 由 models.dev 补齐": "; {keys} 항목은 models.dev가 채움", - ";首个失败:{error}": "; 첫 실패: {error}" + ";首个失败:{error}": "; 첫 실패: {error}", + "搜索 models.dev…": "models.dev 검색 중…", + "没有找到匹配的 models.dev 模型": "일치하는 models.dev 모델을 찾지 못했습니다", + "搜索失败:{message}": "검색 실패: {message}", + "models.dev 身份": "models.dev 식별 정보", + "搜索模型名称或 canonical ID": "모델 이름 또는 canonical ID 검색", + "搜索": "검색", + "已选择的 canonical ID(留空 = 未绑定)": "선택한 canonical ID(비우면 바인딩 없음)", + "用于名称对齐的上游展示名": "이름 정렬에 사용할 업스트림 표시 이름", + "允许同一提供商的新模型 ID 按名称复用此映射": "같은 제공자의 새 모델 ID가 이름으로 이 매핑을 재사용하도록 허용", + "名称只在当前提供商内参与唯一候选匹配;精确 ID 绑定始终优先,出现同名冲突时不会自动补齐。": "이름은 현재 제공자 범위에서 후보가 하나일 때만 일치합니다. 정확한 ID 바인딩이 항상 우선하며 같은 이름 충돌은 자동으로 채우지 않습니다.", + "✅ 已保存 {id} 的能力位与 models.dev 身份({provider})": "✅ {id}의 능력과 models.dev 식별 정보를 저장했습니다({provider})" } diff --git a/desktop-tauri/i18n/i18n-src/pt-BR/models.json b/desktop-tauri/i18n/i18n-src/pt-BR/models.json index 69d6addd4..4a361f258 100644 --- a/desktop-tauri/i18n/i18n-src/pt-BR/models.json +++ b/desktop-tauri/i18n/i18n-src/pt-BR/models.json @@ -479,5 +479,16 @@ ":": ":", ":这里的模型不会出现在其他家,别名也只在这一家内生效。改名称 / 协议 / Base URL 在账号页该家账号的「设置」→ 提供商一栏;整家不要了,鼠标移到左栏这家上点 × 删除(连同名下账号)。": ": os modelos daqui não aparecem em outros provedores e os aliases só valem dentro deste. Para alterar nome / protocolo / Base URL, use “Configurações” → campo do provedor nas contas desta família, na página de contas; se não quiser mais o provedor inteiro, passe o mouse sobre ele na barra lateral esquerda e clique em × para excluir (junto com as contas dele).", ";其中 {keys} 由 models.dev 补齐": "; {keys} preenchido(s) pelo models.dev", - ";首个失败:{error}": "; primeira falha: {error}" + ";首个失败:{error}": "; primeira falha: {error}", + "搜索 models.dev…": "Pesquisando no models.dev…", + "没有找到匹配的 models.dev 模型": "Nenhum modelo correspondente foi encontrado no models.dev", + "搜索失败:{message}": "Falha na pesquisa: {message}", + "models.dev 身份": "Identidade no models.dev", + "搜索模型名称或 canonical ID": "Pesquisar nome do modelo ou canonical ID", + "搜索": "Pesquisar", + "已选择的 canonical ID(留空 = 未绑定)": "Canonical ID selecionado (deixe em branco para não vincular)", + "用于名称对齐的上游展示名": "Nome de exibição upstream usado para alinhar pelo nome", + "允许同一提供商的新模型 ID 按名称复用此映射": "Permitir que novos IDs do mesmo provedor reutilizem este mapeamento pelo nome", + "名称只在当前提供商内参与唯一候选匹配;精确 ID 绑定始终优先,出现同名冲突时不会自动补齐。": "Os nomes são comparados somente no provedor atual e apenas quando há um candidato único. Vínculos exatos por ID sempre têm prioridade; conflitos de mesmo nome não são preenchidos automaticamente.", + "✅ 已保存 {id} 的能力位与 models.dev 身份({provider})": "✅ Capacidades e identidade no models.dev salvas para {id} ({provider})" } diff --git a/desktop-tauri/i18n/i18n-src/zh-Hant/models.json b/desktop-tauri/i18n/i18n-src/zh-Hant/models.json index 2b9dceb18..cf378ca8e 100644 --- a/desktop-tauri/i18n/i18n-src/zh-Hant/models.json +++ b/desktop-tauri/i18n/i18n-src/zh-Hant/models.json @@ -479,5 +479,16 @@ ":": "", ":这里的模型不会出现在其他家,别名也只在这一家内生效。改名称 / 协议 / Base URL 在账号页该家账号的「设置」→ 提供商一栏;整家不要了,鼠标移到左栏这家上点 × 删除(连同名下账号)。": "", ";其中 {keys} 由 models.dev 补齐": "", - ";首个失败:{error}": "" + ";首个失败:{error}": "", + "搜索 models.dev…": "搜尋 models.dev…", + "没有找到匹配的 models.dev 模型": "找不到符合的 models.dev 模型", + "搜索失败:{message}": "搜尋失敗:{message}", + "models.dev 身份": "models.dev 身分", + "搜索模型名称或 canonical ID": "搜尋模型名稱或 canonical ID", + "搜索": "搜尋", + "已选择的 canonical ID(留空 = 未绑定)": "已選擇的 canonical ID(留空 = 未綁定)", + "用于名称对齐的上游展示名": "用於名稱對齊的上游顯示名稱", + "允许同一提供商的新模型 ID 按名称复用此映射": "允許同一提供商的新模型 ID 按名稱重用此映射", + "名称只在当前提供商内参与唯一候选匹配;精确 ID 绑定始终优先,出现同名冲突时不会自动补齐。": "名稱只在目前提供商內參與唯一候選比對;精確 ID 綁定始終優先,出現同名衝突時不會自動補齊。", + "✅ 已保存 {id} 的能力位与 models.dev 身份({provider})": "✅ 已儲存 {id} 的能力位與 models.dev 身分({provider})" } diff --git a/desktop-tauri/src-tauri/server/src/server/api/model_manage.rs b/desktop-tauri/src-tauri/server/src/server/api/model_manage.rs index 9f8c86983..7b77abafc 100644 --- a/desktop-tauri/src-tauri/server/src/server/api/model_manage.rs +++ b/desktop-tauri/src-tauri/server/src/server/api/model_manage.rs @@ -23,8 +23,9 @@ //! 两个三态字段各管各的,互不干扰(语义见 `model_rules::add_mapping`)。 use axum::body::Bytes; -use axum::extract::State; +use axum::extract::{Query, State}; use axum::response::Response; +use serde::Deserialize; use serde_json::Value; use crate::server::core::capability; @@ -82,7 +83,11 @@ pub async fn set_state(State(state): State, body: Bytes) -> Respons // 目标提供商:新版前端总是带着(启停粒度是「提供商 × 模型 id」); // 缺省走旧版全局语义 —— 只有旧版前端(升级前)会这么传 let provider = text_field(&object, "provider"); - let provider_opt = if provider.is_empty() { None } else { Some(provider.as_str()) }; + let provider_opt = if provider.is_empty() { + None + } else { + Some(provider.as_str()) + }; if let Some(provider) = provider_opt { if !crate::server::core::providers::is_known_provider_id(provider) { return errors::management_error(400, format!("未知的提供商: {provider}")); @@ -96,7 +101,11 @@ pub async fn set_state(State(state): State, body: Bytes) -> Respons Some(name) => format!("[{name}] {id}"), None => id.clone(), }; - let what = if enabled == Some(true) { "已启用" } else { "已禁用" }; + let what = if enabled == Some(true) { + "已启用" + } else { + "已禁用" + }; logging::log("[Models]", &format!("模型 {subject} {what}")); ok_json(catalog::manage_view(state.store())) } @@ -131,7 +140,11 @@ pub async fn add_mapping(State(state): State, body: Bytes) -> Respo if target.is_empty() { return errors::management_error(400, "缺少目标上游模型"); } - let provider_opt = if provider.is_empty() { None } else { Some(provider.as_str()) }; + let provider_opt = if provider.is_empty() { + None + } else { + Some(provider.as_str()) + }; if let Some(kind) = provider_opt { // provider 必须是注册表里的家:它决定候选链里追加谁,写错名字会让 // 映射悄悄变成一条永远路由不到的死路 @@ -169,7 +182,9 @@ pub async fn add_mapping(State(state): State, body: Bytes) -> Respo Some(Value::Bool(value)) => Some(*value), Some(_) => return errors::management_error(400, "create 必须是布尔值"), }; - if let Err(error) = model_rules::add_mapping(&alias, &target, provider_opt, reasoning, enabled, create) { + if let Err(error) = + model_rules::add_mapping(&alias, &target, provider_opt, reasoning, enabled, create) + { return errors::management_error(error.status_code, error.message); } let subject = match provider_opt { @@ -187,7 +202,10 @@ pub async fn add_mapping(State(state): State, body: Bytes) -> Respo Some(false) => ",开关 关", None => "", }; - logging::log("[Models]", &format!("保存映射 {subject}{reasoning_text}{enabled_text}")); + logging::log( + "[Models]", + &format!("保存映射 {subject}{reasoning_text}{enabled_text}"), + ); ok_json(catalog::manage_view(state.store())) } @@ -208,7 +226,11 @@ pub async fn remove_mapping(State(state): State, body: Bytes) -> Re if alias.is_empty() || target.is_empty() { return errors::management_error(400, "缺少映射名或目标上游模型"); } - let provider_opt = if provider.is_empty() { None } else { Some(provider.as_str()) }; + let provider_opt = if provider.is_empty() { + None + } else { + Some(provider.as_str()) + }; // 自映射(默认绑定)正常**不能删** —— 它跟着模型行存在,要停用请关它的开关。 // 唯一的例外是**这条模型行已经不在了**(上游把模型下线、那家被移除或没有可用 // 账号):这时开关没有落点,「关掉它」无从谈起,删除是唯一出路 —— 管理页的 @@ -312,10 +334,7 @@ pub async fn remove_custom(State(state): State, body: Bytes) -> Res Err(error) => return errors::management_error(error.status_code, error.message), }; if !removed { - return errors::management_error( - 404, - format!("自定义模型不存在: [{provider}] {id}"), - ); + return errors::management_error(404, format!("自定义模型不存在: [{provider}] {id}")); } logging::log("[Models]", &format!("移除自定义模型 [{provider}] {id}")); ok_json(catalog::manage_view(state.store())) @@ -379,7 +398,7 @@ pub async fn set_capabilities(State(state): State, body: Bytes) -> ); } } - if let Err(error) = model_rules::set_capabilities(&provider, &id, &patch) { + if let Err(error) = model_rules::set_capabilities(&provider, &id, &patch, None) { return errors::management_error(error.status_code, error.message); } // 日志把每一项写成「键=值 / 键=恢复」,否则排查时只看得到「保存了能力」 @@ -393,6 +412,94 @@ pub async fn set_capabilities(State(state): State, body: Bytes) -> }) .collect::>() .join(", "); - logging::log("[Models]", &format!("保存模型能力 [{provider}] {id}: {summary}")); + logging::log( + "[Models]", + &format!("保存模型能力 [{provider}] {id}: {summary}"), + ); ok_json(catalog::manage_view(state.store())) } + +#[derive(Deserialize)] +pub struct MetadataBindingQuery { + provider: String, + id: String, +} + +fn binding_json(binding: Option) -> Value { + binding.map_or(Value::Null, |binding| { + serde_json::json!({ + "provider": binding.provider, + "id": binding.id, + "modelsDevId": binding.models_dev_id, + "sourceName": binding.source_name, + "matchByNames": binding.match_by_names, + }) + }) +} + +/// GET /api/models/metadata/binding —— 读取一条手动 models.dev 身份绑定。 +pub async fn get_metadata_binding(Query(query): Query) -> Response { + let provider = query.provider.trim(); + let id = query.id.trim(); + if provider.is_empty() || id.is_empty() { + return errors::management_error(400, "缺少提供商或模型 id"); + } + ok_json(serde_json::json!({ + "binding": binding_json(model_rules::metadata_binding(provider, id)), + })) +} + +/// POST /api/models/metadata/binding —— 设置或清除一条手动 models.dev 身份绑定。 +pub async fn set_metadata_binding(State(_state): State, body: Bytes) -> Response { + let object = match body_object(&body) { + Ok(object) => object, + Err(response) => return response, + }; + let provider = text_field(&object, "provider"); + let id = text_field(&object, "id"); + if provider.is_empty() || id.is_empty() { + return errors::management_error(400, "缺少提供商或模型 id"); + } + let Some(models_dev) = object.get("modelsDevId") else { + return errors::management_error(400, "缺少 modelsDevId 字段"); + }; + let models_dev_id = match models_dev { + Value::Null => String::new(), + Value::String(value) => value.trim().to_string(), + _ => return errors::management_error(400, "modelsDevId 必须是字符串或 null"), + }; + let source_name = object + .get("sourceName") + .and_then(Value::as_str) + .unwrap_or("") + .trim() + .to_string(); + let match_by_names = object + .get("matchByNames") + .and_then(Value::as_bool) + .unwrap_or(false); + let update = model_rules::MetadataBindingUpdate { + models_dev_id: Some(models_dev_id.clone()), + source_name: Some(source_name), + match_by_names: Some(match_by_names), + }; + if let Err(error) = + model_rules::set_capabilities(&provider, &id, &serde_json::Map::new(), Some(&update)) + { + return errors::management_error(error.status_code, error.message); + } + logging::log( + "[Models]", + &format!( + "保存 models.dev 身份 [{provider}] {id}: {}", + if models_dev_id.is_empty() { + "清除".to_string() + } else { + format!("{models_dev_id}(名称对齐={match_by_names})") + } + ), + ); + ok_json(serde_json::json!({ + "binding": binding_json(model_rules::metadata_binding(&provider, &id)), + })) +} diff --git a/desktop-tauri/src-tauri/server/src/server/api/model_meta.rs b/desktop-tauri/src-tauri/server/src/server/api/model_meta.rs index b52c76fa2..1edec6829 100644 --- a/desktop-tauri/src-tauri/server/src/server/api/model_meta.rs +++ b/desktop-tauri/src-tauri/server/src/server/api/model_meta.rs @@ -19,21 +19,18 @@ //! 出现「开关显示已开、状态却是旧的」这种中间态。 use axum::body::Bytes; -use axum::extract::State; +use axum::extract::{Query, State}; use axum::response::Response; +use serde::Deserialize; use serde_json::json; use crate::server::config; -use crate::server::core::model_meta; +use crate::server::core::{maintenance, model_meta}; use crate::server::errors; use crate::server::http::{ok_json, parse_body}; use crate::server::logging; use crate::server::ServerState; -/// 设置页「立即刷新」用的间隔(分钟):这条链路只服务手动触发,不参与定时 -/// 排期(定时那一次走 `scheduled_tasks` 的「模型目录刷新」任务)。 -const MANUAL_INTERVAL_MINUTES: i64 = 1440; - fn metadata_json() -> serde_json::Value { let mut payload = model_meta::status(); if let Some(object) = payload.as_object_mut() { @@ -88,16 +85,34 @@ pub async fn put_metadata(State(_state): State, body: Bytes) -> Res /// `failed`),与 `/api/models/refresh` 同一口径 —— 「跑完了但这次没刷」是常态 /// (未到间隔 / 已在设置里关闭),不是需要调用方解析错误体的异常。 pub async fn refresh_metadata(State(_state): State) -> Response { - let row = model_meta::refresh(true, MANUAL_INTERVAL_MINUTES * 60_000).await; - let status = row.get("status").and_then(serde_json::Value::as_str).unwrap_or(""); + // 手动刷新与自动轮次共用同一份持久化排期,必须使用当前任务间隔收尾, + // 否则一次手动刷新会把下一次自动刷新推迟到固定的一天后。 + let row = model_meta::refresh(true, maintenance::interval(maintenance::MODEL)).await; + let status = row + .get("status") + .and_then(serde_json::Value::as_str) + .unwrap_or(""); if status == "failed" { logging::log( "[Models]", &format!( "models.dev 元数据刷新失败:{}", - row.get("message").and_then(serde_json::Value::as_str).unwrap_or("未知原因") + row.get("message") + .and_then(serde_json::Value::as_str) + .unwrap_or("未知原因") ), ); } ok_json(json!({ "result": row, "status": metadata_json() })) } + +#[derive(Deserialize)] +pub struct SearchQuery { + #[serde(default)] + query: String, +} + +/// GET /api/models/metadata/search —— 手动绑定弹窗按 canonical 标识或名称搜索。 +pub async fn search_models(Query(query): Query) -> Response { + ok_json(json!({ "models": model_meta::search(&query.query) })) +} diff --git a/desktop-tauri/src-tauri/server/src/server/core/agents/catalog.rs b/desktop-tauri/src-tauri/server/src/server/core/agents/catalog.rs index ddf04a73f..e8f93a64c 100644 --- a/desktop-tauri/src-tauri/server/src/server/core/agents/catalog.rs +++ b/desktop-tauri/src-tauri/server/src/server/core/agents/catalog.rs @@ -35,7 +35,8 @@ fn scoped_models(store: &AccountStore, scope: Option<&KeyScope>) -> Vec { return providers::catalog::manifest_for_provider(provider); } custom_providers::models_of(provider).into_iter().map(|mut model| { - model_meta::apply(&mut model); + let id = model_id(&model); + model_meta::apply(provider, &mut model, Some(&id)); let overrides = capability::normalize_object(model.get("capabilities")); capability::apply_overrides(&mut model, &overrides); model diff --git a/desktop-tauri/src-tauri/server/src/server/core/custom_providers/bindings.rs b/desktop-tauri/src-tauri/server/src/server/core/custom_providers/bindings.rs index 5a6967795..c2945eb87 100644 --- a/desktop-tauri/src-tauri/server/src/server/core/custom_providers/bindings.rs +++ b/desktop-tauri/src-tauri/server/src/server/core/custom_providers/bindings.rs @@ -255,15 +255,17 @@ pub fn public_models(provider: &Value) -> Vec { let Some((target, _)) = resolve(provider, &name) else { continue; }; + let target_model = models + .iter() + .find(|model| model_id(model).eq_ignore_ascii_case(&target)); let mut item = json!({ "id": name }); - let capabilities = capability::normalize_object( - models - .iter() - .find(|model| model_id(model).eq_ignore_ascii_case(&target)) - .and_then(|model| model.get("capabilities")), - ); + if let Some(name) = target_model.and_then(|model| model.get("name")) { + item["name"] = name.clone(); + } + let capabilities = + capability::normalize_object(target_model.and_then(|model| model.get("capabilities"))); capability::apply_overrides(&mut item, &capabilities); - model_meta::apply(&mut item); + model_meta::apply(text(provider, "id"), &mut item, Some(&target)); result.push(item); } result diff --git a/desktop-tauri/src-tauri/server/src/server/core/model_meta/fill.rs b/desktop-tauri/src-tauri/server/src/server/core/model_meta/fill.rs index f421bcda9..04432603a 100644 --- a/desktop-tauri/src-tauri/server/src/server/core/model_meta/fill.rs +++ b/desktop-tauri/src-tauri/server/src/server/core/model_meta/fill.rs @@ -29,6 +29,7 @@ use serde_json::{json, Map, Value}; use super::snapshot::{self, Record}; +use crate::server::core::model_rules; /// 六键与 models.dev 字段的对应关系(顺序与 `capability::KEYS` 一致)。 /// @@ -58,9 +59,28 @@ const LEVEL_KEYS: [&str; 3] = ["reasoningLevels", "reasoningEfforts", "efforts"] /// `providers::catalog::manifest_for`、自定义家的 /// `custom_providers::bindings::public_models`。收在两处就不可能出现「管理页 /// 改了、下游没变」那种分叉(与能力位覆盖层同一理由)。 -pub fn apply(item: &mut Value) { - let Some(id) = item.get("id").and_then(Value::as_str).map(str::to_string) else { return }; - let record = snapshot::lookup(&id); +pub fn apply(provider: &str, item: &mut Value, identity_id: Option<&str>) { + let Some(id) = item.get("id").and_then(Value::as_str) else { + return; + }; + let identity_id = identity_id.unwrap_or(id).trim(); + let display_name = item + .get("name") + .and_then(Value::as_str) + .map(str::trim) + .filter(|name| !name.is_empty()); + let binding = model_rules::metadata_binding(provider, identity_id); + let resolution = resolve(provider, item, identity_id, display_name, binding.as_ref()); + let (record, match_source, candidates) = match resolution { + Resolution::Matched(record, source) => (Some(record), source, 0), + Resolution::Ambiguous(count) => (None, "ambiguous", count), + Resolution::Stale => (None, "stale", 0), + Resolution::Unmatched => ( + None, + "unmatched", + snapshot::candidate_count(id, display_name), + ), + }; let mut sources: Map = Map::new(); if let Some(record) = &record { if let Some(object) = item.as_object_mut() { @@ -95,7 +115,11 @@ pub fn apply(item: &mut Value) { } else if let Some(object) = item.as_object() { // 没收录也要把上游声明记下来:管理页据此说明「这个值来自上游」, // 而不是显示成「没数据」。 - for key in BOOL_BRIDGE.iter().map(|(key, _)| *key).chain(TOKEN_BRIDGE.iter().map(|(key, _)| *key)) { + for key in BOOL_BRIDGE + .iter() + .map(|(key, _)| *key) + .chain(TOKEN_BRIDGE.iter().map(|(key, _)| *key)) + { if object.get(key).is_some_and(|value| !value.is_null()) { sources.insert(key.to_string(), json!("upstream")); } @@ -107,55 +131,204 @@ pub fn apply(item: &mut Value) { } } normalize_levels(item, record.as_ref()); + if let Some(object) = item.as_object_mut() { + if let Some(record) = &record { + object.insert("modelsDevId".to_string(), json!(record.canonical)); + } + object.insert("modelsDevMatch".to_string(), json!(match_source)); + if candidates > 0 { + object.insert("modelsDevCandidates".to_string(), json!(candidates)); + } + if let Some(binding) = binding.as_ref() { + object.insert( + "modelsDevBinding".to_string(), + json!({ + "modelsDevId": binding.models_dev_id, + "sourceName": binding.source_name, + "matchByNames": binding.match_by_names, + }), + ); + } + } +} + +enum Resolution { + Matched(Record, &'static str), + Ambiguous(usize), + Stale, + Unmatched, +} + +fn resolve( + provider: &str, + item: &Value, + identity_id: &str, + display_name: Option<&str>, + binding: Option<&model_rules::MetadataBinding>, +) -> Resolution { + if let Some(explicit) = binding.and_then(|binding| binding.models_dev_id.as_deref()) { + return snapshot::by_canonical(explicit) + .map(|record| Resolution::Matched(record, "manual")) + .unwrap_or(Resolution::Stale); + } + if let Some(explicit) = item + .get("modelsDevId") + .and_then(Value::as_str) + .or_else(|| item.get("canonicalModelId").and_then(Value::as_str)) + .or_else(|| item.get("canonical_model_id").and_then(Value::as_str)) + .map(str::trim) + .filter(|id| !id.is_empty()) + { + return snapshot::by_canonical(explicit) + .map(|record| Resolution::Matched(record, "adapter")) + .unwrap_or(Resolution::Stale); + } + if let Some(record) = snapshot::by_canonical(identity_id) { + return Resolution::Matched(record, "canonical"); + } + if let Some(record) = snapshot::by_key_unique(identity_id) { + return Resolution::Matched(record, "id"); + } + + if let Some(name) = display_name { + let Some(normalized) = snapshot::normalize_name(name) else { + return Resolution::Unmatched; + }; + let mut targets = std::collections::BTreeSet::new(); + for binding in model_rules::metadata_bindings_for(provider) { + let Some(models_dev_id) = binding.models_dev_id else { + continue; + }; + let source_matches = binding + .source_name + .as_deref() + .and_then(snapshot::normalize_name) + .is_some_and(|source| source == normalized); + let canonical_matches = snapshot::by_canonical(&models_dev_id) + .and_then(|record| record.name) + .as_deref() + .and_then(snapshot::normalize_name) + .is_some_and(|canonical| canonical == normalized); + if source_matches || canonical_matches { + targets.insert(models_dev_id); + } + } + match targets.len() { + 1 => { + if let Some(record) = targets + .into_iter() + .next() + .and_then(|id| snapshot::by_canonical(&id)) + { + return Resolution::Matched(record, "name"); + } + } + count if count > 1 => return Resolution::Ambiguous(count), + _ => {} + } + if let Some(record) = snapshot::by_name_unique(name) { + return Resolution::Matched(record, "name"); + } + } + Resolution::Unmatched } /// 归一逐模型档位(原键只读,见模块头)。 fn normalize_levels(item: &mut Value, record: Option<&Record>) { - let declared = item.pointer("/reasoning/supportedEfforts").and_then(levels_of) - .or_else(|| LEVEL_KEYS.iter().find_map(|key| item.get(*key).and_then(levels_of))); - let binding_supported = item.get("reasoningBindingSupported").and_then(Value::as_bool); - let fixed = if binding_supported == Some(false) { None } else { - item.get("reasoningFixedLevel").and_then(level_text).or_else(|| { - if declared.as_ref().is_some_and(|levels| !levels.is_empty()) { return None; } - item.pointer("/reasoning/effort").and_then(level_text) - }) + let declared = item + .pointer("/reasoning/supportedEfforts") + .and_then(levels_of) + .or_else(|| { + LEVEL_KEYS + .iter() + .find_map(|key| item.get(*key).and_then(levels_of)) + }); + let binding_supported = item + .get("reasoningBindingSupported") + .and_then(Value::as_bool); + let fixed = if binding_supported == Some(false) { + None + } else { + item.get("reasoningFixedLevel") + .and_then(level_text) + .or_else(|| { + if declared.as_ref().is_some_and(|levels| !levels.is_empty()) { + return None; + } + item.pointer("/reasoning/effort").and_then(level_text) + }) }; let explicit = declared.is_some() || fixed.is_some() || binding_supported == Some(false); - let levels = if binding_supported == Some(false) { Vec::new() } - else if let Some(fixed) = &fixed { + let levels = if binding_supported == Some(false) { + Vec::new() + } else if let Some(fixed) = &fixed { vec![fixed.clone()] } else if explicit { declared.unwrap_or_default() } else { - record.map(|record| record.levels.clone()).unwrap_or_default() + record + .map(|record| record.levels.clone()) + .unwrap_or_default() }; - let default = item.get("reasoningDefaultLevel").and_then(level_text) - .or_else(|| item.pointer("/reasoning/defaultEffort").and_then(level_text)) + let default = item + .get("reasoningDefaultLevel") + .and_then(level_text) + .or_else(|| { + item.pointer("/reasoning/defaultEffort") + .and_then(level_text) + }) .or_else(|| item.pointer("/reasoning/effort").and_then(level_text)) .and_then(|value| { - if levels.is_empty() && item.get("supportsReasoning").and_then(Value::as_bool) != Some(false) { Some(value) } - else { levels.iter().find(|level| level.eq_ignore_ascii_case(&value)).cloned() } - }).or_else(|| fixed.clone()); - let source = item.get("reasoningSource").and_then(Value::as_str) + if levels.is_empty() + && item.get("supportsReasoning").and_then(Value::as_bool) != Some(false) + { + Some(value) + } else { + levels + .iter() + .find(|level| level.eq_ignore_ascii_case(&value)) + .cloned() + } + }) + .or_else(|| fixed.clone()); + let source = item + .get("reasoningSource") + .and_then(Value::as_str) .filter(|source| matches!(*source, "upstream" | "modelsdev" | "undeclared")) .map(str::to_string) .unwrap_or_else(|| { - (if explicit { "upstream" } else if !levels.is_empty() { "modelsdev" } else { "undeclared" }) - .to_string() + (if explicit { + "upstream" + } else if !levels.is_empty() { + "modelsdev" + } else { + "undeclared" + }) + .to_string() }); - let Some(object) = item.as_object_mut() else { return }; + let Some(object) = item.as_object_mut() else { + return; + }; if explicit || !levels.is_empty() { object.insert("reasoningLevels".into(), json!(levels)); } object.remove("reasoningDefaultLevel"); object.remove("reasoningFixedLevel"); - if let Some(default) = default { object.insert("reasoningDefaultLevel".into(), json!(default)); } - if let Some(fixed) = fixed { object.insert("reasoningFixedLevel".into(), json!(fixed)); } + if let Some(default) = default { + object.insert("reasoningDefaultLevel".into(), json!(default)); + } + if let Some(fixed) = fixed { + object.insert("reasoningFixedLevel".into(), json!(fixed)); + } object.insert("reasoningSource".into(), json!(source)); } fn level_text(value: &Value) -> Option { - value.as_str().map(str::trim).filter(|value| !value.is_empty()).map(str::to_string) + value + .as_str() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) } /// 从清单项的一个档位键里读出档位表(非数组 / 空串 / 数字一律丢弃)。 @@ -183,7 +356,9 @@ pub fn mark_user(item: &mut Value, keys: &[String]) { if keys.is_empty() { return; } - let Some(object) = item.as_object_mut() else { return }; + let Some(object) = item.as_object_mut() else { + return; + }; let mut sources = object .get("capSources") .and_then(Value::as_object) diff --git a/desktop-tauri/src-tauri/server/src/server/core/model_meta/mod.rs b/desktop-tauri/src-tauri/server/src/server/core/model_meta/mod.rs index b1fe55bf5..32cfa16cd 100644 --- a/desktop-tauri/src-tauri/server/src/server/core/model_meta/mod.rs +++ b/desktop-tauri/src-tauri/server/src/server/core/model_meta/mod.rs @@ -15,6 +15,12 @@ //! models.dev 只补「上游没声明」的键;上游声明过的键一律不动,用户覆盖层仍然 //! 排最后(它在调用方之后执行)。理由见 `fill` 的模块头。 //! +//! ── 模型身份解析 ──────────────────────────────────────────── +//! 模型 ID 不保证可读,`sn-*`、`default-1.2` 这类名字不能靠 ID 形态猜语义。 +//! 因此先按「用户精确绑定 > 适配器 `modelsDevId` > canonical / 精确 ID > +//! 同提供商名称对齐 > 全局唯一名称」解析身份,同名冲突或无候选时保持未声明; +//! 名称只负责找到唯一候选,不参与路由,也不推翻上游已声明的能力。 +//! //! ── 数据从哪来(三层,见 `snapshot`)──────────────────────── //! `builtin` 仓库里检入的精简快照(`modelsdev.json`,由 //! `scripts/sync-modelsdev.mjs` 生成,构建时进二进制)—— @@ -47,7 +53,7 @@ mod snapshot; pub use fill::{apply, mark_user}; pub use refresh::refresh; -pub use snapshot::{install, status}; +pub use snapshot::{install, search, status}; /// 排期键里的 scope(与各家 `modelRefresh:` 同一命名空间)。 pub(crate) use refresh::SCHEDULE_SCOPE; diff --git a/desktop-tauri/src-tauri/server/src/server/core/model_meta/refresh.rs b/desktop-tauri/src-tauri/server/src/server/core/model_meta/refresh.rs index 85e604e56..f82c87ed1 100644 --- a/desktop-tauri/src-tauri/server/src/server/core/model_meta/refresh.rs +++ b/desktop-tauri/src-tauri/server/src/server/core/model_meta/refresh.rs @@ -24,7 +24,9 @@ //! 越过冷却(`ManualBackoff::Bypass`:用户按按钮往往正是刚把网络问题修好)。 use std::collections::HashMap; +use std::io::Read; +use flate2::read::GzDecoder; use serde_json::{json, Value}; use crate::server::config; @@ -37,8 +39,9 @@ use super::snapshot::{self, Record, Snapshot}; const API_URL: &str = "https://models.dev/api.json"; const MODELS_URL: &str = "https://models.dev/models.json"; -/// 单次请求总超时(api.json 有几 MB,给足一分钟;超时即失败、不产半份索引)。 -const REQUEST_TIMEOUT_MS: u64 = 60_000; +/// 单次请求总超时(两个官方文件都较大,国内网络经代理经常需要一分钟以上; +/// 与 `scripts/sync-modelsdev.mjs` 保持两分钟,超时即失败、不产半份索引)。 +const REQUEST_TIMEOUT_MS: u64 = 120_000; /// 两次刷新之间的最小间隔(防连点 / 防两个进程并排打;手动也不越过)。 const MIN_GAP_MS: i64 = 5_000; @@ -65,7 +68,11 @@ pub async fn refresh(manual: bool, interval_ms: i64) -> Value { row["message"] = json!("已在设置里关闭 models.dev 模型元数据"); return row; } - let backoff = if manual { ManualBackoff::Bypass } else { ManualBackoff::Respect }; + let backoff = if manual { + ManualBackoff::Bypass + } else { + ManualBackoff::Respect + }; let guard = match task_state::claim( &format!("modelRefresh:{SCHEDULE_SCOPE}"), interval_ms, @@ -80,7 +87,11 @@ pub async fn refresh(manual: bool, interval_ms: i64) -> Value { // 会与「按钮其实能立刻重试」自相矛盾 —— 与各家目录刷新同一口径, // 见 `providers::catalog_refresh::deferred_message` 的说明。 row["message"] = json!(if state.retry_at > logging::now_ms() || state.running() { - if manual { state.waiting_message_bypassing() } else { state.waiting_message() } + if manual { + state.waiting_message_bypassing() + } else { + state.waiting_message() + } } else { "沿用缓存,尚未到刷新时间".to_string() }); @@ -126,26 +137,68 @@ async fn fetch_and_reduce() -> Result<(Snapshot, usize), String> { async fn fetch_json(url: &str) -> Result { let response = client::fetch_public( url, - &[("accept".to_string(), "application/json".to_string())], + &[ + ("accept".to_string(), "application/json".to_string()), + // `models.dev` 的两个官方文件较大,节点通常支持 gzip; + // 网关的 reqwest 未启用自动解压特性,因此在读完响应后显式解码 + ("accept-encoding".to_string(), "gzip".to_string()), + ], Some(REQUEST_TIMEOUT_MS), ) .await?; if !response.status().is_success() { return Err(format!("{url} 返回 HTTP {}", response.status().as_u16())); } - response.json::().await.map_err(|error| format!("{url} 响应不是合法 JSON:{error}")) + let content_encoding = response + .headers() + .get(reqwest::header::CONTENT_ENCODING) + .and_then(|value| value.to_str().ok()) + .unwrap_or("") + .to_ascii_lowercase(); + let bytes = response.bytes().await.map_err(|error| { + if error.is_timeout() { + format!("{url} 响应读取超时(超过 {} 秒)", REQUEST_TIMEOUT_MS / 1000) + } else { + format!("{url} 响应读取失败:{error}") + } + })?; + decode_json_body(url, &content_encoding, bytes.as_ref()) +} + +fn decode_json_body( + url: &str, + content_encoding: &str, + bytes: &[u8], +) -> Result { + let decoded = match content_encoding.trim() { + "" | "identity" => bytes.to_vec(), + "gzip" => { + let mut decoded = Vec::new(); + GzDecoder::new(bytes) + .read_to_end(&mut decoded) + .map_err(|error| format!("{url} gzip 响应解压失败:{error}"))?; + decoded + } + encoding => return Err(format!("{url} 返回不支持的压缩编码:{encoding}")), + }; + serde_json::from_slice::(&decoded).map_err(|error| { + format!( + "{url} 响应不是合法 JSON(收到 {} 字节):{error}", + decoded.len() + ) + }) } /// 两份官方 JSON → 精简索引(与 `scripts/sync-modelsdev.mjs` 逐字同口径)。 fn reduce(api: &Value, models: &Value) -> Option { - let mut index: HashMap = HashMap::new(); + let mut records: Vec = Vec::new(); + let mut by_canonical: HashMap = HashMap::new(); // ① canonical 条目的能力位与上下限 for (canonical, entry) in models.as_object()? { if !is_chat_model(entry) { continue; } - let key = snapshot::key_of(canonical); - if key.is_empty() { + if snapshot::key_of(canonical).is_empty() { continue; } let input = entry @@ -154,26 +207,32 @@ fn reduce(api: &Value, models: &Value) -> Option { .cloned() .unwrap_or_default(); let has = |name: &str| input.iter().any(|item| item.as_str() == Some(name)); - index.insert( - key, - Record { - canonical: canonical.clone(), - reasoning: entry.get("reasoning").and_then(Value::as_bool), - tool_call: entry.get("tool_call").and_then(Value::as_bool), - images: Some(has("image")), - video: Some(has("video")), - context: limit_of(entry.pointer("/limit/context")), - output: limit_of(entry.pointer("/limit/output")), - levels: Vec::new(), - }, - ); + by_canonical.insert(canonical.to_ascii_lowercase(), records.len()); + records.push(Record { + canonical: canonical.clone(), + name: entry + .get("name") + .and_then(Value::as_str) + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(str::to_string), + reasoning: entry.get("reasoning").and_then(Value::as_bool), + tool_call: entry.get("tool_call").and_then(Value::as_bool), + images: Some(has("image")), + video: Some(has("video")), + context: limit_of(entry.pointer("/limit/context")), + output: limit_of(entry.pointer("/limit/output")), + levels: Vec::new(), + }); } // ② provider 条目的命名档位(lab 家优先,其次 provider id 字典序) - let mut picks: HashMap, bool)> = HashMap::new(); + let mut picks: HashMap, bool, String)> = HashMap::new(); let mut providers: Vec<(&String, &Value)> = api.as_object()?.iter().collect(); providers.sort_by(|(left, _), (right, _)| left.cmp(right)); for (provider_id, provider) in providers { - let Some(entries) = provider.get("models").and_then(Value::as_object) else { continue }; + let Some(entries) = provider.get("models").and_then(Value::as_object) else { + continue; + }; for entry in entries.values() { let canonical = entry .get("canonical_model_id") @@ -181,29 +240,33 @@ fn reduce(api: &Value, models: &Value) -> Option { .filter(|value| !value.is_empty()) .or_else(|| entry.get("id").and_then(Value::as_str)) .unwrap_or_default(); - let key = snapshot::key_of(canonical); - if key.is_empty() { + if snapshot::key_of(canonical).is_empty() { continue; } - let Some(levels) = effort_levels(entry) else { continue }; + let pick_key = canonical.to_ascii_lowercase(); + let Some(levels) = effort_levels(entry) else { + continue; + }; let is_lab = canonical.split('/').next() == Some(provider_id.as_str()); - match picks.get(&key) { + match picks.get(&pick_key) { // 实验室自述的那条优先;两条都不是 lab 时按 provider 名序取第一条 - Some((_, true)) => continue, - Some((_, false)) if !is_lab => continue, - _ => picks.insert(key, (levels, is_lab)), + Some((_, true, _)) => continue, + Some((_, false, _)) if !is_lab => continue, + _ => picks.insert(pick_key, (levels, is_lab, canonical.to_string())), }; } } - for (key, (levels, _)) in picks { - if let Some(record) = index.get_mut(&key) { - record.levels = levels; + for (levels, _, canonical) in picks.into_values() { + if let Some(index) = by_canonical.get(&canonical.to_ascii_lowercase()).copied() { + if let Some(record) = records.get_mut(index) { + record.levels = levels; + } } } - if index.is_empty() { + if records.is_empty() { return None; } - Some(Snapshot::new(index, logging::now_ms(), "network")) + Some(Snapshot::new(records, logging::now_ms(), "network")) } /// 正整数上限,其余(0 / 负数 / 小数 / 非数字)→ `None`(=「这份数据没说」)。 @@ -222,7 +285,10 @@ fn is_chat_model(entry: &Value) -> bool { return false; } } - match entry.pointer("/modalities/output").and_then(Value::as_array) { + match entry + .pointer("/modalities/output") + .and_then(Value::as_array) + { Some(output) => output.iter().any(|item| item.as_str() == Some("text")), None => true, } @@ -246,3 +312,29 @@ fn effort_levels(entry: &Value) -> Option> { .collect(); (!levels.is_empty()).then_some(levels) } + +#[cfg(test)] +mod tests { + use super::*; + use flate2::write::GzEncoder; + use flate2::Compression; + use std::io::Write; + + #[test] + fn gzip_json_is_decoded_before_parsing() { + let body = br#"{"models":{"demo/model":{"name":"Demo"}}}"#; + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(body).expect("gzip input"); + let compressed = encoder.finish().expect("gzip finish"); + let value = decode_json_body("https://models.dev/models.json", "gzip", &compressed) + .expect("gzip JSON should decode"); + assert_eq!(value["models"]["demo/model"]["name"], "Demo"); + } + + #[test] + fn unsupported_content_encoding_is_reported() { + let error = decode_json_body("https://models.dev/models.json", "br", b"{}") + .expect_err("unsupported encoding must fail"); + assert!(error.contains("不支持的压缩编码")); + } +} diff --git a/desktop-tauri/src-tauri/server/src/server/core/model_meta/snapshot.rs b/desktop-tauri/src-tauri/server/src/server/core/model_meta/snapshot.rs index 63772287d..b341f0fcf 100644 --- a/desktop-tauri/src-tauri/server/src/server/core/model_meta/snapshot.rs +++ b/desktop-tauri/src-tauri/server/src/server/core/model_meta/snapshot.rs @@ -53,6 +53,8 @@ pub const KV_KEY: &str = "modelsDevCatalog"; pub struct Record { /// canonical id(`zhipuai/glm-5.3`)—— 日志与排障用,网关不据此路由 pub canonical: String, + /// models.dev 展示名,只用于名称对齐候选,不参与路由 + pub name: Option, pub reasoning: Option, pub tool_call: Option, pub images: Option, @@ -66,7 +68,10 @@ pub struct Record { /// 一份索引 + 它的来历。 pub struct Snapshot { - models: HashMap, + models: Vec, + by_canonical: HashMap, + by_key: HashMap>, + by_name: HashMap>, /// 这份数据的生成时刻(models.dev 侧没有版本号,用抓取时刻代替) generated_at: i64, /// `builtin` / `cache` / `network`(管理页与日志据此说明数据新旧) @@ -75,14 +80,100 @@ pub struct Snapshot { impl Snapshot { /// 组装一份索引(`refresh` 压缩完两份官方 JSON 后调用;`source` 传 `network`)。 - pub fn new(models: HashMap, generated_at: i64, source: &'static str) -> Self { - Self { models, generated_at, source } + pub fn new(models: Vec, generated_at: i64, source: &'static str) -> Self { + let mut by_canonical = HashMap::with_capacity(models.len()); + let mut by_key: HashMap> = HashMap::new(); + let mut by_name: HashMap> = HashMap::new(); + for (index, record) in models.iter().enumerate() { + by_canonical + .entry(record.canonical.trim().to_ascii_lowercase()) + .or_insert(index); + by_key + .entry(key_of(&record.canonical)) + .or_default() + .push(index); + if let Some(name) = record.name.as_deref().and_then(normalize_name) { + by_name.entry(name).or_default().push(index); + } + } + Self { + models, + by_canonical, + by_key, + by_name, + generated_at, + source, + } } /// 收录的模型条数(刷新结果里的 `count`)。 pub fn len(&self) -> usize { self.models.len() } + + /// 按 canonical 标识精确取一条记录。 + pub fn by_canonical(&self, canonical: &str) -> Option { + let key = canonical.trim().to_ascii_lowercase(); + self.by_canonical + .get(&key) + .map(|index| self.models[*index].clone()) + } + + /// 按归一化模型 ID 取唯一记录;多个候选时返回 `None`,由调用方标记歧义。 + pub fn by_key_unique(&self, model: &str) -> Option { + let key = key_of(model.trim()); + let indexes = self.by_key.get(&key)?; + (indexes.len() == 1).then(|| self.models[indexes[0]].clone()) + } + + /// 按归一化模型名取唯一记录;多个同名模型时返回 `None`。 + pub fn by_name_unique(&self, name: &str) -> Option { + let Some(name) = normalize_name(name) else { + return None; + }; + let indexes = self.by_name.get(&name)?; + (indexes.len() == 1).then(|| self.models[indexes[0]].clone()) + } + + /// 名称或 ID 的候选数量,仅用于管理页解释“为什么没有自动补齐”。 + pub fn candidate_count(&self, model: &str, name: Option<&str>) -> usize { + let id_count = self.by_key.get(&key_of(model.trim())).map_or(0, Vec::len); + let name_count = name + .and_then(normalize_name) + .and_then(|name| self.by_name.get(&name)) + .map_or(0, Vec::len); + id_count.max(name_count) + } + + /// 搜索 canonical 标识和展示名,供手动绑定弹窗选择。 + pub fn search(&self, query: &str, limit: usize) -> Vec { + let needle = query.trim().to_ascii_lowercase(); + let mut rows = self + .models + .iter() + .filter(|record| { + needle.is_empty() + || record.canonical.to_ascii_lowercase().contains(&needle) + || record + .name + .as_deref() + .is_some_and(|name| name.to_ascii_lowercase().contains(&needle)) + }) + .map(|record| { + serde_json::json!({ + "id": record.canonical, + "name": record.name, + }) + }) + .collect::>(); + rows.sort_by(|left, right| { + let left_id = left.get("id").and_then(Value::as_str).unwrap_or(""); + let right_id = right.get("id").and_then(Value::as_str).unwrap_or(""); + left_id.cmp(right_id) + }); + rows.truncate(limit.min(100)); + rows + } } /// 进程级快照(懒装载)。`None` 只在「还没读过」时出现;一旦读过就不会退回。 @@ -118,12 +209,24 @@ pub fn key_of(id: &str) -> String { /// /// 热路径:每次清单成型对每个模型调用一次,索引在内存里,这里是一次哈希查找 /// 加一次小记录克隆。 -pub fn lookup(model: &str) -> Option { - let key = key_of(model.trim()); - if key.is_empty() { - return None; - } - snapshot().models.get(&key).cloned() +pub fn by_canonical(canonical: &str) -> Option { + snapshot().by_canonical(canonical) +} + +pub fn by_key_unique(model: &str) -> Option { + snapshot().by_key_unique(model) +} + +pub fn by_name_unique(name: &str) -> Option { + snapshot().by_name_unique(name) +} + +pub fn candidate_count(model: &str, name: Option<&str>) -> usize { + snapshot().candidate_count(model, name) +} + +pub fn search(query: &str) -> Vec { + snapshot().search(query, 50) } /// 当前索引的状态(管理页与刷新结果展示;**不含**任何体积大的内容)。 @@ -170,8 +273,11 @@ fn load() -> Snapshot { match parse(EMBEDDED, "builtin") { Some(snapshot) => snapshot, None => { - logging::log("[Models]", "内嵌的 models.dev 快照解析失败(模型元数据将不可用,请重新构建)"); - Snapshot { models: HashMap::new(), generated_at: 0, source: "builtin" } + logging::log( + "[Models]", + "内嵌的 models.dev 快照解析失败(模型元数据将不可用,请重新构建)", + ); + Snapshot::new(Vec::new(), 0, "builtin") } } } @@ -191,20 +297,26 @@ pub fn replace(snapshot: Snapshot) { pub fn parse(text: &str, source: &'static str) -> Option { let root: Value = serde_json::from_str(text).ok()?; let object = root.get("models")?.as_object()?; - let mut models = HashMap::with_capacity(object.len()); + let mut models = Vec::with_capacity(object.len()); for (key, value) in object { - if let Some(record) = parse_record(value) { - models.insert(key.clone(), record); + let Some(mut record) = parse_record(value) else { + continue; + }; + if record.canonical.trim().is_empty() { + record.canonical = key.clone(); + } + if !record.canonical.trim().is_empty() { + models.push(record); } } if models.is_empty() { return None; } - Some(Snapshot { + Some(Snapshot::new( models, - generated_at: root.get("generatedAt").and_then(Value::as_i64).unwrap_or(0), + root.get("generatedAt").and_then(Value::as_i64).unwrap_or(0), source, - }) + )) } /// 序列化成文件形态(刷新落库用;与 [`parse`] 严格互逆)。 @@ -214,14 +326,34 @@ pub fn parse(text: &str, source: &'static str) -> Option { /// 遍历序会让每次刷新的键序都不一样,肉眼比对时全是噪音)。 pub fn serialize(snapshot: &Snapshot) -> String { let mut models = serde_json::Map::new(); - let mut keys: Vec<&String> = snapshot.models.keys().collect(); - keys.sort(); - for key in keys { - models.insert(key.clone(), record_value(&snapshot.models[key])); + let mut records = snapshot.models.iter().collect::>(); + records.sort_by(|left, right| left.canonical.cmp(&right.canonical)); + let mut used: HashMap = HashMap::new(); + for record in records { + let base = key_of(&record.canonical); + let base = if base.is_empty() { + "model".to_string() + } else { + base + }; + let count = used.entry(base.clone()).or_default(); + let key = if *count == 0 { + base + } else { + format!("{base}~{}", *count + 1) + }; + *count += 1; + models.insert(key, record_value(record)); } Value::Object(serde_json::Map::from_iter([ - ("generatedAt".to_string(), Value::from(snapshot.generated_at)), - ("source".to_string(), Value::String(snapshot.source.to_string())), + ( + "generatedAt".to_string(), + Value::from(snapshot.generated_at), + ), + ( + "source".to_string(), + Value::String(snapshot.source.to_string()), + ), ("models".to_string(), Value::Object(models)), ])) .to_string() @@ -229,7 +361,13 @@ pub fn serialize(snapshot: &Snapshot) -> String { fn record_value(record: &Record) -> Value { let mut object = serde_json::Map::new(); - object.insert("canonical".to_string(), Value::String(record.canonical.clone())); + object.insert( + "canonical".to_string(), + Value::String(record.canonical.clone()), + ); + if let Some(name) = &record.name { + object.insert("name".to_string(), Value::String(name.clone())); + } for (key, value) in [ ("reasoning", record.reasoning), ("toolCall", record.tool_call), @@ -256,12 +394,22 @@ fn record_value(record: &Record) -> Value { fn parse_record(value: &Value) -> Option { let object = value.as_object()?; - let canonical = object.get("canonical").and_then(Value::as_str).unwrap_or_default().to_string(); + let canonical = object + .get("canonical") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); if canonical.is_empty() { return None; } Some(Record { canonical, + name: object + .get("name") + .and_then(Value::as_str) + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(str::to_string), reasoning: object.get("reasoning").and_then(Value::as_bool), tool_call: object.get("toolCall").and_then(Value::as_bool), images: object.get("images").and_then(Value::as_bool), @@ -284,6 +432,16 @@ fn parse_record(value: &Value) -> Option { }) } +/// 名称归一:大小写、空白和常见连接符不参与比较。 +pub fn normalize_name(name: &str) -> Option { + let normalized = name + .chars() + .filter(|ch| !ch.is_whitespace() && !matches!(ch, '-' | '_' | '.' | '/' | '\\')) + .collect::() + .to_ascii_lowercase(); + (!normalized.is_empty()).then_some(normalized) +} + /// 读库里的刷新结果(库不可用 / 没写过 / 值坏了 → `None`,由调用方回落地板)。 fn read_cache() -> Option { let db = db()?; @@ -303,12 +461,20 @@ pub fn write_cache(text: &str) { let Some(db) = db() else { return }; let written = db.with(|conn| write_text(conn, text)).unwrap_or(false); if !written { - logging::verbose("[Models]", "models.dev 缓存写入失败:下次启动将回落到内嵌快照"); + logging::verbose( + "[Models]", + "models.dev 缓存写入失败:下次启动将回落到内嵌快照", + ); } } fn read_text(conn: &Connection) -> Option { - conn.query_row("SELECT value FROM kv WHERE key = ?1", params![KV_KEY], |row| row.get(0)).ok() + conn.query_row( + "SELECT value FROM kv WHERE key = ?1", + params![KV_KEY], + |row| row.get(0), + ) + .ok() } fn write_text(conn: &Connection, text: &str) -> bool { @@ -319,3 +485,70 @@ fn write_text(conn: &Connection, text: &str) -> bool { ) .is_ok() } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn parse_indexes_names_and_keeps_collisions() { + let text = json!({ + "generatedAt": 1, + "models": { + "gpt5": { + "canonical": "openai/gpt-5", + "name": "GPT 5", + "reasoning": true + }, + "gpt5~2": { + "canonical": "other/gpt5", + "name": "GPT 5", + "reasoning": false + } + } + }) + .to_string(); + let snapshot = parse(&text, "builtin").expect("测试快照应能解析"); + assert_eq!(snapshot.len(), 2); + assert!(snapshot.by_key_unique("gpt-5").is_none()); + assert_eq!(snapshot.candidate_count("gpt-5", Some("GPT-5")), 2); + assert!(snapshot.by_canonical("openai/gpt-5").is_some()); + } + + #[test] + fn unique_name_matches_after_normalization() { + let text = json!({ + "generatedAt": 1, + "models": { + "sonnet4": { + "canonical": "anthropic/claude-sonnet-4", + "name": "Claude Sonnet 4" + } + } + }) + .to_string(); + let snapshot = parse(&text, "builtin").expect("测试快照应能解析"); + let record = snapshot + .by_name_unique("CLAUDE-SONNET 4") + .expect("名称应对齐"); + assert_eq!(record.canonical, "anthropic/claude-sonnet-4"); + } + + #[test] + fn serialize_preserves_colliding_records() { + let text = json!({ + "generatedAt": 1, + "models": { + "gpt5": { "canonical": "openai/gpt-5", "name": "GPT 5" }, + "gpt5~2": { "canonical": "other/gpt5", "name": "Other GPT" } + } + }) + .to_string(); + let snapshot = parse(&text, "network").expect("测试快照应能解析"); + let reparsed = parse(&serialize(&snapshot), "cache").expect("序列化结果应能重新解析"); + assert_eq!(reparsed.len(), 2); + assert!(reparsed.by_canonical("openai/gpt-5").is_some()); + assert!(reparsed.by_canonical("other/gpt5").is_some()); + } +} diff --git a/desktop-tauri/src-tauri/server/src/server/core/model_rules/mod.rs b/desktop-tauri/src-tauri/server/src/server/core/model_rules/mod.rs index 86b26cc12..81bbde0a2 100644 --- a/desktop-tauri/src-tauri/server/src/server/core/model_rules/mod.rs +++ b/desktop-tauri/src-tauri/server/src/server/core/model_rules/mod.rs @@ -107,9 +107,14 @@ mod mutations; mod seeds; use seeds::seed_extra_aliases; -pub use seeds::{migrate_qoder_default_enabled, migrate_workbuddy_default_enabled, seed_hidden_disabled, seed_qoder_defaults, QODER_DEFAULT_ENABLED}; +pub use seeds::{ + migrate_qoder_default_enabled, migrate_workbuddy_default_enabled, seed_hidden_disabled, + seed_qoder_defaults, QODER_DEFAULT_ENABLED, +}; -pub use mutations::{add_custom, add_mapping, remove_custom, remove_mapping, set_capabilities, set_state}; +pub use mutations::{ + add_custom, add_mapping, remove_custom, remove_mapping, set_capabilities, set_state, +}; pub const KEY_MODEL_RULES: &str = "modelRules"; @@ -185,7 +190,10 @@ pub struct RuleEntry { impl RuleEntry { fn new(provider: Option<&str>, id: &str) -> Self { - Self { provider: provider.map(str::to_string), id: id.to_string() } + Self { + provider: provider.map(str::to_string), + id: id.to_string(), + } } fn to_value(&self) -> Value { @@ -199,7 +207,10 @@ impl RuleEntry { /// None(全局条目)或与目标一致。 fn matches(&self, provider: &str, id: &str) -> bool { self.id.eq_ignore_ascii_case(id) - && self.provider.as_deref().map_or(true, |p| p.eq_ignore_ascii_case(provider)) + && self + .provider + .as_deref() + .map_or(true, |p| p.eq_ignore_ascii_case(provider)) } } @@ -245,6 +256,12 @@ pub struct CapabilityOverride { pub provider: String, pub id: String, pub values: Map, + /// 用户显式选择的 models.dev canonical 标识 + pub models_dev_id: Option, + /// 建立绑定时记录的上游展示名,只供同提供商名称对齐 + pub source_name: Option, + /// 未知模型 ID 是否可按记录名称复用此绑定 + pub match_by_names: bool, } impl CapabilityOverride { @@ -266,6 +283,15 @@ impl CapabilityOverride { for (key, value) in &self.values { object.insert(key.clone(), value.clone()); } + if let Some(id) = &self.models_dev_id { + object.insert("modelsDevId".to_string(), Value::String(id.clone())); + } + if let Some(name) = &self.source_name { + object.insert("sourceName".to_string(), Value::String(name.clone())); + } + if self.match_by_names { + object.insert("matchByNames".to_string(), Value::Bool(true)); + } Value::Object(object) } } @@ -328,7 +354,11 @@ fn entries_from(value: Option<&Value>) -> Vec { } // 新形态:{provider, id} Value::Object(object) => { - let id = object.get("id").and_then(Value::as_str).map(str::trim).unwrap_or(""); + let id = object + .get("id") + .and_then(Value::as_str) + .map(str::trim) + .unwrap_or(""); if id.is_empty() { return None; } @@ -397,7 +427,11 @@ fn capabilities_from(value: Option<&Value>) -> Vec { .iter() .filter_map(|item| { let object = item.as_object()?; - let id = object.get("id").and_then(Value::as_str).map(str::trim).unwrap_or(""); + let id = object + .get("id") + .and_then(Value::as_str) + .map(str::trim) + .unwrap_or(""); let provider = object .get("provider") .and_then(Value::as_str) @@ -407,13 +441,32 @@ fn capabilities_from(value: Option<&Value>) -> Vec { return None; } let values = crate::server::core::capability::normalize_object(Some(item)); - if values.is_empty() { + let models_dev_id = object + .get("modelsDevId") + .and_then(Value::as_str) + .map(str::trim) + .filter(|id| !id.is_empty()) + .map(str::to_string); + let source_name = object + .get("sourceName") + .and_then(Value::as_str) + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(str::to_string); + let match_by_names = object + .get("matchByNames") + .and_then(Value::as_bool) + .unwrap_or(false); + if values.is_empty() && models_dev_id.is_none() { return None; } Some(CapabilityOverride { provider: provider.to_string(), id: id.to_string(), values, + models_dev_id, + source_name, + match_by_names, }) }) .collect() @@ -498,7 +551,7 @@ impl ModelRules { // `hidden` 不再写出(机制已移除,见模块头):整份替换落盘时, // 配置里残留的 hidden 名单就此消失 —— 一次性清理的实现方式。 "mappings": self.mappings.iter().map(Mapping::to_value).collect::>(), - // `custom` 必须在这里写出:本函数是**整份替换**(`save` → + // `custom` 必须在这里写出:本函数是**整份替换**(`save` → // `config::update_raw_field`),漏掉哪个键,任何一次写规则 // (启停 / 加映射 / 删映射 / 四个种子)都会把那个键的数据整份抹掉。 "custom": self.custom.iter().map(CustomModel::to_value).collect::>(), @@ -523,15 +576,28 @@ impl ModelRules { let matches = |m: &&Mapping| { m.alias.eq_ignore_ascii_case(alias) && m.target.eq_ignore_ascii_case(target) }; - self.mappings.iter().filter(matches) - .find(|m| m.provider.as_deref().is_some_and(|owner| owner.eq_ignore_ascii_case(provider))) - .or_else(|| self.mappings.iter().filter(matches).find(|m| m.provider.is_none())) + self.mappings + .iter() + .filter(matches) + .find(|m| { + m.provider + .as_deref() + .is_some_and(|owner| owner.eq_ignore_ascii_case(provider)) + }) + .or_else(|| { + self.mappings + .iter() + .filter(matches) + .find(|m| m.provider.is_none()) + }) } /// disabled 只控制原始 ID 的默认绑定,不能连带关闭其他别名。 pub fn default_enabled(&self, provider: &str, id: &str) -> bool { !self.is_disabled(provider, id) - && self.binding(provider, id, id).map_or(true, |mapping| mapping.enabled) + && self + .binding(provider, id, id) + .map_or(true, |mapping| mapping.enabled) } pub fn is_blocked(&self, provider: &str, id: &str) -> bool { @@ -556,7 +622,9 @@ impl ModelRules { /// 该对外名是否已存在任何映射条目(种子防抢占判断用)。 pub fn has_alias(&self, alias: &str) -> bool { - self.mappings.iter().any(|m| m.alias.eq_ignore_ascii_case(alias)) + self.mappings + .iter() + .any(|m| m.alias.eq_ignore_ascii_case(alias)) } /// 某条映射的对外名:target 归属 `provider`(None = 旧版全局条目)。 @@ -573,7 +641,11 @@ impl ModelRules { self.mappings .iter() .filter(|m| m.target.eq_ignore_ascii_case(target)) - .filter(|m| m.provider.as_deref().map_or(true, |p| p.eq_ignore_ascii_case(provider))) + .filter(|m| { + m.provider + .as_deref() + .map_or(true, |p| p.eq_ignore_ascii_case(provider)) + }) .map(|m| m.alias.as_str()) .collect() } @@ -625,7 +697,11 @@ impl ModelRules { /// 那批标记对 Qoder 不构成「已种」的证据。 fn is_seeded(&self, provider: &str, id: &str) -> bool { let key = format!("{provider}:{id}"); - if self.seeded.iter().any(|item| item.eq_ignore_ascii_case(&key)) { + if self + .seeded + .iter() + .any(|item| item.eq_ignore_ascii_case(&key)) + { return true; } if !matches!(provider, "workbuddy" | "raccoon") { @@ -710,6 +786,9 @@ pub fn apply_capability_overrides(provider: &str, items: &mut [Value]) { .iter() .find(|entry| entry.matches(provider, id)) { + if entry.values.is_empty() { + continue; + } crate::server::core::capability::apply_overrides(item, &entry.values); // 来源标记:被覆盖的键从此是**用户指定**的值,既不是上游给的也不是 // models.dev 补的(管理页要把这三档分开显示,见 `core::model_meta` @@ -723,12 +802,67 @@ pub fn apply_capability_overrides(provider: &str, items: &mut [Value]) { } } +/// 用户为某条模型显式选择的 models.dev 身份。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MetadataBinding { + pub provider: String, + pub id: String, + pub models_dev_id: Option, + pub source_name: Option, + pub match_by_names: bool, +} + +/// 一次写入中的元数据绑定补丁,未提供的字段保持原值。 +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct MetadataBindingUpdate { + /// 空串 = 删除显式 models.dev 绑定 + pub models_dev_id: Option, + pub source_name: Option, + pub match_by_names: Option, +} + +/// 精确查询一条用户元数据绑定。 +pub fn metadata_binding(provider: &str, id: &str) -> Option { + shared().capabilities.iter().find_map(|entry| { + entry.matches(provider, id).then(|| MetadataBinding { + provider: entry.provider.clone(), + id: entry.id.clone(), + models_dev_id: entry.models_dev_id.clone(), + source_name: entry.source_name.clone(), + match_by_names: entry.match_by_names, + }) + }) +} + +/// 当前提供商启用了名称对齐的元数据绑定。 +pub fn metadata_bindings_for(provider: &str) -> Vec { + shared() + .capabilities + .iter() + .filter(|entry| { + entry.provider.eq_ignore_ascii_case(provider) + && entry.models_dev_id.is_some() + && entry.match_by_names + }) + .map(|entry| MetadataBinding { + provider: entry.provider.clone(), + id: entry.id.clone(), + models_dev_id: entry.models_dev_id.clone(), + source_name: entry.source_name.clone(), + match_by_names: entry.match_by_names, + }) + .collect() +} + /// 该 `(provider, id)` 是否是用户手动登记的自定义模型。 /// /// 消费方:管理页判「来源 = 手动」(`catalog::manage_entries`)、以及 AutoClaw /// 的模型路由解析(未知名字不静默回落,见 `autoclaw::models::resolve_model_route`)。 pub fn is_custom(provider: &str, id: &str) -> bool { - shared().custom.iter().any(|item| item.matches(provider, id)) + shared() + .custom + .iter() + .any(|item| item.matches(provider, id)) } /// 某一家当前登记的自定义模型,转成**聚合层认的条目形态**。 @@ -761,3 +895,37 @@ pub fn custom_models_for(provider: &str) -> Vec { }) .collect() } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn metadata_binding_roundtrips_without_capability_values() { + let raw = json!({ + KEY_MODEL_RULES: { + "capabilities": [{ + "provider": "demo", + "id": "sn-12345", + "modelsDevId": "anthropic/claude-sonnet-4", + "sourceName": "Claude Sonnet 4", + "matchByNames": true + }] + } + }); + let object = raw.as_object().expect("测试对象"); + let rules = ModelRules::from_raw(object); + assert_eq!(rules.capabilities.len(), 1); + assert!(rules.capabilities[0].values.is_empty()); + assert_eq!( + rules.capabilities[0].models_dev_id.as_deref(), + Some("anthropic/claude-sonnet-4") + ); + assert!(rules.capabilities[0].match_by_names); + let saved = rules.to_value(); + let item = &saved["capabilities"][0]; + assert_eq!(item["modelsDevId"], "anthropic/claude-sonnet-4"); + assert_eq!(item["matchByNames"], true); + } +} diff --git a/desktop-tauri/src-tauri/server/src/server/core/model_rules/mutations.rs b/desktop-tauri/src-tauri/server/src/server/core/model_rules/mutations.rs index 1bc521063..c770f498e 100644 --- a/desktop-tauri/src-tauri/server/src/server/core/model_rules/mutations.rs +++ b/desktop-tauri/src-tauri/server/src/server/core/model_rules/mutations.rs @@ -4,7 +4,7 @@ use super::bindings::{ active_conflict, enable_exclusive, normalize_name, put_binding, set_default, set_membership, Catalog, }; -use super::{CapabilityOverride, CustomModel, ModelRules, KEY_MODEL_RULES}; +use super::{CapabilityOverride, CustomModel, MetadataBindingUpdate, ModelRules, KEY_MODEL_RULES}; use crate::server::config; use crate::server::errors::GatewayError; @@ -320,6 +320,7 @@ pub fn set_capabilities( provider: &str, id: &str, patch: &Map, + binding: Option<&MetadataBindingUpdate>, ) -> Result { mutate(|rules| { let index = rules @@ -329,6 +330,14 @@ pub fn set_capabilities( let mut values = index .map(|index| rules.capabilities[index].values.clone()) .unwrap_or_default(); + let mut models_dev_id = index + .map(|index| rules.capabilities[index].models_dev_id.clone()) + .unwrap_or_default(); + let mut source_name = index + .map(|index| rules.capabilities[index].source_name.clone()) + .unwrap_or_default(); + let mut match_by_names = + index.is_some_and(|index| rules.capabilities[index].match_by_names); for (key, value) in patch { if value.is_null() { values.remove(key); @@ -337,16 +346,42 @@ pub fn set_capabilities( values.insert(key.clone(), value); } } + if let Some(update) = binding { + if let Some(next) = &update.models_dev_id { + let next = next.trim(); + if next.is_empty() { + models_dev_id = None; + source_name = None; + match_by_names = false; + } else { + models_dev_id = Some(next.to_string()); + } + } + if let Some(next) = &update.source_name { + source_name = Some(next.trim().to_string()).filter(|name| !name.is_empty()); + } + if let Some(next) = update.match_by_names { + match_by_names = next && models_dev_id.is_some(); + } + } match index { - Some(index) if values.is_empty() => { + Some(index) if values.is_empty() && models_dev_id.is_none() => { rules.capabilities.remove(index); } - Some(index) => rules.capabilities[index].values = values, - None if values.is_empty() => {} + Some(index) => { + rules.capabilities[index].values = values; + rules.capabilities[index].models_dev_id = models_dev_id; + rules.capabilities[index].source_name = source_name; + rules.capabilities[index].match_by_names = match_by_names; + } + None if values.is_empty() && models_dev_id.is_none() => {} None => rules.capabilities.push(CapabilityOverride { provider: provider.to_string(), id: id.to_string(), values, + models_dev_id, + source_name, + match_by_names, }), } Ok(rules.clone()) diff --git a/desktop-tauri/src-tauri/server/src/server/core/providers/catalog.rs b/desktop-tauri/src-tauri/server/src/server/core/providers/catalog.rs index b58127c01..275d1445a 100644 --- a/desktop-tauri/src-tauri/server/src/server/core/providers/catalog.rs +++ b/desktop-tauri/src-tauri/server/src/server/core/providers/catalog.rs @@ -57,7 +57,8 @@ pub(crate) fn manifest_for_provider(provider: &str) -> Vec { } } for item in models.iter_mut() { - model_meta::apply(item); + let id = model_id(item); + model_meta::apply(provider, item, Some(&id)); } model_rules::apply_capability_overrides(provider, &mut models); models diff --git a/desktop-tauri/src-tauri/server/src/server/core/providers/catalog/view.rs b/desktop-tauri/src-tauri/server/src/server/core/providers/catalog/view.rs index 480a7afb3..5704f84e4 100644 --- a/desktop-tauri/src-tauri/server/src/server/core/providers/catalog/view.rs +++ b/desktop-tauri/src-tauri/server/src/server/core/providers/catalog/view.rs @@ -216,6 +216,7 @@ fn push_managed_model( .find(|entry| entry.matches(provider, &id)) .map(|entry| capability::overridden_keys(&entry.values)) .unwrap_or_default(); + let metadata_binding = model_rules::metadata_binding(provider, &id); models.push(json!({ "id": id, "name": item.get("name"), "credits": item.get("credits"), "isDefault": item.get("isDefault").and_then(Value::as_bool).unwrap_or(false), @@ -234,6 +235,14 @@ fn push_managed_model( // 可信,而它们的可信度差着量级。缺失(没有这个键)= 那一项没有 // 任何来源(未声明)。 "capSources": item.get("capSources"), + "modelsDevId": item.get("modelsDevId"), + "modelsDevMatch": item.get("modelsDevMatch"), + "modelsDevCandidates": item.get("modelsDevCandidates"), + "modelsDevBinding": metadata_binding.map(|binding| json!({ + "modelsDevId": binding.models_dev_id, + "sourceName": binding.source_name, + "matchByNames": binding.match_by_names, + })), // 模型**自己能配哪些思考档位**(出口键名,由 `core::model_meta` // 在清单成型处归一:这家自己声明的 > models.dev 的 effort 列表)。 // 与上面 `mappings[].reasoning`(用户给某条映射**指定**的档位) diff --git a/desktop-tauri/src-tauri/server/src/server/core/routing_groups.rs b/desktop-tauri/src-tauri/server/src/server/core/routing_groups.rs index a8310de33..e49028928 100644 --- a/desktop-tauri/src-tauri/server/src/server/core/routing_groups.rs +++ b/desktop-tauri/src-tauri/server/src/server/core/routing_groups.rs @@ -51,7 +51,8 @@ impl RequestNeeds { fn manifest(provider: &str) -> Vec { if let Some(owner) = custom_providers::get(provider) { owner.get("models").and_then(Value::as_array).cloned().unwrap_or_default().into_iter().map(|mut item| { - super::model_meta::apply(&mut item); + let id = model_id(&item); + super::model_meta::apply(provider, &mut item, Some(&id)); let overrides = capability::normalize_object(item.get("capabilities")); capability::apply_overrides(&mut item, &overrides); item diff --git a/desktop-tauri/src-tauri/server/src/server/http.rs b/desktop-tauri/src-tauri/server/src/server/http.rs index b5acf773b..1b7268147 100644 --- a/desktop-tauri/src-tauri/server/src/server/http.rs +++ b/desktop-tauri/src-tauri/server/src/server/http.rs @@ -612,6 +612,11 @@ pub fn panel_router(state: ServerState) -> Router { get(api::model_meta::get_metadata).put(api::model_meta::put_metadata), ) .route("/api/models/metadata/refresh", post(api::model_meta::refresh_metadata)) + .route("/api/models/metadata/search", get(api::model_meta::search_models)) + .route( + "/api/models/metadata/binding", + get(api::model_manage::get_metadata_binding).post(api::model_manage::set_metadata_binding), + ) // 模型测试(模型管理页操作列的「测试」):会**真打上游、消耗额度**, // 与 /v1/chat/completions 同一量级的接口,必须挂 protected。 // 结论失败也返回 2xx(上游的错误放在响应体的 status / error 里, diff --git a/desktop-tauri/src-tauri/server/src/web_shim.rs b/desktop-tauri/src-tauri/server/src/web_shim.rs index 22b92d53b..00a3ff3c9 100644 --- a/desktop-tauri/src-tauri/server/src/web_shim.rs +++ b/desktop-tauri/src-tauri/server/src/web_shim.rs @@ -1660,6 +1660,17 @@ pub fn shim_js() -> &'static str { setModelCapabilities: function (provider, id, capabilities) { return call('POST', '/api/models/capabilities', { provider: provider, id: id, capabilities: capabilities }); }, + getModelMetadataBinding: function (provider, id) { + return call('GET', '/api/models/metadata/binding?provider=' + encodeURIComponent(provider) + + '&id=' + encodeURIComponent(id)); + }, + setModelMetadataBinding: function (provider, id, binding) { + return call('POST', '/api/models/metadata/binding', + Object.assign({ provider: provider, id: id }, binding || {})); + }, + searchModelsDev: function (query) { + return call('GET', '/api/models/metadata/search?query=' + encodeURIComponent(String(query || ''))); + }, // 模型测试:真打上游、会消耗额度;payload 原样透传(与桌面 bridge 对齐), // 各键都可选(account_id / prompt / system_prompt / reasoning / stream / test_id)。 // 结论失败也返回 2xx —— 上游的错误在返回值的 status / error 里 diff --git a/desktop-tauri/src-tauri/src/bridge.rs b/desktop-tauri/src-tauri/src/bridge.rs index 85e198c59..71070af70 100644 --- a/desktop-tauri/src-tauri/src/bridge.rs +++ b/desktop-tauri/src-tauri/src/bridge.rs @@ -326,6 +326,12 @@ const BRIDGE_JS: &str = r#" // 整表保存,见后端 handler 的说明)。`capabilities` 各键三态:不给 = 不改、 // null = 恢复清单原值、给值 = 覆盖。 setModelCapabilities: (provider, id, capabilities) => call('POST', '/api/models/capabilities', { provider, id, capabilities }), + // models.dev 手动身份绑定:精确 canonical 标识与“同提供商名称对齐”开关。 + getModelMetadataBinding: (provider, id) => + call('GET', `/api/models/metadata/binding?provider=${encodeURIComponent(provider)}&id=${encodeURIComponent(id)}`), + setModelMetadataBinding: (provider, id, binding) => + call('POST', '/api/models/metadata/binding', { provider, id, ...(binding || {}) }), + searchModelsDev: query => call('GET', `/api/models/metadata/search?query=${encodeURIComponent(String(query || ''))}`), // 模型测试:以这一行的上游模型发一次**真实**请求(走生产转发链路、 // 会消耗额度),候选被收窄到「这一家 × 指定的账号」,见后端 // `api::model_test` 的模块头。 diff --git a/desktop-tauri/ui-islands/src/islands/model-capability-dialog.tsx b/desktop-tauri/ui-islands/src/islands/model-capability-dialog.tsx index 12331bdb8..9ac523278 100644 --- a/desktop-tauri/ui-islands/src/islands/model-capability-dialog.tsx +++ b/desktop-tauri/ui-islands/src/islands/model-capability-dialog.tsx @@ -29,7 +29,7 @@ import * as React from 'react' import { - Button, Dialog, DialogBody, DialogContent, DialogFooter, DialogHeader, DialogTitle, + Button, Checkbox, Dialog, DialogBody, DialogContent, DialogFooter, DialogHeader, DialogTitle, Input, Label, SegmentedControl, type SegmentedControlOption, } from '@ui' @@ -40,8 +40,8 @@ import { } from './model-capability' import * as customSource from './models-custom-source' import { - accept, errorMessage, modelRowOf, providerLabelOf, toast, writeCapabilities, - type CapabilityContext, + accept, errorMessage, modelRowOf, providerLabelOf, readModelsDevBinding, searchModelsDev, toast, + writeCapabilities, writeModelsDevBinding, type CapabilityContext, type ModelsDevBinding, type ModelsDevChoice, } from './models-panel-state' import { t } from '../i18n' @@ -106,6 +106,16 @@ function setKeysOf(capabilities: ReturnType, overrides: C }) } +function bindingOf(value: unknown): ModelsDevBinding { + if (!value || typeof value !== 'object') return { modelsDevId: '', sourceName: '', matchByNames: false } + const object = value as Record + return { + modelsDevId: typeof object.modelsDevId === 'string' ? object.modelsDevId : '', + sourceName: typeof object.sourceName === 'string' ? object.sourceName : '', + matchByNames: object.matchByNames === true, + } +} + /** * 草稿初值:**已设置的项**给现值(数值键给数字串,布尔键给 'on' / 'off'), * 其余留空 / 'inherit'(数值键的 placeholder 会显示上游值)。 @@ -135,6 +145,24 @@ export function CapabilityDialog({ context, onClose }: { context: CapabilityCont const [draft, setDraft] = React.useState(() => draftFrom(capabilities, setKeys)) const [status, setStatus] = React.useState('') const [saving, setSaving] = React.useState(false) + const initialBinding = bindingOf(row?.modelsDevBinding) + const [modelsDevId, setModelsDevId] = React.useState(initialBinding.modelsDevId || '') + const [sourceName, setSourceName] = React.useState(initialBinding.sourceName || row?.name || '') + const [matchByNames, setMatchByNames] = React.useState(initialBinding.matchByNames === true) + const [metadataQuery, setMetadataQuery] = React.useState(initialBinding.modelsDevId || row?.name || '') + const [metadataChoices, setMetadataChoices] = React.useState([]) + + React.useEffect(() => { + let cancelled = false + void readModelsDevBinding(context.provider, context.id).then(binding => { + if (cancelled || !binding) return + setModelsDevId(String(binding.modelsDevId || '')) + setSourceName(String(binding.sourceName || row?.name || '')) + setMatchByNames(binding.matchByNames === true) + setMetadataQuery(String(binding.modelsDevId || row?.name || '')) + }).catch(() => {}) + return () => { cancelled = true } + }, [context.provider, context.id, row?.name]) /** 行没了:给一句解释 + 只能关闭(保存按钮打空靶比这难查得多) */ if (!row) { @@ -197,15 +225,32 @@ export function CapabilityDialog({ context, onClose }: { context: CapabilityCont setSaving(true) setStatus(t('保存中…')) try { - accept(await writeCapabilities(context.provider, context.id, patchFromDraft(draft))) + const view = await writeCapabilities(context.provider, context.id, patchFromDraft(draft)) + await writeModelsDevBinding(context.provider, context.id, { + modelsDevId: modelsDevId.trim() || null, + sourceName: sourceName.trim() || row?.name || '', + matchByNames, + }) + accept(view) onClose() - toast(t('✅ 已保存 {id} 的能力位({provider})', { id: rowId, provider: providerLabel })) + toast(t('✅ 已保存 {id} 的能力位与 models.dev 身份({provider})', { id: rowId, provider: providerLabel })) } catch (error) { setStatus(t('保存失败:{message}', { message: errorMessage(error) })) setSaving(false) } } + async function findMetadata(): Promise { + setStatus(t('搜索 models.dev…')) + try { + const rows = await searchModelsDev(metadataQuery.trim()) + setMetadataChoices(rows) + setStatus(rows.length ? '' : t('没有找到匹配的 models.dev 模型')) + } catch (error) { + setStatus(t('搜索失败:{message}', { message: errorMessage(error) })) + } + } + return ( { if (next) return @@ -223,6 +268,49 @@ export function CapabilityDialog({ context, onClose }: { context: CapabilityCont {row.id}{t('({provider})', { provider: providerLabel })} +
+ +
+ setMetadataQuery(event.currentTarget.value)} + onKeyDown={event => { + if (event.key === 'Enter') { event.preventDefault(); void findMetadata() } + }} /> + +
+ {metadataChoices.length ? ( +
+ {metadataChoices.map(choice => ( + + ))} +
+ ) : null} + setModelsDevId(event.currentTarget.value)} /> + setSourceName(event.currentTarget.value)} /> + +

+ {t('名称只在当前提供商内参与唯一候选匹配;精确 ID 绑定始终优先,出现同名冲突时不会自动补齐。')} +

+
+ {TOKEN_KEYS.map(key => (
diff --git a/desktop-tauri/ui-islands/src/islands/models-custom-source.ts b/desktop-tauri/ui-islands/src/islands/models-custom-source.ts index a3f5f6d1f..45e72306c 100644 --- a/desktop-tauri/ui-islands/src/islands/models-custom-source.ts +++ b/desktop-tauri/ui-islands/src/islands/models-custom-source.ts @@ -78,6 +78,11 @@ export type ManageModel = { reasoningFixedLevel?: unknown reasoningBindingSupported?: unknown reasoningSource?: unknown + /** 后端解析出的 models.dev canonical 标识与命中方式(管理页解释来源) */ + modelsDevId?: unknown + modelsDevMatch?: unknown + modelsDevCandidates?: unknown + modelsDevBinding?: unknown /** * **可见性**:这个模型在官方客户端里是否会被列出。 * diff --git a/desktop-tauri/ui-islands/src/islands/models-panel-state.ts b/desktop-tauri/ui-islands/src/islands/models-panel-state.ts index abc99da57..3bc65cb89 100644 --- a/desktop-tauri/ui-islands/src/islands/models-panel-state.ts +++ b/desktop-tauri/ui-islands/src/islands/models-panel-state.ts @@ -87,6 +87,12 @@ export type SharedWindow = { setModelCapabilities( provider: string, id: string, patch: Record, ): Promise + getModelMetadataBinding(provider: string, id: string): Promise<{ binding?: ModelsDevBinding | null } | null | undefined> + setModelMetadataBinding( + provider: string, id: string, + binding: { modelsDevId: string | null; sourceName?: string; matchByNames?: boolean }, + ): Promise<{ binding?: ModelsDevBinding | null } | null | undefined> + searchModelsDev(query: string): Promise<{ models?: ModelsDevChoice[] } | null | undefined> } wbApp?: { toast?: (message: string, kind?: 'err' | 'ok') => void @@ -838,6 +844,39 @@ export async function writeCapabilities( return enqueueWrite(() => api.setModelCapabilities(provider, id, capabilities)) } +/** 手动选择的 models.dev 身份;`modelsDevId` 为空表示没有显式绑定。 */ +export type ModelsDevBinding = { + provider?: string + id?: string + modelsDevId?: string | null + sourceName?: string | null + matchByNames?: boolean +} + +export type ModelsDevChoice = { id: string; name?: string | null } + +export async function readModelsDevBinding(provider: string, id: string): Promise { + const response = await shared().workbuddyDesktop?.getModelMetadataBinding?.(provider, id) + return response?.binding || null +} + +export async function writeModelsDevBinding( + provider: string, id: string, binding: ModelsDevBinding, +): Promise { + const api = shared().workbuddyDesktop + if (!api?.setModelMetadataBinding) throw new Error(t('后端桥不可用')) + await enqueueWrite(() => api.setModelMetadataBinding!(provider, id, { + modelsDevId: binding.modelsDevId || null, + sourceName: binding.sourceName || '', + matchByNames: binding.matchByNames === true, + })) +} + +export async function searchModelsDev(query: string): Promise { + const response = await shared().workbuddyDesktop?.searchModelsDev?.(query) + return Array.isArray(response?.models) ? response.models : [] +} + /* ─── 行内操作 ───────────────────────────────── */ /** diff --git a/desktop-tauri/ui-shared/maintenance-summary.tsx b/desktop-tauri/ui-shared/maintenance-summary.tsx index df12dfd0a..784a79c26 100644 --- a/desktop-tauri/ui-shared/maintenance-summary.tsx +++ b/desktop-tauri/ui-shared/maintenance-summary.tsx @@ -1,5 +1,12 @@ -import { Button, Card, Spinner, Table, TableBody, TableCell, TableRow } from '@ui' +import * as React from 'react' +import { + Button, Card, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Spinner, + Table, TableBody, TableCell, TableRow, cn, +} from '@ui' import { t } from '../ui-kit/src/i18n' +import { + buildProxyPick, EMPTY_PROXY_SELECTION, fetchProxySelection, saveProxy, type ProxySelection, +} from '../ui-islands/src/islands/update-shared' import { RunCounts, RunStatus } from './maintenance-shared' import { duration, maintenanceLabel, sourceLabel, stamp } from './maintenance-format' import type { MaintenanceRun, MaintenanceTask, MaintenanceTaskId } from './maintenance-types' @@ -40,6 +47,38 @@ export function MaintenanceSummary({ tasks, latest, busy, compact, onRun, onShow onRun(id: MaintenanceTaskId): void onShowLatest(id: MaintenanceTaskId): void }) { + const [proxySelection, setProxySelection] = React.useState(EMPTY_PROXY_SELECTION) + const [proxyBusy, setProxyBusy] = React.useState(false) + const proxyPick = buildProxyPick(proxySelection) + React.useEffect(() => { + let alive = true + void fetchProxySelection().then(selection => { if (alive) setProxySelection(selection) }) + return () => { alive = false } + }, []) + const chooseProxy = async (value: string) => { + if (proxyBusy) return + setProxyBusy(true) + try { + const result = await saveProxy(value) + if (result) setProxySelection(previous => ({ ...previous, proxyChoice: result.choice, proxyLoaded: true })) + const next = await fetchProxySelection() + setProxySelection(next) + } finally { + setProxyBusy(false) + } + } + const proxyPicker = (task: MaintenanceTask) => task.id !== 'modelRefresh' ? — : ( + + ) const rows: TaskRow[] = tasks.map(task => { const run = latest[task.id] const running = task.running || busy === task.id @@ -65,6 +104,7 @@ export function MaintenanceSummary({ tasks, latest, busy, compact, onRun, onShow

{lastRun(row)}

{row.run ? : null}

{schedule(row)}{cooldown(row)}

+ {row.task.id === 'modelRefresh' ?
{proxyPicker(row.task)}
: null}