diff --git a/docs/user-manual/en/5-faq/5.3-deeplink.md b/docs/user-manual/en/5-faq/5.3-deeplink.md index e6e8e8131..df7e41a93 100644 --- a/docs/user-manual/en/5-faq/5.3-deeplink.md +++ b/docs/user-manual/en/5-faq/5.3-deeplink.md @@ -67,6 +67,8 @@ ccswitch://v1/import?resource={type}&app={app}&name={name}&... | `usageUserId` | No | Usage query user ID | | `usageAutoInterval` | No | Auto query interval (minutes) | +For Grok Build, `config` likewise takes `{"config": ""}` or the whole config.toml with `configFormat=toml`. Only the `[model.""]` table named by `[models] default` is imported (display name, `description`, `api_backend`, `context_window`, sampling and retry settings, etc.); the endpoint and key always come from the link parameters, and other model tables are skipped. Links whose table uses `auth_provider`, `env_http_headers` or `mtls_cert_dir` are rejected, and `env_key` is ignored. + **Prompt parameters** (resource=prompt): | Parameter | Required | Description | diff --git a/docs/user-manual/ja/5-faq/5.3-deeplink.md b/docs/user-manual/ja/5-faq/5.3-deeplink.md index e7c15b457..a26fb0d9d 100644 --- a/docs/user-manual/ja/5-faq/5.3-deeplink.md +++ b/docs/user-manual/ja/5-faq/5.3-deeplink.md @@ -67,6 +67,8 @@ ccswitch://v1/import?resource={type}&app={app}&name={name}&... | `usageUserId` | いいえ | 使用量クエリユーザー ID | | `usageAutoInterval` | いいえ | 自動クエリ間隔(分) | +Grok Build の `config` も `{"config": ""}` の形式、または `configFormat=toml` で config.toml 全体を渡せます。`[models] default` が指す `[model."<名前>"]` テーブル(表示名、`description`、`api_backend`、`context_window`、サンプリング・リトライ設定など)のみをインポートし、エンドポイントとキーは常にリンクのパラメーターを使用します。他のモデルテーブルはインポートされません。テーブルに `auth_provider`、`env_http_headers`、`mtls_cert_dir` を含むリンクは拒否され、`env_key` は無視されます。 + **プロンプトパラメータ**(resource=prompt): | パラメータ | 必須 | 説明 | diff --git a/docs/user-manual/zh/5-faq/5.3-deeplink.md b/docs/user-manual/zh/5-faq/5.3-deeplink.md index c0e9f8ada..435cbee60 100644 --- a/docs/user-manual/zh/5-faq/5.3-deeplink.md +++ b/docs/user-manual/zh/5-faq/5.3-deeplink.md @@ -67,6 +67,8 @@ ccswitch://v1/import?resource={type}&app={app}&name={name}&... | `usageUserId` | 否 | 用量查询用户 ID | | `usageAutoInterval` | 否 | 自动查询间隔(分钟) | +Grok Build 的 `config` 同样可写成 `{"config": ""}` 或用 `configFormat=toml` 给整份:只导入 `[models] default` 指向的那张 `[model."<名称>"]` 表(显示名、`description`、`api_backend`、`context_window`、采样和重试参数等),地址和 Key 一律用链接参数,其他模型表不导入;表里带 `auth_provider`、`env_http_headers`、`mtls_cert_dir` 的链接会被拒绝,`env_key` 会被忽略。 + **提示词参数**(resource=prompt): | 参数 | 必填 | 说明 | diff --git a/src-tauri/src/deeplink/grok_link.rs b/src-tauri/src/deeplink/grok_link.rs new file mode 100644 index 000000000..f787f2ad2 --- /dev/null +++ b/src-tauri/src/deeplink/grok_link.rs @@ -0,0 +1,225 @@ +//! Grok Build 供应商链接附带的 `config.toml`:只收选中的那张 `[model."<名称>"]` 表。 +//! +//! 一个供应商在 live 里只拥有一张模型表(`live/project/grok.rs`),所以链接里的其他表不 +//! 导入。选中的表按白名单收:显示名、说明、接口类型、窗口、采样和重试参数等照搬;地址、 +//! Key、模型名用合并后的值(URL 参数优先)。 +//! +//! 链接是不可信输入:让 Grok 运行凭据提供器(`auth_provider`)、读环境变量 +//! (`env_http_headers`)或本机证书(`mtls_cert_dir`)的表直接拒绝;`env_key` 丢掉,Key +//! 用链接自己给的(只给了 `env_key` 的链接由调用方拒绝)。 + +use toml_edit::{DocumentMut, Item, Table, TableLike, Value}; + +use crate::error::AppError; +use crate::grok_config::{DEFAULT_API_BACKEND, DEFAULT_CONTEXT_WINDOW}; + +/// 可以从链接带进来的键(Grok Build 1.0.50 的模型表,去掉地址、凭据和下面拒绝的)。 +const PROFILE_KEYS: &[&str] = &[ + "name", + "description", + "notice", + "model_family", + "api_backend", + "auth_scheme", + "extra_headers", + "query_params", + "context_window", + "context_windows", + "max_completion_tokens", + "temperature", + "top_p", + "max_request_bytes", + "auto_compact_threshold_percent", + "system_prompt_label", + "use_concise", + "agent_type", + "inference_idle_timeout_secs", + "max_retries", + "rate_limit_retry_threshold", + "subagent_rate_limit_max_attempts", + "hidden", + "supports_reasoning_effort", + "reasoning_efforts", + "supports_backend_search", + "compaction_at_tokens", + "show_model_fingerprint", + "stream_tool_calls", + "reasoning_summary", +]; + +/// 运行凭据提供器、读环境变量或本机证书:链接带了就拒绝导入。 +const REJECTED_PROFILE_KEYS: &[&str] = &["auth_provider", "env_http_headers", "mtls_cert_dir"]; + +/// 链接里选中的模型表,已按白名单过滤。 +pub(super) struct GrokLinkProfile { + /// 表名(`models.default` 指向的名字)。 + pub name: String, + table: Table, + model: Option, + base_url: Option, + api_key: Option, + /// 表用 `env_key` 取 Key。 + pub declares_env_key: bool, +} + +impl GrokLinkProfile { + pub(super) fn parse(text: &str) -> Result { + let doc = text + .parse::() + .map_err(|e| AppError::InvalidInput(format!("Invalid Grok Build config.toml: {e}")))?; + let (name, source) = selected_profile(&doc)?; + if let Some(key) = REJECTED_PROFILE_KEYS + .iter() + .find(|key| source.get(key).is_some()) + { + return Err(AppError::InvalidInput(format!( + "The Grok Build config uses '{key}', which makes Grok run a credential provider or \ + read credentials from this computer; it cannot be imported from a link. Add the \ + provider manually instead." + ))); + } + + let mut table = Table::new(); + for key in PROFILE_KEYS { + if let Some(item) = source.get(key) { + table.insert(key, undecorated(item)); + } + } + Ok(Self { + name, + table, + model: non_empty_str(source.get("model")), + base_url: non_empty_str(source.get("base_url")), + api_key: non_empty_str(source.get("api_key")), + declares_env_key: source.get("env_key").is_some(), + }) + } + + pub(super) fn model(&self) -> Option { + self.model.clone() + } + + pub(super) fn base_url(&self) -> Option { + self.base_url.clone() + } + + pub(super) fn api_key(&self) -> Option { + self.api_key.clone() + } + + /// 写成供应商行的 `config`。地址、Key、模型名用合并后的值;表里没写显示名、接口类型、 + /// 窗口时补上和模板一样的默认值(live 的形状校验要求它们)。 + pub(super) fn render( + &self, + display_name: &str, + model: &str, + endpoint: &str, + api_key: &str, + ) -> String { + let mut table = Table::new(); + table.insert("model", toml_edit::value(model)); + table.insert("base_url", toml_edit::value(endpoint)); + table.insert( + "name", + self.table + .get("name") + .filter(|item| non_empty_str(Some(item)).is_some()) + .cloned() + .unwrap_or_else(|| toml_edit::value(display_name)), + ); + table.insert("api_key", toml_edit::value(api_key)); + table.insert( + "api_backend", + self.table + .get("api_backend") + .cloned() + .unwrap_or_else(|| toml_edit::value(DEFAULT_API_BACKEND)), + ); + table.insert( + "context_window", + self.table + .get("context_window") + .cloned() + .unwrap_or_else(|| toml_edit::value(DEFAULT_CONTEXT_WINDOW)), + ); + for (key, item) in self.table.iter() { + if !table.contains_key(key) { + table.insert(key, item.clone()); + } + } + + let mut doc = DocumentMut::new(); + let mut models = Table::new(); + models.insert("default", toml_edit::value(&self.name)); + doc.insert("models", Item::Table(models)); + let mut model_tables = Table::new(); + model_tables.set_implicit(true); + model_tables.insert(&self.name, Item::Table(table)); + doc.insert("model", Item::Table(model_tables)); + doc.to_string() + } +} + +/// `models.default` 指向的表;没写或指不到时取唯一的一张表。 +fn selected_profile(doc: &DocumentMut) -> Result<(String, &dyn TableLike), AppError> { + let tables = doc + .get("model") + .and_then(Item::as_table_like) + .ok_or_else(|| { + AppError::InvalidInput( + "The Grok Build config has no [model.\"\"] table to import".to_string(), + ) + })?; + let default = non_empty_str(doc.get("models").and_then(|models| models.get("default"))); + if let Some(name) = default { + if let Some(table) = tables.get(&name).and_then(Item::as_table_like) { + return Ok((name, table)); + } + } + let mut candidates = tables + .iter() + .filter_map(|(name, item)| Some((name.to_string(), item.as_table_like()?))); + match (candidates.next(), candidates.next()) { + (Some(only), None) => Ok(only), + (None, _) => Err(AppError::InvalidInput( + "The Grok Build config has no [model.\"\"] table to import".to_string(), + )), + _ => Err(AppError::InvalidInput( + "The Grok Build config has several [model.*] tables; set [models] default to the one \ + to import" + .to_string(), + )), + } +} + +fn non_empty_str(item: Option<&Item>) -> Option { + item.and_then(Item::as_str) + .map(str::trim) + .filter(|text| !text.is_empty()) + .map(str::to_string) +} + +fn undecorated_value(value: &Value) -> Value { + let mut value = value.clone(); + value.decor_mut().clear(); + value +} + +/// 去掉原文的注释和空白;子表保留成子表。 +fn undecorated(item: &Item) -> Item { + match item { + Item::Value(value) => Item::Value(undecorated_value(value)), + Item::Table(table) => { + let mut table = table.clone(); + table.decor_mut().clear(); + table.set_implicit(false); + for (_, value) in table.iter_mut() { + if let Some(value) = value.as_value_mut() { + value.decor_mut().clear(); + } + } + Item::Table(table) + } + other => other.clone(), + } +} diff --git a/src-tauri/src/deeplink/mod.rs b/src-tauri/src/deeplink/mod.rs index ff52cdbd7..73f286342 100644 --- a/src-tauri/src/deeplink/mod.rs +++ b/src-tauri/src/deeplink/mod.rs @@ -9,6 +9,7 @@ //! mod codex_link; +mod grok_link; mod mcp; mod parser; mod prompt; diff --git a/src-tauri/src/deeplink/provider.rs b/src-tauri/src/deeplink/provider.rs index abd19dea4..afce413c0 100644 --- a/src-tauri/src/deeplink/provider.rs +++ b/src-tauri/src/deeplink/provider.rs @@ -3,6 +3,7 @@ //! Handles importing provider configurations via ccswitch:// URLs. use super::codex_link::{self, CodexLinkConfig}; +use super::grok_link::GrokLinkProfile; use super::utils::{decode_base64_param, infer_homepage_from_endpoint}; use super::DeepLinkImportRequest; use crate::error::AppError; @@ -149,7 +150,7 @@ pub(crate) fn build_provider_from_request( AppType::Claude | AppType::ClaudeDesktop => build_claude_settings(request), AppType::Codex => build_codex_settings(request)?, AppType::Gemini => build_gemini_settings(request), - AppType::GrokBuild => build_grokbuild_settings(request), + AppType::GrokBuild => build_grokbuild_settings(request)?, AppType::OpenCode => build_opencode_settings(request), AppType::OpenClaw => build_additive_app_settings(request), AppType::Hermes => build_hermes_settings(request), @@ -491,7 +492,11 @@ fn build_gemini_settings(request: &DeepLinkImportRequest) -> serde_json::Value { json!({ "env": env }) } -fn build_grokbuild_settings(request: &DeepLinkImportRequest) -> serde_json::Value { +/// 链接附带了 `config.toml` 时,按白名单收下选中的那张模型表([`grok_link`]);没带时用 +/// 固定模板。模板的字节由 golden 测试锁住(降级后旧版会把行里的 config 整份写进 live)。 +fn build_grokbuild_settings( + request: &DeepLinkImportRequest, +) -> Result { let model = request .model .as_deref() @@ -507,18 +512,23 @@ fn build_grokbuild_settings(request: &DeepLinkImportRequest) -> serde_json::Valu let endpoint = get_primary_endpoint(request).trim().to_string(); let api_key = request.api_key.as_deref().unwrap_or("").trim(); + if let Some(config) = decode_link_config(request)? { + let profile = GrokLinkProfile::parse(&grokbuild_config_text(&config)?)?; + return Ok(json!({ "config": profile.render(name, model, &endpoint, api_key) })); + } + let model_value = toml_edit::Value::from(model).to_string(); let name_value = toml_edit::Value::from(name).to_string(); let endpoint_value = toml_edit::Value::from(endpoint.as_str()).to_string(); let api_key_value = toml_edit::Value::from(api_key).to_string(); - json!({ + Ok(json!({ "config": format!( "[models]\ndefault = {model_value}\n\n[model.{model_value}]\nmodel = {model_value}\nbase_url = {endpoint_value}\nname = {name_value}\napi_key = {api_key_value}\napi_backend = \"{}\"\ncontext_window = {}\n", crate::grok_config::DEFAULT_API_BACKEND, crate::grok_config::DEFAULT_CONTEXT_WINDOW, ) - }) + })) } /// Build OpenCode settings configuration @@ -867,23 +877,24 @@ fn merge_gemini_config( Ok(()) } +/// 链接 config 参数里的 Grok Build `config.toml` 文本:`{"config": ""}`,或者 +/// 整份就是 config.toml(`configFormat=toml`,或 JSON 写的同一份内容)。 +fn grokbuild_config_text(config: &serde_json::Value) -> Result { + if let Some(config_toml) = config.get("config").and_then(|v| v.as_str()) { + return Ok(config_toml.to_string()); + } + let toml_value: toml::Value = serde_json::from_value(config.clone()) + .map_err(|error| AppError::InvalidInput(format!("Invalid Grok Build config: {error}")))?; + toml::to_string(&toml_value) + .map_err(|error| AppError::InvalidInput(format!("Invalid Grok Build config: {error}"))) +} + fn merge_grokbuild_config( request: &mut DeepLinkImportRequest, config: &serde_json::Value, ) -> Result<(), AppError> { - let config_toml = if let Some(config_toml) = config.get("config").and_then(|v| v.as_str()) { - config_toml.to_string() - } else { - let toml_value: toml::Value = serde_json::from_value(config.clone()).map_err(|error| { - AppError::InvalidInput(format!("Invalid Grok Build config: {error}")) - })?; - toml::to_string(&toml_value).map_err(|error| { - AppError::InvalidInput(format!("Invalid Grok Build config: {error}")) - })? - }; - let model = crate::grok_config::extract_model_config(&config_toml).ok_or_else(|| { - AppError::InvalidInput("Invalid Grok Build config.toml model profile".to_string()) - })?; + // 先过白名单:拒绝的键在确认框打开时就报出来,而不是点了导入才失败 + let profile = GrokLinkProfile::parse(&grokbuild_config_text(config)?)?; if request .api_key @@ -896,7 +907,7 @@ fn merge_grokbuild_config( // victim's environment secret into the imported provider's config.toml // and ship it to whatever `base_url` the link declares. `env_key` is an // indirection that must stay a name, not a resolved secret, on import. - request.api_key = model.api_key; + request.api_key = profile.api_key(); // An `env_key`-only link is not importable at all, and saying so beats // falling through to the generic "API key is required" (which reads like @@ -909,7 +920,7 @@ fn merge_grokbuild_config( .api_key .as_ref() .is_none_or(|value| value.is_empty()) - && model.env_key.is_some() + && profile.declares_env_key { return Err(AppError::InvalidInput( "This link supplies its API key indirectly through `env_key`, which cannot be \ @@ -924,10 +935,10 @@ fn merge_grokbuild_config( .as_ref() .is_none_or(|value| value.is_empty()) { - request.endpoint = Some(model.base_url); + request.endpoint = profile.base_url(); } if request.model.is_none() { - request.model = Some(model.model); + request.model = profile.model(); } if request .homepage @@ -1032,7 +1043,7 @@ mod tests { }); merge_grokbuild_config(&mut request, &config).expect("merge should succeed"); - let settings = build_grokbuild_settings(&request); + let settings = build_grokbuild_settings(&request).unwrap(); let rendered = settings["config"].as_str().expect("config string"); assert!( diff --git a/src-tauri/src/deeplink/tests.rs b/src-tauri/src/deeplink/tests.rs index ab36541a3..f2fde24ab 100644 --- a/src-tauri/src/deeplink/tests.rs +++ b/src-tauri/src/deeplink/tests.rs @@ -1421,3 +1421,105 @@ fn test_codex_link_accepts_a_whole_config_toml() { Some("Toml Relay") ); } + +// ============ Grok Build:链接附带的 config.toml ============ + +fn import_grok_with_config(config_toml: &str) -> Result { + use super::provider::build_provider_from_request; + + let request = DeepLinkImportRequest { + version: "v1".to_string(), + resource: "provider".to_string(), + app: Some("grokbuild".to_string()), + name: Some("Relay Card".to_string()), + endpoint: Some("https://api.relay.example/v1".to_string()), + api_key: Some("xai-link".to_string()), + config: Some( + BASE64_STANDARD.encode(serde_json::json!({ "config": config_toml }).to_string()), + ), + config_format: Some("json".to_string()), + ..Default::default() + }; + let merged = parse_and_merge_config(&request)?; + Ok(build_provider_from_request(&AppType::GrokBuild, &merged)?.settings_config) +} + +#[test] +fn test_grok_link_config_keeps_the_selected_profile_fields() { + // #6457:显示名、说明、窗口等来自链接的模型表;地址和 Key 用链接参数 + let settings = import_grok_with_config(concat!( + "[models]\ndefault = \"grok-4.6\"\n\n", + "[model.\"grok-4.6\"]\nmodel = \"grok-4.6\"\nname = \"Grok 4.6 (Relay)\"\n", + "description = \"Fast coding model\"\n", + "base_url = \"https://untrusted.example/v1\"\napi_key = \"sk-in-config\"\n", + "env_key = \"RELAY_KEY\"\n", + "api_backend = \"chat_completions\"\ncontext_window = 256000\n", + "reasoning_summary = \"auto\"\nextra_headers = { \"x-relay\" = \"1\" }\n\n", + "[model.\"grok-4.5\"]\nmodel = \"grok-4.5\"\nname = \"Grok 4.5\"\n", + "base_url = \"https://untrusted.example/v1\"\napi_key = \"sk-in-config\"\n", + "api_backend = \"responses\"\ncontext_window = 500000\n", + )) + .unwrap(); + + let text = settings["config"].as_str().unwrap(); + let doc: toml_edit::DocumentMut = text.parse().unwrap(); + assert_eq!(doc["models"]["default"].as_str(), Some("grok-4.6")); + let table = &doc["model"]["grok-4.6"]; + assert_eq!(table["name"].as_str(), Some("Grok 4.6 (Relay)")); + assert_eq!(table["description"].as_str(), Some("Fast coding model")); + assert_eq!(table["api_backend"].as_str(), Some("chat_completions")); + assert_eq!(table["context_window"].as_integer(), Some(256000)); + assert_eq!(table["reasoning_summary"].as_str(), Some("auto")); + assert_eq!(table["extra_headers"]["x-relay"].as_str(), Some("1")); + assert_eq!( + table["base_url"].as_str(), + Some("https://api.relay.example/v1") + ); + assert_eq!(table["api_key"].as_str(), Some("xai-link")); + assert!(table.get("env_key").is_none(), "{text}"); + // 一个供应商只拥有一张模型表,其余的不导入 + assert!(doc["model"].get("grok-4.5").is_none(), "{text}"); + + // 行能被投影(切换时写进 live 的就是这张表) + let projection = crate::live::project::grok::GrokProjection::of(&settings, false).unwrap(); + assert_eq!(projection.table_name(), Some("grok-4.6")); +} + +#[test] +fn test_grok_link_config_fills_template_defaults_for_missing_fields() { + let settings = import_grok_with_config( + "[models]\ndefault = \"relay\"\n\n[model.relay]\nmodel = \"relay-model\"\n", + ) + .unwrap(); + let doc: toml_edit::DocumentMut = settings["config"].as_str().unwrap().parse().unwrap(); + let table = &doc["model"]["relay"]; + assert_eq!(table["name"].as_str(), Some("Relay Card")); + assert_eq!(table["model"].as_str(), Some("relay-model")); + assert_eq!( + table["api_backend"].as_str(), + Some(crate::grok_config::DEFAULT_API_BACKEND) + ); + assert_eq!( + table["context_window"].as_integer(), + Some(crate::grok_config::DEFAULT_CONTEXT_WINDOW) + ); + crate::live::project::grok::GrokProjection::of(&settings, false).unwrap(); +} + +#[test] +fn test_grok_link_config_rejects_credential_providers_and_local_files() { + for (key, line) in [ + ("auth_provider", "auth_provider = \"corp-sso\"\n"), + ( + "env_http_headers", + "env_http_headers = { \"x-key\" = \"HOME\" }\n", + ), + ("mtls_cert_dir", "mtls_cert_dir = \"/etc/ssl/private\"\n"), + ] { + let config = format!( + "[models]\ndefault = \"relay\"\n\n[model.relay]\nmodel = \"relay-model\"\n{line}" + ); + let err = import_grok_with_config(&config).unwrap_err(); + assert!(err.to_string().contains(key), "{key}: {err}"); + } +} diff --git a/src-tauri/src/live/project/grok.rs b/src-tauri/src/live/project/grok.rs index 6ef8ee5d2..368e9bede 100644 --- a/src-tauri/src/live/project/grok.rs +++ b/src-tauri/src/live/project/grok.rs @@ -251,6 +251,8 @@ impl GrokConfigPatch { } } + // 这次删掉的表(写入记录里的,加上代理契约留下的) + let mut retired_here: Vec = Vec::new(); if let Some(tables) = table_mut(path, root, "model", false)? { let placeholder = self.placeholder.as_deref(); let doomed: Vec = tables @@ -267,10 +269,12 @@ impl GrokConfigPatch { }) .map(|(name, _)| name.to_string()) .collect(); - for name in doomed { - tables.remove(&name); + for name in &doomed { + tables.remove(name); } + retired_here = doomed; } + self.follow_session_summary(path, root, target_name, &retired_here)?; if let Some((name, table)) = &self.target { let tables = table_mut(path, root, "model", true)?.expect("created"); @@ -305,6 +309,62 @@ impl GrokConfigPatch { } } +impl GrokConfigPatch { + /// `models.session_summary`:Grok 生成会话标题和摘要用的模型。没设时 Grok(1.0.50 实测) + /// 用内置的 `grok-4.6`,请求却发到默认模型表的地址,第三方上游不认这个模型名,标题生成 + /// 就失败(#7003)。 + /// + /// 没设、或者指向 CC Switch 写的表(目标表、写入记录里的、这次删掉的)时跟着目标走:有 + /// 目标就指向它,切到官方就删掉。指向用户自己的表或内置模型时不动。 + fn follow_session_summary( + &self, + path: &Path, + root: &mut Table, + target_name: Option<&str>, + retired_here: &[String], + ) -> Result<(), LiveWriteError> { + let current = root + .get("models") + .and_then(|models| models.get("session_summary")) + .and_then(Item::as_str) + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(str::to_string); + let follows = match current.as_deref() { + None => true, + Some(name) => { + Some(name) == target_name + || self.retired.iter().any(|retired| retired == name) + || retired_here.iter().any(|retired| retired == name) + } + }; + if !follows { + return Ok(()); + } + match target_name { + Some(name) => { + let models = table_mut(path, root, "models", true)?.expect("created"); + match models.get_mut("session_summary") { + Some(Item::Value(slot)) => { + let decor = slot.decor().clone(); + *slot = name.into(); + *slot.decor_mut() = decor; + } + _ => { + models.insert("session_summary", toml_edit::value(name)); + } + } + } + None => { + if let Some(models) = table_mut(path, root, "models", false)? { + models.remove("session_summary"); + } + } + } + Ok(()) + } +} + impl TomlDocPatch for GrokConfigPatch { fn apply_to(&self, path: &Path, doc: &mut DocumentMut) -> Result<(), LiveWriteError> { Self::apply_to(self, path, doc) @@ -381,6 +441,61 @@ api_backend = "chat_completions" context_window = 200000 "#; + /// #7003:没设 session_summary 时 Grok 用内置模型生成标题,第三方上游不认。 + #[test] + fn session_summary_follows_the_written_table() { + let b = GrokProjection::of(&row(ROW_B), false).unwrap(); + let summary = |out: &str| { + let doc: DocumentMut = out.parse().unwrap(); + doc.get("models") + .and_then(|models| models.get("session_summary")) + .and_then(Item::as_str) + .map(str::to_string) + }; + + // 没设:指向目标表 + let out = apply(&GrokConfigPatch::direct(&b, vec![], PLACEHOLDER), ""); + assert_eq!(summary(&out).as_deref(), Some("b"), "{out}"); + + // 指向上一家写的表:跟着换 + let live = ROW_A.replace( + "default = \"grok-4.5\"\n", + "default = \"grok-4.5\"\nsession_summary = \"grok-4.5\" # titles\n", + ); + let out = apply( + &GrokConfigPatch::direct(&b, vec!["grok-4.5".into()], PLACEHOLDER), + &live, + ); + assert_eq!(summary(&out).as_deref(), Some("b"), "{out}"); + assert!( + out.contains("session_summary = \"b\" # titles"), + "keeps the comment: {out}" + ); + + // 指向用户自己选的模型:不动 + let live = ROW_A.replace( + "default = \"grok-4.5\"\n", + "default = \"grok-4.5\"\nsession_summary = \"grok-4.6\"\n", + ); + let out = apply( + &GrokConfigPatch::direct(&b, vec!["grok-4.5".into()], PLACEHOLDER), + &live, + ); + assert_eq!(summary(&out).as_deref(), Some("grok-4.6"), "{out}"); + + // 切到官方:指向上一家的删掉,不留一个指不到表的名字 + let official = GrokProjection::of(&row(""), true).unwrap(); + let live = ROW_A.replace( + "default = \"grok-4.5\"\n", + "default = \"grok-4.5\"\nsession_summary = \"grok-4.5\"\n", + ); + let out = apply( + &GrokConfigPatch::direct(&official, vec!["grok-4.5".into()], PLACEHOLDER), + &live, + ); + assert_eq!(out, ""); + } + #[test] fn a_default_that_points_nowhere_falls_back_to_the_only_table() { let polluted = ROW_A.replace("default = \"grok-4.5\"", "default = \"grok-4.6\""); diff --git a/src-tauri/tests/golden/import_rows.rs b/src-tauri/tests/golden/import_rows.rs index 9e5b03563..0e7a5234f 100644 --- a/src-tauri/tests/golden/import_rows.rs +++ b/src-tauri/tests/golden/import_rows.rs @@ -106,6 +106,33 @@ fn deeplink_grokbuild_rows() { ); } +/// 带 `config.toml` 的 Grok Build 链接:只收 `models.default` 指向的那张表,地址和 Key +/// 用链接参数。 +#[test] +fn deeplink_grokbuild_config_rows() { + use base64::prelude::*; + + let config = serde_json::json!({ + "config": concat!( + "[models]\ndefault = \"grok-4.6\"\n\n", + "[model.\"grok-4.6\"]\nmodel = \"grok-4.6\"\nname = \"Grok 4.6 (Relay)\"\n", + "description = \"Fast coding model\"\n", + "base_url = \"https://ignored.example/v1\"\napi_key = \"sk-ignored\"\n", + "api_backend = \"chat_completions\"\ncontext_window = 256000\n\n", + "[model.\"grok-4.5\"]\nmodel = \"grok-4.5\"\nname = \"Grok 4.5\"\n", + ) + }); + let url = format!( + "ccswitch://v1/import?resource=provider&app=grokbuild&name=Relay%20Grok%20Config&endpoint=https%3A%2F%2Fapi.relay.example%2Fv1&apiKey=xai-relay&configFormat=json&config={}", + BASE64_URL_SAFE_NO_PAD.encode(config.to_string()) + ); + import_deeplinks( + AppType::GrokBuild, + &[&url], + "rows/deeplink-grokbuild-config.txt", + ); +} + /// 首启:先把 live 导入成 `default`,再 seed 官方预设(顺序同 `lib.rs` 的启动流程)。 /// live 里供应商以外的部分进了通用配置片段,片段也一起锁。关键字段(模型名、推理档位 /// 等)不进片段、留在 `default` 行里:片段已冻结,收进去就再也写不回 live。 diff --git a/src-tauri/tests/golden/snapshots/rows/deeplink-grokbuild-config.txt b/src-tauri/tests/golden/snapshots/rows/deeplink-grokbuild-config.txt new file mode 100644 index 000000000..9f0c7c426 --- /dev/null +++ b/src-tauri/tests/golden/snapshots/rows/deeplink-grokbuild-config.txt @@ -0,0 +1,13 @@ +id: relaygrokconfig- +name: Relay Grok Config +settings_config: {"config":"[models]\ndefault = \"grok-4.6\"\n\n[model.\"grok-4.6\"]\nmodel = \"grok-4.6\"\nbase_url = \"https://api.relay.example/v1\"\nname = \"Grok 4.6 (Relay)\"\napi_key = \"xai-relay\"\napi_backend = \"chat_completions\"\ncontext_window = 256000\ndescription = \"Fast coding model\"\n"} +website_url: https://relay.example +category: NULL +sort_index: NULL +notes: NULL +icon: NULL +icon_color: NULL +meta: {} +is_current: true +in_failover_queue: false + diff --git a/src-tauri/tests/provider_commands.rs b/src-tauri/tests/provider_commands.rs index 2c85e83cb..05608bae7 100644 --- a/src-tauri/tests/provider_commands.rs +++ b/src-tauri/tests/provider_commands.rs @@ -35,6 +35,15 @@ context_window = 500000 ) } +/// 切换时 `models.session_summary` 跟着目标模型表走(#7003),live 比行多这一行。 +fn with_session_summary(config: &str) -> String { + config.replacen( + "default = \"grok-4.5\"\n", + "default = \"grok-4.5\"\nsession_summary = \"grok-4.5\"\n", + 1, + ) +} + #[test] fn grokbuild_import_and_switch_write_live_config() { let _guard = test_mutex().lock().unwrap_or_else(|e| e.into_inner()); @@ -82,7 +91,7 @@ fn grokbuild_import_and_switch_write_live_config() { assert_eq!( std::fs::read_to_string(&live_path).expect("read switched Grok Build config"), - next_config + with_session_summary(&next_config) ); assert_eq!( state @@ -128,11 +137,12 @@ fn grokbuild_switch_back_after_client_changed_default_model() { switch_provider_test_hook(&state, AppType::GrokBuild, "a").expect("switch to provider a"); assert_eq!( std::fs::read_to_string(&live_path).expect("read live after switching to a"), - relay_a + with_session_summary(&relay_a) ); // Simulate Grok's `/settings` → Default model picking the built-in grok-4.6. - let client_edited = relay_a.replace("default = \"grok-4.5\"", "default = \"grok-4.6\""); + let client_edited = + with_session_summary(&relay_a).replace("default = \"grok-4.5\"", "default = \"grok-4.6\""); std::fs::write(&live_path, &client_edited).expect("simulate client edit"); switch_provider_test_hook(&state, AppType::GrokBuild, "b").expect("switch to provider b"); diff --git a/src/components/DeepLinkImportDialog.tsx b/src/components/DeepLinkImportDialog.tsx index 019764c4d..0d4e5f36d 100644 --- a/src/components/DeepLinkImportDialog.tsx +++ b/src/components/DeepLinkImportDialog.tsx @@ -620,12 +620,12 @@ export function DeepLinkImportDialog() {
                                     {parsedConfig.tomlConfig}
                                   
