From ec1ae7073f78723ed2b4cddc866bc4c03d1b41e3 Mon Sep 17 00:00:00 2001 From: YoVinchen Date: Mon, 17 Nov 2025 16:40:28 +0800 Subject: [PATCH 1/5] feat(providers): add notes field for provider management - Add notes field to Provider model (backend and frontend) - Display notes with higher priority than URL in provider card - Style notes as non-clickable text to differentiate from URLs - Add notes input field in provider form - Add i18n support (zh/en) for notes field --- src-tauri/src/provider.rs | 4 +++ src/components/providers/ProviderCard.tsx | 31 +++++++++++++++++-- .../providers/forms/BasicFormFields.tsx | 14 +++++++++ .../providers/forms/ProviderForm.tsx | 2 ++ src/i18n/locales/en.json | 2 ++ src/i18n/locales/zh.json | 2 ++ src/lib/schemas/provider.ts | 1 + src/types.ts | 2 ++ 8 files changed, 56 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index b6aa159..e3b6298 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -22,6 +22,9 @@ pub struct Provider { #[serde(skip_serializing_if = "Option::is_none")] #[serde(rename = "sortIndex")] pub sort_index: Option, + /// 备注信息 + #[serde(skip_serializing_if = "Option::is_none")] + pub notes: Option, /// 供应商元数据(不写入 live 配置,仅存于 ~/.cc-switch/config.json) #[serde(skip_serializing_if = "Option::is_none")] pub meta: Option, @@ -43,6 +46,7 @@ impl Provider { category: None, created_at: None, sort_index: None, + notes: None, meta: None, } } diff --git a/src/components/providers/ProviderCard.tsx b/src/components/providers/ProviderCard.tsx index bb95b61..9c53333 100644 --- a/src/components/providers/ProviderCard.tsx +++ b/src/components/providers/ProviderCard.tsx @@ -33,10 +33,17 @@ interface ProviderCardProps { } const extractApiUrl = (provider: Provider, fallbackText: string) => { + // 优先级 1: 备注 + if (provider.notes?.trim()) { + return provider.notes.trim(); + } + + // 优先级 2: 官网地址 if (provider.websiteUrl) { return provider.websiteUrl; } + // 优先级 3: 从配置中提取请求地址 const config = provider.settingsConfig; if (config && typeof config === "object") { @@ -83,10 +90,24 @@ export function ProviderCard({ return extractApiUrl(provider, fallbackUrlText); }, [provider, fallbackUrlText]); + // 判断是否为可点击的 URL(备注不可点击) + const isClickableUrl = useMemo(() => { + // 如果有备注,则不可点击 + if (provider.notes?.trim()) { + return false; + } + // 如果显示的是回退文本,也不可点击 + if (displayUrl === fallbackUrlText) { + return false; + } + // 其他情况(官网地址或请求地址)可点击 + return true; + }, [provider.notes, displayUrl, fallbackUrlText]); + const usageEnabled = provider.meta?.usage_script?.enabled ?? false; const handleOpenWebsite = () => { - if (!displayUrl || displayUrl === fallbackUrlText) { + if (!isClickableUrl) { return; } onOpenWebsite(displayUrl); @@ -174,8 +195,14 @@ export function ProviderCard({ diff --git a/src/components/providers/forms/BasicFormFields.tsx b/src/components/providers/forms/BasicFormFields.tsx index daec379..f4e8a21 100644 --- a/src/components/providers/forms/BasicFormFields.tsx +++ b/src/components/providers/forms/BasicFormFields.tsx @@ -46,6 +46,20 @@ export function BasicFormFields({ form }: BasicFormFieldsProps) { )} /> + + ( + + {t("provider.notes")} + + + + + + )} + /> ); } diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index edafc9b..e4d057d 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -74,6 +74,7 @@ interface ProviderFormProps { initialData?: { name?: string; websiteUrl?: string; + notes?: string; settingsConfig?: Record; category?: ProviderCategory; meta?: ProviderMeta; @@ -138,6 +139,7 @@ export function ProviderForm({ () => ({ name: initialData?.name ?? "", websiteUrl: initialData?.websiteUrl ?? "", + notes: initialData?.notes ?? "", settingsConfig: initialData?.settingsConfig ? JSON.stringify(initialData.settingsConfig, null, 2) : appId === "codex" diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index b66f491..39cc560 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -83,6 +83,8 @@ "name": "Provider Name", "namePlaceholder": "e.g., Claude Official", "websiteUrl": "Website URL", + "notes": "Notes", + "notesPlaceholder": "e.g., Company dedicated account", "configJson": "Config JSON", "writeCommonConfig": "Write common config", "editCommonConfigButton": "Edit common config", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 6959f59..d998818 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -83,6 +83,8 @@ "name": "供应商名称", "namePlaceholder": "例如:Claude 官方", "websiteUrl": "官网链接", + "notes": "备注", + "notesPlaceholder": "例如:公司专用账号", "configJson": "配置 JSON", "writeCommonConfig": "写入通用配置", "editCommonConfigButton": "编辑通用配置", diff --git a/src/lib/schemas/provider.ts b/src/lib/schemas/provider.ts index 62b203a..d3ce7a7 100644 --- a/src/lib/schemas/provider.ts +++ b/src/lib/schemas/provider.ts @@ -38,6 +38,7 @@ function parseJsonError(error: unknown): string { export const providerSchema = z.object({ name: z.string().min(1, "请填写供应商名称"), websiteUrl: z.string().url("请输入有效的网址").optional().or(z.literal("")), + notes: z.string().optional(), settingsConfig: z .string() .min(1, "请填写配置内容") diff --git a/src/types.ts b/src/types.ts index 40fc5d1..6701676 100644 --- a/src/types.ts +++ b/src/types.ts @@ -14,6 +14,8 @@ export interface Provider { category?: ProviderCategory; createdAt?: number; // 添加时间戳(毫秒) sortIndex?: number; // 排序索引(用于自定义拖拽排序) + // 备注信息 + notes?: string; // 新增:是否为商业合作伙伴 isPartner?: boolean; // 可选:供应商元数据(仅存于 ~/.cc-switch/config.json,不写入 live 配置) From 6b5752db240fc1d632e6e128fa0538fc253b8764 Mon Sep 17 00:00:00 2001 From: YoVinchen Date: Mon, 17 Nov 2025 16:45:57 +0800 Subject: [PATCH 2/5] chore: format code and clean up unused props - Run cargo fmt on Rust backend code - Format TypeScript imports and code style - Remove unused appId prop from ProviderPresetSelector - Clean up unused variables in tests - Integrate notes field handling in provider dialogs --- src-tauri/src/app_config.rs | 8 ++--- src-tauri/src/claude_mcp.rs | 12 ++++---- src-tauri/src/commands/config.rs | 12 ++++---- src-tauri/src/gemini_mcp.rs | 2 -- src-tauri/src/mcp.rs | 12 ++------ src-tauri/tests/import_export_sync.rs | 30 ++++++++++++++----- src-tauri/tests/mcp_commands.rs | 22 +++++++++----- src/components/mcp/McpFormModal.tsx | 9 +++++- src/components/mcp/McpWizardModal.tsx | 4 ++- src/components/mcp/useMcpValidation.ts | 5 +--- .../providers/AddProviderDialog.tsx | 1 + .../providers/EditProviderDialog.tsx | 2 ++ .../providers/forms/ProviderForm.tsx | 1 - .../forms/ProviderPresetSelector.tsx | 3 -- tests/components/McpFormModal.test.tsx | 4 +-- 15 files changed, 71 insertions(+), 56 deletions(-) diff --git a/src-tauri/src/app_config.rs b/src-tauri/src/app_config.rs index 2daace5..dd748ea 100644 --- a/src-tauri/src/app_config.rs +++ b/src-tauri/src/app_config.rs @@ -317,7 +317,9 @@ impl MultiAppConfig { // 迁移通用配置片段:claude_common_config_snippet → common_config_snippets.claude if let Some(old_claude_snippet) = config.claude_common_config_snippet.take() { - log::info!("迁移通用配置:claude_common_config_snippet → common_config_snippets.claude"); + log::info!( + "迁移通用配置:claude_common_config_snippet → common_config_snippets.claude" + ); config.common_config_snippets.claude = Some(old_claude_snippet); updated = true; } @@ -414,9 +416,7 @@ impl MultiAppConfig { return Ok(false); } - log::info!( - "检测到已存在配置文件且 Prompt 列表为空,将尝试从现有提示词文件自动导入" - ); + log::info!("检测到已存在配置文件且 Prompt 列表为空,将尝试从现有提示词文件自动导入"); let mut imported = false; for app in [AppType::Claude, AppType::Codex, AppType::Gemini] { diff --git a/src-tauri/src/claude_mcp.rs b/src-tauri/src/claude_mcp.rs index 8a8bb35..0369e91 100644 --- a/src-tauri/src/claude_mcp.rs +++ b/src-tauri/src/claude_mcp.rs @@ -139,13 +139,11 @@ pub fn upsert_mcp_server(id: &str, spec: Value) -> Result { if is_http || is_sse { let url = spec.get("url").and_then(|x| x.as_str()).unwrap_or(""); if url.is_empty() { - return Err(AppError::McpValidation( - if is_http { - "http 类型的 MCP 服务器缺少 url 字段".into() - } else { - "sse 类型的 MCP 服务器缺少 url 字段".into() - }, - )); + return Err(AppError::McpValidation(if is_http { + "http 类型的 MCP 服务器缺少 url 字段".into() + } else { + "sse 类型的 MCP 服务器缺少 url 字段".into() + })); } } diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index 4724430..2f81def 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -184,13 +184,12 @@ pub async fn get_common_config_snippet( use crate::app_config::AppType; use std::str::FromStr; - let app = AppType::from_str(&app_type) - .map_err(|e| format!("无效的应用类型: {}", e))?; + let app = AppType::from_str(&app_type).map_err(|e| format!("无效的应用类型: {e}"))?; let guard = state .config .read() - .map_err(|e| format!("读取配置锁失败: {}", e))?; + .map_err(|e| format!("读取配置锁失败: {e}"))?; Ok(guard.common_config_snippets.get(&app).cloned()) } @@ -205,13 +204,12 @@ pub async fn set_common_config_snippet( use crate::app_config::AppType; use std::str::FromStr; - let app = AppType::from_str(&app_type) - .map_err(|e| format!("无效的应用类型: {}", e))?; + let app = AppType::from_str(&app_type).map_err(|e| format!("无效的应用类型: {e}"))?; let mut guard = state .config .write() - .map_err(|e| format!("写入配置锁失败: {}", e))?; + .map_err(|e| format!("写入配置锁失败: {e}"))?; // 验证格式(根据应用类型) if !snippet.trim().is_empty() { @@ -219,7 +217,7 @@ pub async fn set_common_config_snippet( AppType::Claude | AppType::Gemini => { // 验证 JSON 格式 serde_json::from_str::(&snippet) - .map_err(|e| format!("无效的 JSON 格式: {}", e))?; + .map_err(|e| format!("无效的 JSON 格式: {e}"))?; } AppType::Codex => { // TOML 格式暂不验证(或可使用 toml crate) diff --git a/src-tauri/src/gemini_mcp.rs b/src-tauri/src/gemini_mcp.rs index 6213d35..b2eb91a 100644 --- a/src-tauri/src/gemini_mcp.rs +++ b/src-tauri/src/gemini_mcp.rs @@ -48,8 +48,6 @@ pub fn read_mcp_json() -> Result, AppError> { Ok(Some(content)) } - - /// 读取 Gemini settings.json 中的 mcpServers 映射 pub fn read_mcp_servers_map() -> Result, AppError> { let path = user_config_path(); diff --git a/src-tauri/src/mcp.rs b/src-tauri/src/mcp.rs index 360bbec..e104054 100644 --- a/src-tauri/src/mcp.rs +++ b/src-tauri/src/mcp.rs @@ -396,11 +396,7 @@ pub fn import_from_claude(config: &mut MultiAppConfig) -> Result Result= 2, "should import both servers"); // v3.7.0: 检查统一结构 - let servers = config.mcp.servers.as_ref().expect("unified servers should exist"); + let servers = config + .mcp + .servers + .as_ref() + .expect("unified servers should exist"); let echo = servers.get("echo_server").expect("echo server"); - assert_eq!(echo.apps.codex, true, "Codex app should be enabled for echo_server"); + assert_eq!( + echo.apps.codex, true, + "Codex app should be enabled for echo_server" + ); let server_spec = echo.server.as_object().expect("server spec"); assert_eq!( server_spec @@ -502,7 +509,10 @@ url = "https://example.com" ); let http = servers.get("http_server").expect("http server"); - assert_eq!(http.apps.codex, true, "Codex app should be enabled for http_server"); + assert_eq!( + http.apps.codex, true, + "Codex app should be enabled for http_server" + ); let http_spec = http.server.as_object().expect("http spec"); assert_eq!( http_spec.get("url").and_then(|v| v.as_str()).unwrap_or(""), @@ -541,7 +551,7 @@ command = "echo" }), apps: cc_switch_lib::McpApps { claude: false, - codex: false, // 初始未启用 + codex: false, // 初始未启用 gemini: false, }, description: None, @@ -564,7 +574,10 @@ command = "echo" .expect("existing entry"); // 验证 Codex 应用已启用 - assert_eq!(entry.apps.codex, true, "Codex app should be enabled after import"); + assert_eq!( + entry.apps.codex, true, + "Codex app should be enabled after import" + ); // 验证现有配置被保留(server 不应被覆盖) let spec = entry.server.as_object().expect("server spec"); @@ -662,7 +675,7 @@ fn import_from_claude_merges_into_config() { "command": "prev" }), apps: cc_switch_lib::McpApps { - claude: false, // 初始未启用 + claude: false, // 初始未启用 codex: false, gemini: false, }, @@ -686,7 +699,10 @@ fn import_from_claude_merges_into_config() { .expect("entry exists"); // 验证 Claude 应用已启用 - assert_eq!(entry.apps.claude, true, "Claude app should be enabled after import"); + assert_eq!( + entry.apps.claude, true, + "Claude app should be enabled after import" + ); // 验证现有配置被保留(server 不应被覆盖) let server = entry.server.as_object().expect("server obj"); diff --git a/src-tauri/tests/mcp_commands.rs b/src-tauri/tests/mcp_commands.rs index 30160c7..ad342c4 100644 --- a/src-tauri/tests/mcp_commands.rs +++ b/src-tauri/tests/mcp_commands.rs @@ -127,8 +127,14 @@ fn import_mcp_from_claude_creates_config_and_enables_servers() { let guard = state.config.read().expect("lock config"); // v3.7.0: 检查统一结构 - let servers = guard.mcp.servers.as_ref().expect("unified servers should exist"); - let entry = servers.get("echo").expect("server imported into unified structure"); + let servers = guard + .mcp + .servers + .as_ref() + .expect("unified servers should exist"); + let entry = servers + .get("echo") + .expect("server imported into unified structure"); assert!( entry.apps.claude, "imported server should have Claude app enabled" @@ -182,10 +188,12 @@ fn set_mcp_enabled_for_codex_writes_live_config() { // 创建 Codex 配置目录和文件 let codex_dir = home.join(".codex"); fs::create_dir_all(&codex_dir).expect("create codex dir"); - fs::write(codex_dir.join("auth.json"), r#"{"OPENAI_API_KEY":"test-key"}"#) - .expect("create auth.json"); - fs::write(codex_dir.join("config.toml"), "") - .expect("create empty config.toml"); + fs::write( + codex_dir.join("auth.json"), + r#"{"OPENAI_API_KEY":"test-key"}"#, + ) + .expect("create auth.json"); + fs::write(codex_dir.join("config.toml"), "").expect("create empty config.toml"); let mut config = MultiAppConfig::default(); config.ensure_app(&AppType::Codex); @@ -203,7 +211,7 @@ fn set_mcp_enabled_for_codex_writes_live_config() { }), apps: McpApps { claude: false, - codex: false, // 初始未启用 + codex: false, // 初始未启用 gemini: false, }, description: None, diff --git a/src/components/mcp/McpFormModal.tsx b/src/components/mcp/McpFormModal.tsx index ba46279..50291f9 100644 --- a/src/components/mcp/McpFormModal.tsx +++ b/src/components/mcp/McpFormModal.tsx @@ -1,7 +1,14 @@ import React, { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; -import { Save, Plus, AlertCircle, ChevronDown, ChevronUp, Wand2 } from "lucide-react"; +import { + Save, + Plus, + AlertCircle, + ChevronDown, + ChevronUp, + Wand2, +} from "lucide-react"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { diff --git a/src/components/mcp/McpWizardModal.tsx b/src/components/mcp/McpWizardModal.tsx index 9a663e4..f30e90f 100644 --- a/src/components/mcp/McpWizardModal.tsx +++ b/src/components/mcp/McpWizardModal.tsx @@ -80,7 +80,9 @@ const McpWizardModal: React.FC = ({ initialServer, }) => { const { t } = useTranslation(); - const [wizardType, setWizardType] = useState<"stdio" | "http" | "sse">("stdio"); + const [wizardType, setWizardType] = useState<"stdio" | "http" | "sse">( + "stdio", + ); const [wizardTitle, setWizardTitle] = useState(""); // stdio 字段 const [wizardCommand, setWizardCommand] = useState(""); diff --git a/src/components/mcp/useMcpValidation.ts b/src/components/mcp/useMcpValidation.ts index 53169dd..e65fcbf 100644 --- a/src/components/mcp/useMcpValidation.ts +++ b/src/components/mcp/useMcpValidation.ts @@ -76,10 +76,7 @@ export function useMcpValidation() { if (typ === "stdio" && !(obj as any)?.command?.trim()) { return t("mcp.error.commandRequired"); } - if ( - (typ === "http" || typ === "sse") && - !(obj as any)?.url?.trim() - ) { + if ((typ === "http" || typ === "sse") && !(obj as any)?.url?.trim()) { return t("mcp.wizard.urlRequired"); } } diff --git a/src/components/providers/AddProviderDialog.tsx b/src/components/providers/AddProviderDialog.tsx index 53aa0e2..1f2b3d6 100644 --- a/src/components/providers/AddProviderDialog.tsx +++ b/src/components/providers/AddProviderDialog.tsx @@ -45,6 +45,7 @@ export function AddProviderDialog({ // 构造基础提交数据 const providerData: Omit = { name: values.name.trim(), + notes: values.notes?.trim() || undefined, websiteUrl: values.websiteUrl?.trim() || undefined, settingsConfig: parsedConfig, ...(values.presetCategory ? { category: values.presetCategory } : {}), diff --git a/src/components/providers/EditProviderDialog.tsx b/src/components/providers/EditProviderDialog.tsx index 46a79d9..aa221f7 100644 --- a/src/components/providers/EditProviderDialog.tsx +++ b/src/components/providers/EditProviderDialog.tsx @@ -93,6 +93,7 @@ export function EditProviderDialog({ const updatedProvider: Provider = { ...provider, name: values.name.trim(), + notes: values.notes?.trim() || undefined, websiteUrl: values.websiteUrl?.trim() || undefined, settingsConfig: parsedConfig, ...(values.presetCategory ? { category: values.presetCategory } : {}), @@ -129,6 +130,7 @@ export function EditProviderDialog({ onCancel={() => onOpenChange(false)} initialData={{ name: provider.name, + notes: provider.notes, websiteUrl: provider.websiteUrl, // 若读取到实时配置则优先使用 settingsConfig: initialSettingsConfig, diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index e4d057d..8ed4e5e 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -623,7 +623,6 @@ export function ProviderForm({ presetCategoryLabels={presetCategoryLabels} onPresetChange={handlePresetChange} category={category} - appId={appId} /> )} diff --git a/src/components/providers/forms/ProviderPresetSelector.tsx b/src/components/providers/forms/ProviderPresetSelector.tsx index dfd4c68..1927c5e 100644 --- a/src/components/providers/forms/ProviderPresetSelector.tsx +++ b/src/components/providers/forms/ProviderPresetSelector.tsx @@ -6,7 +6,6 @@ import type { ProviderPreset } from "@/config/claudeProviderPresets"; import type { CodexProviderPreset } from "@/config/codexProviderPresets"; import type { GeminiProviderPreset } from "@/config/geminiProviderPresets"; import type { ProviderCategory } from "@/types"; -import type { AppId } from "@/lib/api"; type PresetEntry = { id: string; @@ -20,7 +19,6 @@ interface ProviderPresetSelectorProps { presetCategoryLabels: Record; onPresetChange: (value: string) => void; category?: ProviderCategory; // 当前选中的分类 - appId?: AppId; } export function ProviderPresetSelector({ @@ -30,7 +28,6 @@ export function ProviderPresetSelector({ presetCategoryLabels, onPresetChange, category, - appId, }: ProviderPresetSelectorProps) { const { t } = useTranslation(); diff --git a/tests/components/McpFormModal.test.tsx b/tests/components/McpFormModal.test.tsx index 4f75237..ce01b5d 100644 --- a/tests/components/McpFormModal.test.tsx +++ b/tests/components/McpFormModal.test.tsx @@ -220,7 +220,7 @@ describe("McpFormModal", () => { }); it("缺少配置命令时阻止提交并提示错误", async () => { - const { onSave } = renderForm(); + renderForm(); fireEvent.change(screen.getByPlaceholderText("mcp.form.titlePlaceholder"), { target: { value: "no-command" }, @@ -288,7 +288,7 @@ command = "run" }); it("TOML 模式下缺少命令时展示错误提示并阻止提交", async () => { - const { onSave } = renderForm({ defaultFormat: "toml" }); + renderForm({ defaultFormat: "toml" }); // 填写 ID 字段 fireEvent.change(screen.getByPlaceholderText("mcp.form.titlePlaceholder"), { From 461ba6f4182a9adc7f65468d4ed04c580ced7dbd Mon Sep 17 00:00:00 2001 From: YoVinchen Date: Tue, 18 Nov 2025 02:03:12 +0800 Subject: [PATCH 3/5] feat(deeplink): implement ccswitch:// protocol for provider import Add deep link support to enable one-click provider configuration import via ccswitch:// URLs. Backend: - Implement URL parsing and validation (src-tauri/src/deeplink.rs) - Add Tauri commands for parse and import (src-tauri/src/commands/deeplink.rs) - Register ccswitch:// protocol in macOS Info.plist - Add comprehensive unit tests (src-tauri/tests/deeplink_import.rs) Frontend: - Create confirmation dialog with security review UI (src/components/DeepLinkImportDialog.tsx) - Add API wrapper (src/lib/api/deeplink.ts) - Integrate event listeners in App.tsx Configuration: - Update Tauri config for deep link handling - Add i18n support for Chinese and English - Include test page for deep link validation (deeplink-test.html) Files: 15 changed, 1312 insertions(+) --- deeplink-test.html | 548 ++++++++++++++++++++++++ src-tauri/Info.plist | 19 + src-tauri/src/commands/deeplink.rs | 29 ++ src-tauri/src/deeplink.rs | 354 +++++++++++++++ src-tauri/tests/deeplink_import.rs | 121 ++++++ src/components/DeepLinkImportDialog.tsx | 206 +++++++++ src/lib/api/deeplink.ts | 35 ++ 7 files changed, 1312 insertions(+) create mode 100644 deeplink-test.html create mode 100644 src-tauri/Info.plist create mode 100644 src-tauri/src/commands/deeplink.rs create mode 100644 src-tauri/src/deeplink.rs create mode 100644 src-tauri/tests/deeplink_import.rs create mode 100644 src/components/DeepLinkImportDialog.tsx create mode 100644 src/lib/api/deeplink.ts diff --git a/deeplink-test.html b/deeplink-test.html new file mode 100644 index 0000000..fd213eb --- /dev/null +++ b/deeplink-test.html @@ -0,0 +1,548 @@ + + + + + + CC Switch 深链接测试 + + + +
+
+

🔗 CC Switch 深链接测试

+

点击下方链接测试深链接导入功能

+
+ +
+ +
+

Claude Code 供应商

+ + + + +
+ + +
+

Codex 供应商

+ + + + +
+ + +
+

Gemini 供应商

+ + + + +
+ + +
+

⚠️ 使用注意事项

+
    +
  • 首次点击:浏览器会询问是否允许打开 CC Switch,请点击"允许"或"打开"
  • +
  • macOS 用户:可能需要在"系统设置" → "隐私与安全性"中允许应用
  • +
  • 测试 API Key:示例中的 API Key 仅用于测试格式,无法实际使用
  • +
  • 导入确认:点击链接后会弹出确认对话框,API Key 会被掩码显示(前4位+****)
  • +
  • 编辑配置:导入后可以在 CC Switch 中随时编辑或删除配置
  • +
+
+ + +
+

🛠️ 深链接生成器

+

填写下方表单,生成您自己的深链接

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + + + +
+
+
+ + + + diff --git a/src-tauri/Info.plist b/src-tauri/Info.plist new file mode 100644 index 0000000..93db133 --- /dev/null +++ b/src-tauri/Info.plist @@ -0,0 +1,19 @@ + + + + + + CFBundleURLTypes + + + CFBundleURLName + CC Switch Deep Link + CFBundleURLSchemes + + ccswitch + + + + + + diff --git a/src-tauri/src/commands/deeplink.rs b/src-tauri/src/commands/deeplink.rs new file mode 100644 index 0000000..663231f --- /dev/null +++ b/src-tauri/src/commands/deeplink.rs @@ -0,0 +1,29 @@ +use crate::deeplink::{import_provider_from_deeplink, parse_deeplink_url, DeepLinkImportRequest}; +use crate::store::AppState; +use tauri::State; + +/// Parse a deep link URL and return the parsed request for frontend confirmation +#[tauri::command] +pub fn parse_deeplink(url: String) -> Result { + log::info!("Parsing deep link URL: {url}"); + parse_deeplink_url(&url).map_err(|e| e.to_string()) +} + +/// Import a provider from a deep link request (after user confirmation) +#[tauri::command] +pub fn import_from_deeplink( + state: State, + request: DeepLinkImportRequest, +) -> Result { + log::info!( + "Importing provider from deep link: {} for app {}", + request.name, + request.app + ); + + let provider_id = import_provider_from_deeplink(&state, request).map_err(|e| e.to_string())?; + + log::info!("Successfully imported provider with ID: {provider_id}"); + + Ok(provider_id) +} diff --git a/src-tauri/src/deeplink.rs b/src-tauri/src/deeplink.rs new file mode 100644 index 0000000..ffde895 --- /dev/null +++ b/src-tauri/src/deeplink.rs @@ -0,0 +1,354 @@ +/// Deep link import functionality for CC Switch +/// +/// This module implements the ccswitch:// protocol for importing provider configurations +/// via deep links. See docs/ccswitch-deeplink-design.md for detailed design. +use crate::error::AppError; +use crate::provider::Provider; +use crate::services::ProviderService; +use crate::store::AppState; +use crate::AppType; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::str::FromStr; +use url::Url; + +/// Deep link import request model +/// Represents a parsed ccswitch:// URL ready for processing +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeepLinkImportRequest { + /// Protocol version (e.g., "v1") + pub version: String, + /// Resource type to import (e.g., "provider") + pub resource: String, + /// Target application (claude/codex/gemini) + pub app: String, + /// Provider name + pub name: String, + /// Provider homepage URL + pub homepage: String, + /// API endpoint/base URL + pub endpoint: String, + /// API key + pub api_key: String, + /// Optional model name + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + /// Optional notes/description + #[serde(skip_serializing_if = "Option::is_none")] + pub notes: Option, +} + +/// Parse a ccswitch:// URL into a DeepLinkImportRequest +/// +/// Expected format: +/// ccswitch://v1/import?resource=provider&app=claude&name=...&homepage=...&endpoint=...&apiKey=... +pub fn parse_deeplink_url(url_str: &str) -> Result { + // Parse URL + let url = Url::parse(url_str) + .map_err(|e| AppError::InvalidInput(format!("Invalid deep link URL: {e}")))?; + + // Validate scheme + let scheme = url.scheme(); + if scheme != "ccswitch" { + return Err(AppError::InvalidInput(format!( + "Invalid scheme: expected 'ccswitch', got '{scheme}'" + ))); + } + + // Extract version from host + let version = url + .host_str() + .ok_or_else(|| AppError::InvalidInput("Missing version in URL host".to_string()))? + .to_string(); + + // Validate version + if version != "v1" { + return Err(AppError::InvalidInput(format!( + "Unsupported protocol version: {version}" + ))); + } + + // Extract path (should be "/import") + let path = url.path(); + if path != "/import" { + return Err(AppError::InvalidInput(format!( + "Invalid path: expected '/import', got '{path}'" + ))); + } + + // Parse query parameters + let params: HashMap = url.query_pairs().into_owned().collect(); + + // Extract and validate resource type + let resource = params + .get("resource") + .ok_or_else(|| AppError::InvalidInput("Missing 'resource' parameter".to_string()))? + .clone(); + + if resource != "provider" { + return Err(AppError::InvalidInput(format!( + "Unsupported resource type: {resource}" + ))); + } + + // Extract required fields + let app = params + .get("app") + .ok_or_else(|| AppError::InvalidInput("Missing 'app' parameter".to_string()))? + .clone(); + + // Validate app type + if app != "claude" && app != "codex" && app != "gemini" { + return Err(AppError::InvalidInput(format!( + "Invalid app type: must be 'claude', 'codex', or 'gemini', got '{app}'" + ))); + } + + let name = params + .get("name") + .ok_or_else(|| AppError::InvalidInput("Missing 'name' parameter".to_string()))? + .clone(); + + let homepage = params + .get("homepage") + .ok_or_else(|| AppError::InvalidInput("Missing 'homepage' parameter".to_string()))? + .clone(); + + let endpoint = params + .get("endpoint") + .ok_or_else(|| AppError::InvalidInput("Missing 'endpoint' parameter".to_string()))? + .clone(); + + let api_key = params + .get("apiKey") + .ok_or_else(|| AppError::InvalidInput("Missing 'apiKey' parameter".to_string()))? + .clone(); + + // Validate URLs + validate_url(&homepage, "homepage")?; + validate_url(&endpoint, "endpoint")?; + + // Extract optional fields + let model = params.get("model").cloned(); + let notes = params.get("notes").cloned(); + + Ok(DeepLinkImportRequest { + version, + resource, + app, + name, + homepage, + endpoint, + api_key, + model, + notes, + }) +} + +/// Validate that a string is a valid HTTP(S) URL +fn validate_url(url_str: &str, field_name: &str) -> Result<(), AppError> { + let url = Url::parse(url_str) + .map_err(|e| AppError::InvalidInput(format!("Invalid URL for '{field_name}': {e}")))?; + + let scheme = url.scheme(); + if scheme != "http" && scheme != "https" { + return Err(AppError::InvalidInput(format!( + "Invalid URL scheme for '{field_name}': must be http or https, got '{scheme}'" + ))); + } + + Ok(()) +} + +/// Import a provider from a deep link request +/// +/// This function: +/// 1. Validates the request +/// 2. Converts it to a Provider structure +/// 3. Delegates to ProviderService for actual import +pub fn import_provider_from_deeplink( + state: &AppState, + request: DeepLinkImportRequest, +) -> Result { + // Parse app type + let app_type = AppType::from_str(&request.app) + .map_err(|_| AppError::InvalidInput(format!("Invalid app type: {}", request.app)))?; + + // Build provider configuration based on app type + let mut provider = build_provider_from_request(&app_type, &request)?; + + // Generate a unique ID for the provider using timestamp + sanitized name + // This is similar to how frontend generates IDs + let timestamp = chrono::Utc::now().timestamp_millis(); + let sanitized_name = request + .name + .chars() + .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_') + .collect::() + .to_lowercase(); + provider.id = format!("{sanitized_name}-{timestamp}"); + + let provider_id = provider.id.clone(); + + // Use ProviderService to add the provider + ProviderService::add(state, app_type, provider)?; + + Ok(provider_id) +} + +/// Build a Provider structure from a deep link request +fn build_provider_from_request( + app_type: &AppType, + request: &DeepLinkImportRequest, +) -> Result { + use serde_json::json; + + let settings_config = match app_type { + AppType::Claude => { + // Claude configuration structure + let mut env = serde_json::Map::new(); + env.insert("ANTHROPIC_AUTH_TOKEN".to_string(), json!(request.api_key)); + env.insert("ANTHROPIC_BASE_URL".to_string(), json!(request.endpoint)); + + // Add model if provided (use as default model) + if let Some(model) = &request.model { + env.insert("ANTHROPIC_MODEL".to_string(), json!(model)); + } + + json!({ "env": env }) + } + AppType::Codex => { + // Codex configuration structure + // For Codex, we store auth.json (JSON) and config.toml (TOML string) in settings_config + + // Generate TOML string for config.toml + let mut config_toml = format!( + r#"[api] +base_url = "{}" +"#, + request.endpoint + ); + + // Add model if provided + if let Some(model) = &request.model { + config_toml.push_str(&format!("model = \"{model}\"\n")); + } + + json!({ + "auth": { + "OPENAI_API_KEY": request.api_key, + }, + "config": config_toml + }) + } + AppType::Gemini => { + // Gemini configuration structure (.env format) + let mut env = serde_json::Map::new(); + env.insert("GEMINI_API_KEY".to_string(), json!(request.api_key)); + env.insert( + "GOOGLE_GEMINI_BASE_URL".to_string(), + json!(request.endpoint), + ); + + // Add model if provided + if let Some(model) = &request.model { + env.insert("GOOGLE_GEMINI_MODEL".to_string(), json!(model)); + } + + json!({ "env": env }) + } + }; + + let provider = Provider { + id: String::new(), // Will be generated by ProviderService + name: request.name.clone(), + settings_config, + website_url: Some(request.homepage.clone()), + category: None, + created_at: None, + sort_index: None, + notes: request.notes.clone(), + meta: None, + }; + + Ok(provider) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_valid_claude_deeplink() { + let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test%20Provider&homepage=https%3A%2F%2Fexample.com&endpoint=https%3A%2F%2Fapi.example.com&apiKey=sk-test-123"; + + let request = parse_deeplink_url(url).unwrap(); + + assert_eq!(request.version, "v1"); + assert_eq!(request.resource, "provider"); + assert_eq!(request.app, "claude"); + assert_eq!(request.name, "Test Provider"); + assert_eq!(request.homepage, "https://example.com"); + assert_eq!(request.endpoint, "https://api.example.com"); + assert_eq!(request.api_key, "sk-test-123"); + } + + #[test] + fn test_parse_deeplink_with_notes() { + let url = "ccswitch://v1/import?resource=provider&app=codex&name=Codex&homepage=https%3A%2F%2Fcodex.com&endpoint=https%3A%2F%2Fapi.codex.com&apiKey=key123¬es=Test%20notes"; + + let request = parse_deeplink_url(url).unwrap(); + + assert_eq!(request.notes, Some("Test notes".to_string())); + } + + #[test] + fn test_parse_invalid_scheme() { + let url = "https://v1/import?resource=provider&app=claude&name=Test"; + + let result = parse_deeplink_url(url); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Invalid scheme")); + } + + #[test] + fn test_parse_unsupported_version() { + let url = "ccswitch://v2/import?resource=provider&app=claude&name=Test"; + + let result = parse_deeplink_url(url); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Unsupported protocol version")); + } + + #[test] + fn test_parse_missing_required_field() { + let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test"; + + let result = parse_deeplink_url(url); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Missing 'homepage' parameter")); + } + + #[test] + fn test_validate_invalid_url() { + let result = validate_url("not-a-url", "test"); + assert!(result.is_err()); + } + + #[test] + fn test_validate_invalid_scheme() { + let result = validate_url("ftp://example.com", "test"); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("must be http or https")); + } +} diff --git a/src-tauri/tests/deeplink_import.rs b/src-tauri/tests/deeplink_import.rs new file mode 100644 index 0000000..d70a060 --- /dev/null +++ b/src-tauri/tests/deeplink_import.rs @@ -0,0 +1,121 @@ +use std::sync::RwLock; + +use cc_switch_lib::{ + import_provider_from_deeplink, parse_deeplink_url, AppState, AppType, MultiAppConfig, +}; + +#[path = "support.rs"] +mod support; +use support::{ensure_test_home, reset_test_fs, test_mutex}; + +#[test] +fn deeplink_import_claude_provider_persists_to_config() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let home = ensure_test_home(); + + let url = "ccswitch://v1/import?resource=provider&app=claude&name=DeepLink%20Claude&homepage=https%3A%2F%2Fexample.com&endpoint=https%3A%2F%2Fapi.example.com%2Fv1&apiKey=sk-test-claude-key&model=claude-sonnet-4"; + let request = parse_deeplink_url(url).expect("parse deeplink url"); + + let mut config = MultiAppConfig::default(); + config.ensure_app(&AppType::Claude); + + let state = AppState { + config: RwLock::new(config), + }; + + let provider_id = import_provider_from_deeplink(&state, request.clone()) + .expect("import provider from deeplink"); + + // 验证内存状态 + let guard = state.config.read().expect("read config"); + let manager = guard + .get_manager(&AppType::Claude) + .expect("claude manager should exist"); + let provider = manager + .providers + .get(&provider_id) + .expect("provider created via deeplink"); + assert_eq!(provider.name, request.name); + assert_eq!( + provider.website_url.as_deref(), + Some(request.homepage.as_str()) + ); + let auth_token = provider + .settings_config + .pointer("/env/ANTHROPIC_AUTH_TOKEN") + .and_then(|v| v.as_str()); + let base_url = provider + .settings_config + .pointer("/env/ANTHROPIC_BASE_URL") + .and_then(|v| v.as_str()); + assert_eq!(auth_token, Some(request.api_key.as_str())); + assert_eq!(base_url, Some(request.endpoint.as_str())); + drop(guard); + + // 验证配置已持久化 + let config_path = home.join(".cc-switch").join("config.json"); + assert!( + config_path.exists(), + "importing provider from deeplink should persist config.json" + ); +} + +#[test] +fn deeplink_import_codex_provider_builds_auth_and_config() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let home = ensure_test_home(); + + let url = "ccswitch://v1/import?resource=provider&app=codex&name=DeepLink%20Codex&homepage=https%3A%2F%2Fopenai.example&endpoint=https%3A%2F%2Fapi.openai.example%2Fv1&apiKey=sk-test-codex-key&model=gpt-4o"; + let request = parse_deeplink_url(url).expect("parse deeplink url"); + + let mut config = MultiAppConfig::default(); + config.ensure_app(&AppType::Codex); + + let state = AppState { + config: RwLock::new(config), + }; + + let provider_id = import_provider_from_deeplink(&state, request.clone()) + .expect("import provider from deeplink"); + + let guard = state.config.read().expect("read config"); + let manager = guard + .get_manager(&AppType::Codex) + .expect("codex manager should exist"); + let provider = manager + .providers + .get(&provider_id) + .expect("provider created via deeplink"); + assert_eq!(provider.name, request.name); + assert_eq!( + provider.website_url.as_deref(), + Some(request.homepage.as_str()) + ); + let auth_value = provider + .settings_config + .pointer("/auth/OPENAI_API_KEY") + .and_then(|v| v.as_str()); + let config_text = provider + .settings_config + .get("config") + .and_then(|v| v.as_str()) + .unwrap_or_default(); + assert_eq!(auth_value, Some(request.api_key.as_str())); + assert!( + config_text.contains(request.endpoint.as_str()), + "config.toml content should contain endpoint" + ); + assert!( + config_text.contains("model = \"gpt-4o\""), + "config.toml content should contain model setting" + ); + drop(guard); + + let config_path = home.join(".cc-switch").join("config.json"); + assert!( + config_path.exists(), + "importing provider from deeplink should persist config.json" + ); +} diff --git a/src/components/DeepLinkImportDialog.tsx b/src/components/DeepLinkImportDialog.tsx new file mode 100644 index 0000000..0c21a1c --- /dev/null +++ b/src/components/DeepLinkImportDialog.tsx @@ -0,0 +1,206 @@ +import { useState, useEffect } from "react"; +import { listen } from "@tauri-apps/api/event"; +import { DeepLinkImportRequest, deeplinkApi } from "@/lib/api/deeplink"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { toast } from "sonner"; +import { useTranslation } from "react-i18next"; +import { useQueryClient } from "@tanstack/react-query"; + +interface DeeplinkError { + url: string; + error: string; +} + +export function DeepLinkImportDialog() { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const [request, setRequest] = useState(null); + const [isImporting, setIsImporting] = useState(false); + const [isOpen, setIsOpen] = useState(false); + + useEffect(() => { + // Listen for deep link import events + const unlistenImport = listen( + "deeplink-import", + (event) => { + console.log("Deep link import event received:", event.payload); + setRequest(event.payload); + setIsOpen(true); + }, + ); + + // Listen for deep link error events + const unlistenError = listen("deeplink-error", (event) => { + console.error("Deep link error:", event.payload); + toast.error(t("deeplink.parseError"), { + description: event.payload.error, + }); + }); + + return () => { + unlistenImport.then((fn) => fn()); + unlistenError.then((fn) => fn()); + }; + }, [t]); + + const handleImport = async () => { + if (!request) return; + + setIsImporting(true); + + try { + await deeplinkApi.importFromDeeplink(request); + + // Invalidate provider queries to refresh the list + await queryClient.invalidateQueries({ + queryKey: ["providers", request.app], + }); + + toast.success(t("deeplink.importSuccess"), { + description: t("deeplink.importSuccessDescription", { + name: request.name, + }), + }); + + setIsOpen(false); + setRequest(null); + } catch (error) { + console.error("Failed to import provider from deep link:", error); + toast.error(t("deeplink.importError"), { + description: error instanceof Error ? error.message : String(error), + }); + } finally { + setIsImporting(false); + } + }; + + const handleCancel = () => { + setIsOpen(false); + setRequest(null); + }; + + if (!request) return null; + + // Mask API key for display (show first 4 chars + ***) + const maskedApiKey = + request.apiKey.length > 4 + ? `${request.apiKey.substring(0, 4)}${"*".repeat(20)}` + : "****"; + + return ( + + + {/* 标题显式左对齐,避免默认居中样式影响 */} + + {t("deeplink.confirmImport")} + + {t("deeplink.confirmImportDescription")} + + + + {/* 主体内容整体右移,略大于标题内边距,让内容看起来不贴边 */} +
+ {/* App Type */} +
+
+ {t("deeplink.app")} +
+
+ + {request.app} + +
+
+ + {/* Provider Name */} +
+
+ {t("deeplink.providerName")} +
+
{request.name}
+
+ + {/* Homepage */} +
+
+ {t("deeplink.homepage")} +
+
+ {request.homepage} +
+
+ + {/* API Endpoint */} +
+
+ {t("deeplink.endpoint")} +
+
+ {request.endpoint} +
+
+ + {/* API Key (masked) */} +
+
+ {t("deeplink.apiKey")} +
+
+ {maskedApiKey} +
+
+ + {/* Model (if present) */} + {request.model && ( +
+
+ {t("deeplink.model")} +
+
+ {request.model} +
+
+ )} + + {/* Notes (if present) */} + {request.notes && ( +
+
+ {t("deeplink.notes")} +
+
+ {request.notes} +
+
+ )} + + {/* Warning */} +
+ {t("deeplink.warning")} +
+
+ + + + + +
+
+ ); +} diff --git a/src/lib/api/deeplink.ts b/src/lib/api/deeplink.ts new file mode 100644 index 0000000..52f7712 --- /dev/null +++ b/src/lib/api/deeplink.ts @@ -0,0 +1,35 @@ +import { invoke } from "@tauri-apps/api/core"; + +export interface DeepLinkImportRequest { + version: string; + resource: string; + app: "claude" | "codex" | "gemini"; + name: string; + homepage: string; + endpoint: string; + apiKey: string; + model?: string; + notes?: string; +} + +export const deeplinkApi = { + /** + * Parse a deep link URL + * @param url The ccswitch:// URL to parse + * @returns Parsed deep link request + */ + parseDeeplink: async (url: string): Promise => { + return invoke("parse_deeplink", { url }); + }, + + /** + * Import a provider from a deep link request + * @param request The deep link import request + * @returns The ID of the imported provider + */ + importFromDeeplink: async ( + request: DeepLinkImportRequest, + ): Promise => { + return invoke("import_from_deeplink", { request }); + }, +}; From 956e72378192ef48d97b5510a2948c704e58a389 Mon Sep 17 00:00:00 2001 From: YoVinchen Date: Tue, 18 Nov 2025 02:06:10 +0800 Subject: [PATCH 4/5] chore(deeplink): integrate deep link handling into app lifecycle Wire up deep link infrastructure with app initialization and event handling. Backend Integration: - Register deep link module and commands in mod.rs - Add URL handling in app setup (src-tauri/src/lib.rs:handle_deeplink_url) - Handle deep links from single instance callback (Windows/Linux CLI) - Handle deep links from macOS system events - Add tauri-plugin-deep-link dependency (Cargo.toml) Frontend Integration: - Listen for deeplink-import/deeplink-error events in App.tsx - Update DeepLinkImportDialog component imports Configuration: - Enable deep link plugin in tauri.conf.json - Update Cargo.lock for new dependencies Localization: - Add Chinese translations for deep link UI (zh.json) - Add English translations for deep link UI (en.json) Files: 9 changed, 359 insertions(+), 18 deletions(-) --- src-tauri/Cargo.lock | 111 +++++++++++++ src-tauri/Cargo.toml | 4 +- src-tauri/src/commands/mod.rs | 2 + src-tauri/src/lib.rs | 201 ++++++++++++++++++++++-- src-tauri/tauri.conf.json | 14 +- src/App.tsx | 3 + src/components/DeepLinkImportDialog.tsx | 6 +- src/i18n/locales/en.json | 18 +++ src/i18n/locales/zh.json | 18 +++ 9 files changed, 359 insertions(+), 18 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index d966f0a..3564351 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -579,6 +579,7 @@ dependencies = [ "serial_test", "tauri", "tauri-build", + "tauri-plugin-deep-link", "tauri-plugin-dialog", "tauri-plugin-log", "tauri-plugin-opener", @@ -591,6 +592,7 @@ dependencies = [ "tokio", "toml 0.8.2", "toml_edit 0.22.27", + "url", ] [[package]] @@ -665,6 +667,26 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.16", + "once_cell", + "tiny-keccak", +] + [[package]] name = "convert_case" version = "0.4.0" @@ -754,6 +776,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.6" @@ -983,6 +1011,15 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + [[package]] name = "downcast-rs" version = "1.2.1" @@ -1671,6 +1708,12 @@ dependencies = [ "ahash", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.16.0" @@ -1747,6 +1790,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" + [[package]] name = "httparse" version = "1.10.1" @@ -2778,6 +2827,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + [[package]] name = "ordered-stream" version = "0.2.0" @@ -3623,6 +3682,16 @@ dependencies = [ "cc", ] +[[package]] +name = "rust-ini" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + [[package]] name = "rust_decimal" version = "1.38.0" @@ -4362,6 +4431,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http", + "http-range", "jni", "libc", "log", @@ -4477,6 +4547,27 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-deep-link" +version = "2.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e82759f7c7d51de3cbde51c04b3f2332de52436ed84541182cd8944b04e9e73" +dependencies = [ + "dunce", + "plist", + "rust-ini", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.17", + "tracing", + "url", + "windows-registry", + "windows-result 0.3.4", +] + [[package]] name = "tauri-plugin-dialog" version = "2.4.0" @@ -4831,6 +4922,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tinystr" version = "0.8.1" @@ -5754,6 +5854,17 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + [[package]] name = "windows-result" version = "0.3.4" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 066182b..77656d8 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -26,13 +26,14 @@ serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } log = "0.4" chrono = "0.4" -tauri = { version = "2.8.2", features = ["tray-icon"] } +tauri = { version = "2.8.2", features = ["tray-icon", "protocol-asset"] } tauri-plugin-log = "2" tauri-plugin-opener = "2" tauri-plugin-process = "2" tauri-plugin-updater = "2" tauri-plugin-dialog = "2" tauri-plugin-store = "2" +tauri-plugin-deep-link = "2" dirs = "5.0" toml = "0.8" toml_edit = "0.22" @@ -42,6 +43,7 @@ futures = "0.3" regex = "1.10" rquickjs = { version = "0.8", features = ["array-buffer", "classes"] } thiserror = "1.0" +url = "2.5" [target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies] tauri-plugin-single-instance = "2" diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 90d7516..ab99fca 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,6 +1,7 @@ #![allow(non_snake_case)] mod config; +mod deeplink; mod import_export; mod mcp; mod misc; @@ -10,6 +11,7 @@ mod provider; mod settings; pub use config::*; +pub use deeplink::*; pub use import_export::*; pub use mcp::*; pub use misc::*; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9e8852e..2f9def5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,6 +5,7 @@ mod claude_plugin; mod codex_config; mod commands; mod config; +mod deeplink; mod error; mod gemini_config; // 新增 mod gemini_mcp; @@ -22,6 +23,7 @@ pub use app_config::{AppType, McpApps, McpServer, MultiAppConfig}; pub use codex_config::{get_codex_auth_path, get_codex_config_path, write_codex_live_atomic}; pub use commands::*; pub use config::{get_claude_mcp_path, get_claude_settings_path, read_json_file}; +pub use deeplink::{import_provider_from_deeplink, parse_deeplink_url, DeepLinkImportRequest}; pub use error::AppError; pub use mcp::{ import_from_claude, import_from_codex, import_from_gemini, remove_server_from_claude, @@ -35,6 +37,7 @@ pub use services::{ }; pub use settings::{update_settings, AppSettings}; pub use store::AppState; +use tauri_plugin_deep_link::DeepLinkExt; use tauri::{ menu::{CheckMenuItem, Menu, MenuBuilder, MenuItem}, @@ -281,6 +284,65 @@ fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) { } } +/// 统一处理 ccswitch:// 深链接 URL +/// +/// - 解析 URL +/// - 向前端发射 `deeplink-import` / `deeplink-error` 事件 +/// - 可选:在成功时聚焦主窗口 +fn handle_deeplink_url( + app: &tauri::AppHandle, + url_str: &str, + focus_main_window: bool, + source: &str, +) -> bool { + if !url_str.starts_with("ccswitch://") { + return false; + } + + log::info!("✓ Deep link URL detected from {source}: {url_str}"); + + match crate::deeplink::parse_deeplink_url(url_str) { + Ok(request) => { + log::info!( + "✓ Successfully parsed deep link: resource={}, app={}, name={}", + request.resource, + request.app, + request.name + ); + + if let Err(e) = app.emit("deeplink-import", &request) { + log::error!("✗ Failed to emit deeplink-import event: {e}"); + } else { + log::info!("✓ Emitted deeplink-import event to frontend"); + } + + if focus_main_window { + if let Some(window) = app.get_webview_window("main") { + let _ = window.unminimize(); + let _ = window.show(); + let _ = window.set_focus(); + log::info!("✓ Window shown and focused"); + } + } + } + Err(e) => { + log::error!("✗ Failed to parse deep link URL: {e}"); + + if let Err(emit_err) = app.emit( + "deeplink-error", + serde_json::json!({ + "url": url_str, + "error": e.to_string() + }), + ) { + log::error!("✗ Failed to emit deeplink-error event: {emit_err}"); + } + } + } + + true +} + // /// 内部切换供应商函数 @@ -346,7 +408,27 @@ pub fn run() { #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] { - builder = builder.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { + builder = builder.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { + log::info!("=== Single Instance Callback Triggered ==="); + log::info!("Args count: {}", args.len()); + for (i, arg) in args.iter().enumerate() { + log::info!(" arg[{i}]: {arg}"); + } + + // Check for deep link URL in args (mainly for Windows/Linux command line) + let mut found_deeplink = false; + for arg in &args { + if handle_deeplink_url(app, arg, false, "single_instance args") { + found_deeplink = true; + break; + } + } + + if !found_deeplink { + log::info!("ℹ No deep link URL found in args (this is expected on macOS when launched via system)"); + } + + // Show and focus window regardless if let Some(window) = app.get_webview_window("main") { let _ = window.unminimize(); let _ = window.show(); @@ -356,6 +438,8 @@ pub fn run() { } let builder = builder + // 注册 deep-link 插件(处理 macOS AppleEvent 和其他平台的深链接) + .plugin(tauri_plugin_deep_link::init()) // 拦截窗口关闭:根据设置决定是否最小化到托盘 .on_window_event(|window, event| { if let tauri::WindowEvent::CloseRequested { api, .. } = event { @@ -471,7 +555,40 @@ pub fn run() { config_guard.ensure_app(&app_config::AppType::Codex); } - // 启动阶段不再无条件保存,避免意外覆盖用户配置。 + // 启动阶段不再无条件保存,避免意外覆盖用户配置。 + + // 注册 deep-link URL 处理器(使用正确的 DeepLinkExt API) + log::info!("=== Registering deep-link URL handler ==="); + + // Linux 和 Windows 调试模式需要显式注册 + #[cfg(any(target_os = "linux", all(debug_assertions, windows)))] + { + if let Err(e) = app.deep_link().register_all() { + log::error!("✗ Failed to register deep link schemes: {}", e); + } else { + log::info!("✓ Deep link schemes registered (Linux/Windows)"); + } + } + + // 注册 URL 处理回调(所有平台通用) + app.deep_link().on_open_url({ + let app_handle = app.handle().clone(); + move |event| { + log::info!("=== Deep Link Event Received (on_open_url) ==="); + let urls = event.urls(); + log::info!("Received {} URL(s)", urls.len()); + + for (i, url) in urls.iter().enumerate() { + let url_str = url.as_str(); + log::info!(" URL[{i}]: {url_str}"); + + if handle_deeplink_url(&app_handle, url_str, true, "on_open_url") { + break; // Process only first ccswitch:// URL + } + } + } + }); + log::info!("✓ Deep-link URL handler registered"); // 创建动态托盘菜单 let menu = create_tray_menu(app.handle(), &app_state)?; @@ -572,6 +689,9 @@ pub fn run() { commands::save_file_dialog, commands::open_file_dialog, commands::sync_current_providers_live, + // Deep link import + commands::parse_deeplink, + commands::import_from_deeplink, update_tray_menu, ]); @@ -581,17 +701,74 @@ pub fn run() { app.run(|app_handle, event| { #[cfg(target_os = "macos")] - // macOS 在 Dock 图标被点击并重新激活应用时会触发 Reopen 事件,这里手动恢复主窗口 - if let RunEvent::Reopen { .. } = event { - if let Some(window) = app_handle.get_webview_window("main") { - #[cfg(target_os = "windows")] - { - let _ = window.set_skip_taskbar(false); + { + match event { + // macOS 在 Dock 图标被点击并重新激活应用时会触发 Reopen 事件,这里手动恢复主窗口 + RunEvent::Reopen { .. } => { + if let Some(window) = app_handle.get_webview_window("main") { + #[cfg(target_os = "windows")] + { + let _ = window.set_skip_taskbar(false); + } + let _ = window.unminimize(); + let _ = window.show(); + let _ = window.set_focus(); + apply_tray_policy(app_handle, true); + } } - let _ = window.unminimize(); - let _ = window.show(); - let _ = window.set_focus(); - apply_tray_policy(app_handle, true); + // 处理通过自定义 URL 协议触发的打开事件(例如 ccswitch://...) + RunEvent::Opened { urls } => { + if let Some(url) = urls.first() { + let url_str = url.to_string(); + log::info!("RunEvent::Opened with URL: {url_str}"); + + if url_str.starts_with("ccswitch://") { + // 解析并广播深链接事件,复用与 single_instance 相同的逻辑 + match crate::deeplink::parse_deeplink_url(&url_str) { + Ok(request) => { + log::info!( + "Successfully parsed deep link from RunEvent::Opened: resource={}, app={}", + request.resource, + request.app + ); + + if let Err(e) = + app_handle.emit("deeplink-import", &request) + { + log::error!( + "Failed to emit deep link event from RunEvent::Opened: {e}" + ); + } + } + Err(e) => { + log::error!( + "Failed to parse deep link URL from RunEvent::Opened: {e}" + ); + + if let Err(emit_err) = app_handle.emit( + "deeplink-error", + serde_json::json!({ + "url": url_str, + "error": e.to_string() + }), + ) { + log::error!( + "Failed to emit deep link error event from RunEvent::Opened: {emit_err}" + ); + } + } + } + + // 确保主窗口可见 + if let Some(window) = app_handle.get_webview_window("main") { + let _ = window.unminimize(); + let _ = window.show(); + let _ = window.set_focus(); + } + } + } + } + _ => {} } } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index a66f8d9..ec1585c 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -23,7 +23,11 @@ } ], "security": { - "csp": "default-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ipc: http://ipc.localhost https: http:" + "csp": "default-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ipc: http://ipc.localhost https: http:", + "assetProtocol": { + "enable": true, + "scope": [] + } } }, "bundle": { @@ -41,9 +45,17 @@ "wix": { "template": "wix/per-user-main.wxs" } + }, + "macOS": { + "minimumSystemVersion": "10.15" } }, "plugins": { + "deep-link": { + "desktop": { + "schemes": ["ccswitch"] + } + }, "updater": { "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEM4MDI4QzlBNTczOTI4RTMKUldUaktEbFhtb3dDeUM5US9kT0FmdGR5Ti9vQzcwa2dTMlpibDVDUmQ2M0VGTzVOWnd0SGpFVlEK", "endpoints": [ diff --git a/src/App.tsx b/src/App.tsx index d15b0de..45c5857 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -22,6 +22,7 @@ import { UpdateBadge } from "@/components/UpdateBadge"; import UsageScriptModal from "@/components/UsageScriptModal"; import UnifiedMcpPanel from "@/components/mcp/UnifiedMcpPanel"; import PromptPanel from "@/components/prompts/PromptPanel"; +import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog"; import { Button } from "@/components/ui/button"; function App() { @@ -303,6 +304,8 @@ function App() { /> + + ); } diff --git a/src/components/DeepLinkImportDialog.tsx b/src/components/DeepLinkImportDialog.tsx index 0c21a1c..49f9f65 100644 --- a/src/components/DeepLinkImportDialog.tsx +++ b/src/components/DeepLinkImportDialog.tsx @@ -113,10 +113,8 @@ export function DeepLinkImportDialog() {
{t("deeplink.app")}
-
- - {request.app} - +
+ {request.app}
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 39cc560..ab6a9e2 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -608,5 +608,23 @@ "deleteTitle": "Confirm Delete", "deleteMessage": "Are you sure you want to delete prompt \"{{name}}\"?" } + }, + "deeplink": { + "confirmImport": "Confirm Import Provider", + "confirmImportDescription": "The following configuration will be imported from deep link into CC Switch", + "app": "App Type", + "providerName": "Provider Name", + "homepage": "Homepage", + "endpoint": "API Endpoint", + "apiKey": "API Key", + "model": "Model", + "notes": "Notes", + "import": "Import", + "importing": "Importing...", + "warning": "Please confirm the information above is correct before importing. You can edit or delete it later in the provider list.", + "parseError": "Failed to parse deep link", + "importSuccess": "Import successful", + "importSuccessDescription": "Provider \"{{name}}\" has been successfully imported", + "importError": "Failed to import" } } diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index d998818..da43db7 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -608,5 +608,23 @@ "deleteTitle": "确认删除", "deleteMessage": "确定要删除提示词 \"{{name}}\" 吗?" } + }, + "deeplink": { + "confirmImport": "确认导入供应商配置", + "confirmImportDescription": "以下配置将导入到 CC Switch", + "app": "应用类型", + "providerName": "供应商名称", + "homepage": "官网地址", + "endpoint": "API 端点", + "apiKey": "API 密钥", + "model": "模型", + "notes": "备注", + "import": "导入", + "importing": "导入中...", + "warning": "请确认以上信息准确无误后再导入。导入后可在供应商列表中编辑或删除。", + "parseError": "深链接解析失败", + "importSuccess": "导入成功", + "importSuccessDescription": "供应商 \"{{name}}\" 已成功导入", + "importError": "导入失败" } } From a7f1461a333c743995d334867f1bddb50dedf151 Mon Sep 17 00:00:00 2001 From: YoVinchen Date: Wed, 19 Nov 2025 00:33:31 +0800 Subject: [PATCH 5/5] refactor(deeplink): enhance Codex provider template generation Align deep link import with UI preset generation logic by: - Adding complete config.toml template matching frontend defaults - Generating safe provider name from sanitized input - Including model_provider, reasoning_effort, and wire_api settings - Removing minimal template that only contained base_url - Cleaning up deprecated test file deeplink-test.html --- deeplink-test.html => deplink.html | 29 ++++++----- src-tauri/src/deeplink.rs | 77 +++++++++++++++++++++++++----- 2 files changed, 81 insertions(+), 25 deletions(-) rename deeplink-test.html => deplink.html (94%) diff --git a/deeplink-test.html b/deplink.html similarity index 94% rename from deeplink-test.html rename to deplink.html index fd213eb..5fe2c5d 100644 --- a/deeplink-test.html +++ b/deplink.html @@ -130,6 +130,8 @@ } .info-box ul { + list-style: disc; + margin-left: 20px; color: #856404; font-size: 14px; line-height: 1.8; @@ -294,9 +296,9 @@ Claude Official (官方)

- 导入 Claude 官方 API 配置。使用官方端点 api.anthropic.com,默认模型 claude-sonnet-4-20250514。 + 导入 Claude 官方 API 配置。使用官方端点 api.anthropic.com,默认模型 claude-haiku-4.1。

- 📥 导入 Claude Official @@ -308,9 +310,9 @@ Claude 测试环境

- 公司内部测试环境配置示例。包含备注信息,方便区分不同环境。默认模型 claude-sonnet-4-20250514。 + 公司内部测试环境配置示例。包含备注信息,方便区分不同环境。默认模型 claude-haiku-4.1。

- 📥 导入测试环境 @@ -327,9 +329,9 @@ OpenAI Official (官方)

- 导入 OpenAI 官方 API 配置。使用官方端点 api.openai.com,默认模型 gpt-4o。 + 导入 OpenAI 官方 API 配置。使用官方端点 api.openai.com,默认模型 gpt-5.1。

- 📥 导入 OpenAI Official @@ -341,9 +343,9 @@ Azure OpenAI

- Azure 部署的 OpenAI 服务示例。适合企业用户使用 Azure 云服务。默认模型 gpt-4o。 + Azure 部署的 OpenAI 服务示例。适合企业用户使用 Azure 云服务。默认模型 gpt-5.1。

- 📥 导入 Azure OpenAI @@ -360,9 +362,9 @@ Google Gemini Official

- 导入 Google Gemini 官方 API 配置。默认模型 gemini-2.0-flash-exp。 + 导入 Google Gemini 官方 API 配置。默认模型 gemini-3-pro-preview。

- 📥 导入 Google Gemini @@ -374,9 +376,9 @@ Gemini 测试环境

- 公司内部 Gemini 测试环境配置示例。用于验证 Gemini 相关深链接导入流程,请求地址为:https://api-gemini-test.company.com/v1beta。默认模型 gemini-2.0-flash-exp。 + 公司内部 Gemini 测试环境配置示例。用于验证 Gemini 相关深链接导入流程,请求地址为:https://api-gemini-test.company.com/v1beta。默认模型 gemini-3-pro-preview。

- 📥 导入 Gemini 测试环境 @@ -431,7 +433,7 @@
- +
@@ -546,3 +548,4 @@ + diff --git a/src-tauri/src/deeplink.rs b/src-tauri/src/deeplink.rs index ffde895..058e43e 100644 --- a/src-tauri/src/deeplink.rs +++ b/src-tauri/src/deeplink.rs @@ -220,21 +220,74 @@ fn build_provider_from_request( } AppType::Codex => { // Codex configuration structure - // For Codex, we store auth.json (JSON) and config.toml (TOML string) in settings_config + // For Codex, we store auth.json (JSON) and config.toml (TOML string) in settings_config。 + // + // 这里尽量与前端 `getCodexCustomTemplate` 的默认模板保持一致, + // 再根据深链接参数注入 base_url / model,避免出现“只有 base_url 行”的极简配置, + // 让通过 UI 新建和通过深链接导入的 Codex 自定义供应商行为一致。 - // Generate TOML string for config.toml - let mut config_toml = format!( - r#"[api] -base_url = "{}" -"#, - request.endpoint + // 1. 生成一个适合作为 model_provider 名的安全标识 + // 规则尽量与前端 codexProviderPresets.generateThirdPartyConfig 保持一致: + // - 转小写 + // - 非 [a-z0-9_] 统一替换为下划线 + // - 去掉首尾下划线 + // - 若结果为空,则使用 "custom" + let clean_provider_name = { + let raw: String = request + .name + .chars() + .filter(|c| !c.is_control()) + .collect(); + let lower = raw.to_lowercase(); + let mut key: String = lower + .chars() + .map(|c| match c { + 'a'..='z' | '0'..='9' | '_' => c, + _ => '_', + }) + .collect(); + + // 去掉首尾下划线 + while key.starts_with('_') { + key.remove(0); + } + while key.ends_with('_') { + key.pop(); + } + + if key.is_empty() { + "custom".to_string() + } else { + key + } + }; + + // 2. 模型名称:优先使用 deeplink 中的 model,否则退回到 Codex 默认模型 + let model_name = request + .model + .as_deref() + .unwrap_or("gpt-5-codex") + .to_string(); + + // 3. 端点:与 UI 中 Base URL 处理方式保持一致,去掉结尾多余的斜杠 + let endpoint = request.endpoint.trim().trim_end_matches('/').to_string(); + + // 4. 组装 config.toml 内容 + // 使用 Rust 1.58+ 的内联格式化语法,避免 clippy::uninlined_format_args 警告 + let config_toml = format!( + r#"model_provider = "{clean_provider_name}" +model = "{model_name}" +model_reasoning_effort = "high" +disable_response_storage = true + +[model_providers.{clean_provider_name}] +name = "{clean_provider_name}" +base_url = "{endpoint}" +wire_api = "responses" +requires_openai_auth = true +"# ); - // Add model if provided - if let Some(model) = &request.model { - config_toml.push_str(&format!("model = \"{model}\"\n")); - } - json!({ "auth": { "OPENAI_API_KEY": request.api_key,