- {/* 后端按白名单收(deeplink/codex_link.rs),原文里的全局设置不会导入 */} - {parsedConfig.type === "codex" && ( -

- {t("deeplink.codexConfigScopeHint")} -

- )} + {/* 后端按白名单收(deeplink/codex_link.rs、grok_link.rs),原文不会整份导入 */} +

+ {parsedConfig.type === "codex" + ? t("deeplink.codexConfigScopeHint") + : t("deeplink.grokConfigScopeHint")} +

)} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index cc5ee5891..c4b326c91 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -3385,6 +3385,7 @@ "importError": "Failed to import", "configSource": "Config Source", "codexConfigScopeHint": "Only the settings that belong to this provider (endpoint, auth, model, reasoning effort, etc.) are imported; global settings such as [features], MCP and permissions are skipped.", + "grokConfigScopeHint": "Only the model table named by [models] default is imported (display name, description, API backend, context window, etc.); the endpoint and key come from the link parameters, and other model tables are skipped.", "configEmbedded": "Embedded Config", "configRemote": "Remote Config", "configDetails": "Config Details", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 60fe2fa7b..94216f658 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -3385,6 +3385,7 @@ "importError": "インポートに失敗しました", "configSource": "設定ソース", "codexConfigScopeHint": "このプロバイダーに属する設定(エンドポイント、認証、モデル、推論レベルなど)のみをインポートします。[features]、MCP、権限などのグローバル設定はインポートされません。", + "grokConfigScopeHint": "[models] default が指すモデルテーブル(表示名、説明、API バックエンド、コンテキストウィンドウなど)のみをインポートします。エンドポイントとキーはリンクのパラメーターを使用し、他のモデルテーブルはインポートされません。", "configEmbedded": "埋め込み設定", "configRemote": "リモート設定", "configDetails": "設定の詳細", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 0fc73cf9b..a7d3646e2 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -3386,6 +3386,7 @@ "importError": "匯入失敗", "configSource": "設定來源", "codexConfigScopeHint": "只匯入其中屬於這個供應商的設定(位址、驗證、模型、推理強度等);[features]、MCP、權限這類全域設定不會匯入。", + "grokConfigScopeHint": "只匯入 [models] default 指向的那張模型表(顯示名稱、說明、介面類型、視窗等);位址和 Key 使用連結參數,其他模型表不會匯入。", "configEmbedded": "內嵌設定", "configRemote": "遠端設定", "configDetails": "設定詳情", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 6c0506ffe..478a55261 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -3385,6 +3385,7 @@ "importError": "导入失败", "configSource": "配置来源", "codexConfigScopeHint": "只导入其中属于这个供应商的设置(地址、鉴权、模型、推理强度等);[features]、MCP、权限这类全局设置不会导入。", + "grokConfigScopeHint": "只导入 [models] default 指向的那张模型表(显示名、说明、接口类型、窗口等);地址和 Key 用链接参数,其他模型表不导入。", "configEmbedded": "内嵌配置", "configRemote": "远程配置", "configDetails": "配置详情",