- feat(types): add Provider.meta and ProviderMeta (snake_case) with custom_endpoints map

- feat(provider-form): persist custom endpoints on provider create by merging EndpointSpeedTest’s custom URLs into meta.custom_endpoints on submit

- feat(endpoint-speed-test): add onCustomEndpointsChange callback emitting normalized custom URLs; wire it for both Claude/Codex modals

- fix(api): send alias param names (app/appType/app_type and provider_id/providerId) in Tauri invokes to avoid “missing providerId” with older backends

- storage: custom endpoints are stored in ~/.cc-switch/config.json under providers[<id>].meta.custom_endpoints (not in settings.json)

- behavior: edit flow remains immediate writes; create flow now writes once via addProvider, removing the providerId dependency during creation
This commit is contained in:
Jason
2025-10-07 11:09:00 +08:00
parent 498920dea6
commit 061aef1c2f
7 changed files with 311 additions and 103 deletions

View File

@@ -9,7 +9,7 @@ use crate::app_config::AppType;
use crate::claude_plugin;
use crate::codex_config;
use crate::config::{self, get_claude_settings_path, ConfigStatus};
use crate::provider::Provider;
use crate::provider::{Provider, ProviderMeta};
use crate::speedtest;
use crate::store::AppState;
@@ -742,36 +742,80 @@ pub async fn test_api_endpoints(
/// 获取自定义端点列表
#[tauri::command]
pub async fn get_custom_endpoints(app_type: AppType) -> Result<Vec<crate::settings::CustomEndpoint>, String> {
let settings = crate::settings::get_settings();
let endpoints = match app_type {
AppType::Claude => &settings.custom_endpoints_claude,
AppType::Codex => &settings.custom_endpoints_codex,
pub async fn get_custom_endpoints(
state: State<'_, crate::store::AppState>,
app_type: Option<AppType>,
app: Option<String>,
appType: Option<String>,
provider_id: Option<String>,
providerId: Option<String>,
) -> Result<Vec<crate::settings::CustomEndpoint>, String> {
let app_type = app_type
.or_else(|| app.as_deref().map(|s| s.into()))
.or_else(|| appType.as_deref().map(|s| s.into()))
.unwrap_or(AppType::Claude);
let provider_id = provider_id
.or(providerId)
.ok_or_else(|| "缺少 providerId".to_string())?;
let mut cfg_guard = state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
let manager = cfg_guard
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
let Some(provider) = manager.providers.get_mut(&provider_id) else {
return Ok(vec![]);
};
let mut result: Vec<crate::settings::CustomEndpoint> = endpoints.values().cloned().collect();
// 按添加时间降序排序(最新的在前)
result.sort_by(|a, b| b.added_at.cmp(&a.added_at));
// 首选从 provider.meta 读取
let meta = provider.meta.get_or_insert_with(ProviderMeta::default);
if !meta.custom_endpoints.is_empty() {
let mut result: Vec<_> = meta.custom_endpoints.values().cloned().collect();
result.sort_by(|a, b| b.added_at.cmp(&a.added_at));
return Ok(result);
}
Ok(result)
Ok(vec![])
}
/// 添加自定义端点
#[tauri::command]
pub async fn add_custom_endpoint(
app_type: AppType,
state: State<'_, crate::store::AppState>,
app_type: Option<AppType>,
app: Option<String>,
appType: Option<String>,
provider_id: Option<String>,
providerId: Option<String>,
url: String,
) -> Result<(), String> {
let app_type = app_type
.or_else(|| app.as_deref().map(|s| s.into()))
.or_else(|| appType.as_deref().map(|s| s.into()))
.unwrap_or(AppType::Claude);
let provider_id = provider_id
.or(providerId)
.ok_or_else(|| "缺少 providerId".to_string())?;
let normalized = url.trim().trim_end_matches('/').to_string();
if normalized.is_empty() {
return Err("URL 不能为空".to_string());
}
let mut settings = crate::settings::get_settings();
let endpoints = match app_type {
AppType::Claude => &mut settings.custom_endpoints_claude,
AppType::Codex => &mut settings.custom_endpoints_codex,
let mut cfg_guard = state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
let manager = cfg_guard
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
let Some(provider) = manager.providers.get_mut(&provider_id) else {
return Err("供应商不存在或未选择".to_string());
};
let meta = provider.meta.get_or_insert_with(ProviderMeta::default);
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@@ -783,56 +827,90 @@ pub async fn add_custom_endpoint(
added_at: timestamp,
last_used: None,
};
endpoints.insert(normalized, endpoint);
crate::settings::update_settings(settings)?;
meta.custom_endpoints.insert(normalized, endpoint);
drop(cfg_guard);
state.save()?;
Ok(())
}
/// 删除自定义端点
#[tauri::command]
pub async fn remove_custom_endpoint(
app_type: AppType,
state: State<'_, crate::store::AppState>,
app_type: Option<AppType>,
app: Option<String>,
appType: Option<String>,
provider_id: Option<String>,
providerId: Option<String>,
url: String,
) -> Result<(), String> {
let app_type = app_type
.or_else(|| app.as_deref().map(|s| s.into()))
.or_else(|| appType.as_deref().map(|s| s.into()))
.unwrap_or(AppType::Claude);
let provider_id = provider_id
.or(providerId)
.ok_or_else(|| "缺少 providerId".to_string())?;
let normalized = url.trim().trim_end_matches('/').to_string();
let mut settings = crate::settings::get_settings();
let endpoints = match app_type {
AppType::Claude => &mut settings.custom_endpoints_claude,
AppType::Codex => &mut settings.custom_endpoints_codex,
};
endpoints.remove(&normalized);
crate::settings::update_settings(settings)?;
let mut cfg_guard = state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
let manager = cfg_guard
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
if let Some(provider) = manager.providers.get_mut(&provider_id) {
if let Some(meta) = provider.meta.as_mut() {
meta.custom_endpoints.remove(&normalized);
}
}
drop(cfg_guard);
state.save()?;
Ok(())
}
/// 更新端点最后使用时间
#[tauri::command]
pub async fn update_endpoint_last_used(
app_type: AppType,
state: State<'_, crate::store::AppState>,
app_type: Option<AppType>,
app: Option<String>,
appType: Option<String>,
provider_id: Option<String>,
providerId: Option<String>,
url: String,
) -> Result<(), String> {
let app_type = app_type
.or_else(|| app.as_deref().map(|s| s.into()))
.or_else(|| appType.as_deref().map(|s| s.into()))
.unwrap_or(AppType::Claude);
let provider_id = provider_id
.or(providerId)
.ok_or_else(|| "缺少 providerId".to_string())?;
let normalized = url.trim().trim_end_matches('/').to_string();
let mut settings = crate::settings::get_settings();
let endpoints = match app_type {
AppType::Claude => &mut settings.custom_endpoints_claude,
AppType::Codex => &mut settings.custom_endpoints_codex,
};
let mut cfg_guard = state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
let manager = cfg_guard
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
if let Some(endpoint) = endpoints.get_mut(&normalized) {
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as i64;
endpoint.last_used = Some(timestamp);
crate::settings::update_settings(settings)?;
if let Some(provider) = manager.providers.get_mut(&provider_id) {
if let Some(meta) = provider.meta.as_mut() {
if let Some(endpoint) = meta.custom_endpoints.get_mut(&normalized) {
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as i64;
endpoint.last_used = Some(timestamp);
}
}
}
drop(cfg_guard);
state.save()?;
Ok(())
}

View File

@@ -19,6 +19,9 @@ pub struct Provider {
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "createdAt")]
pub created_at: Option<i64>,
/// 供应商元数据(不写入 live 配置,仅存于 ~/.cc-switch/config.json
#[serde(skip_serializing_if = "Option::is_none")]
pub meta: Option<ProviderMeta>,
}
impl Provider {
@@ -36,6 +39,7 @@ impl Provider {
website_url,
category: None,
created_at: None,
meta: None,
}
}
}
@@ -56,6 +60,14 @@ impl Default for ProviderManager {
}
}
/// 供应商元数据
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProviderMeta {
/// 自定义端点列表(按 URL 去重存储)
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub custom_endpoints: HashMap<String, crate::settings::CustomEndpoint>,
}
impl ProviderManager {
/// 获取所有供应商
pub fn get_all_providers(&self) -> &HashMap<String, Provider> {

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef, useMemo } from "react";
import { Provider, ProviderCategory } from "../types";
import { Provider, ProviderCategory, CustomEndpoint } from "../types";
import { AppType } from "../lib/tauri-api";
import {
updateCommonConfigSnippet,
@@ -219,6 +219,10 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
const [codexBaseUrl, setCodexBaseUrl] = useState("");
const [isCodexTemplateModalOpen, setIsCodexTemplateModalOpen] =
useState(false);
// 新建供应商:收集端点测速弹窗中的“自定义端点”,提交时一次性落盘到 meta.custom_endpoints
const [draftCustomEndpoints, setDraftCustomEndpoints] = useState<string[]>(
[]
);
// 端点测速弹窗状态
const [isEndpointModalOpen, setIsEndpointModalOpen] = useState(false);
const [isCodexEndpointModalOpen, setIsCodexEndpointModalOpen] = useState(false);
@@ -603,13 +607,31 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
}
}
onSubmit({
// 构造基础提交数据
const basePayload: Omit<Provider, "id"> = {
name: formData.name,
websiteUrl: formData.websiteUrl,
settingsConfig,
// 仅在用户选择了预设或手动选择“自定义”时持久化分类
...(category ? { category } : {}),
});
};
// 若为“新建供应商”,且已在弹窗中添加了自定义端点,则随提交一并落盘
if (!initialData && draftCustomEndpoints.length > 0) {
const now = Date.now();
const customMap: Record<string, CustomEndpoint> = {};
for (const raw of draftCustomEndpoints) {
const url = raw.trim().replace(/\/+$/, "");
if (!url) continue;
if (!customMap[url]) {
customMap[url] = { url, addedAt: now };
}
}
onSubmit({ ...basePayload, meta: { custom_endpoints: customMap } });
return;
}
onSubmit(basePayload);
};
const handleChange = (
@@ -1620,11 +1642,13 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
{!isCodex && shouldShowSpeedTest && isEndpointModalOpen && (
<EndpointSpeedTest
appType={appType}
providerId={initialData?.id}
value={baseUrl}
onChange={handleBaseUrlChange}
initialEndpoints={claudeSpeedTestEndpoints}
visible={isEndpointModalOpen}
onClose={() => setIsEndpointModalOpen(false)}
onCustomEndpointsChange={setDraftCustomEndpoints}
/>
)}
@@ -1705,11 +1729,13 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
{isCodex && shouldShowSpeedTest && isCodexEndpointModalOpen && (
<EndpointSpeedTest
appType={appType}
providerId={initialData?.id}
value={codexBaseUrl}
onChange={handleCodexBaseUrlChange}
initialEndpoints={codexSpeedTestEndpoints}
visible={isCodexEndpointModalOpen}
onClose={() => setIsCodexEndpointModalOpen(false)}
onCustomEndpointsChange={setDraftCustomEndpoints}
/>
)}

View File

@@ -12,11 +12,14 @@ export interface EndpointCandidate {
interface EndpointSpeedTestProps {
appType: AppType;
providerId?: string;
value: string;
onChange: (url: string) => void;
initialEndpoints: EndpointCandidate[];
visible?: boolean;
onClose: () => void;
// 当自定义端点列表变化时回传(仅包含 isCustom 的条目)
onCustomEndpointsChange?: (urls: string[]) => void;
}
interface EndpointEntry extends EndpointCandidate {
@@ -63,11 +66,13 @@ const buildInitialEntries = (
const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
appType,
providerId,
value,
onChange,
initialEndpoints,
visible = true,
onClose,
onCustomEndpointsChange,
}) => {
const [entries, setEntries] = useState<EndpointEntry[]>(() =>
buildInitialEntries(initialEndpoints, value),
@@ -82,11 +87,15 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
const hasEndpoints = entries.length > 0;
// 加载保存的自定义端点
// 加载保存的自定义端点(按正在编辑的供应商)
useEffect(() => {
const loadCustomEndpoints = async () => {
try {
const customEndpoints = await window.api.getCustomEndpoints(appType);
if (!providerId) return;
const customEndpoints = await window.api.getCustomEndpoints(
appType,
providerId,
);
const candidates: EndpointCandidate[] = customEndpoints.map((ep) => ({
url: ep.url,
isCustom: true,
@@ -125,7 +134,7 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
if (visible) {
loadCustomEndpoints();
}
}, [appType, visible]);
}, [appType, visible, providerId]);
useEffect(() => {
setEntries((prev) => {
@@ -169,6 +178,25 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
});
}, [initialEndpoints, normalizedSelected]);
// 将自定义端点变化透传给父组件(仅限 isCustom
useEffect(() => {
if (!onCustomEndpointsChange) return;
try {
const customUrls = Array.from(
new Set(
entries
.filter((e) => e.isCustom)
.map((e) => (e.url ? normalizeEndpointUrl(e.url) : ""))
.filter(Boolean),
),
);
onCustomEndpointsChange(customUrls);
} catch (err) {
// ignore
}
// 仅在 entries 变化时同步
}, [entries, onCustomEndpointsChange]);
const sortedEntries = useMemo(() => {
return entries.slice().sort((a, b) => {
const aLatency = a.latency ?? Number.POSITIVE_INFINITY;
@@ -183,55 +211,63 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
const handleAddEndpoint = useCallback(
async () => {
const candidate = customUrl.trim();
setAddError(null);
let errorMsg: string | null = null;
if (!candidate) {
setAddError("请输入有效的 URL");
return;
errorMsg = "请输入有效的 URL";
}
let parsed: URL;
try {
parsed = new URL(candidate);
} catch {
setAddError("URL 格式不正确");
return;
}
if (!parsed.protocol.startsWith("http")) {
setAddError("仅支持 HTTP/HTTPS");
return;
}
const sanitized = normalizeEndpointUrl(parsed.toString());
// 检查是否已存在
setEntries((prev) => {
if (prev.some((entry) => entry.url === sanitized)) {
setAddError("该地址已存在");
return prev;
let parsed: URL | null = null;
if (!errorMsg) {
try {
parsed = new URL(candidate);
} catch {
errorMsg = "URL 格式不正确";
}
return prev;
});
}
if (addError) return;
if (!errorMsg && parsed && !parsed.protocol.startsWith("http")) {
errorMsg = "仅支持 HTTP/HTTPS";
}
let sanitized = "";
if (!errorMsg && parsed) {
sanitized = normalizeEndpointUrl(parsed.toString());
// 使用当前 entries 做去重校验,避免依赖可能过期的 addError
const isDuplicate = entries.some((entry) => entry.url === sanitized);
if (isDuplicate) {
errorMsg = "该地址已存在";
}
}
if (errorMsg) {
setAddError(errorMsg);
return;
}
setAddError(null);
// 保存到后端
try {
await window.api.addCustomEndpoint(appType, sanitized);
if (providerId) {
await window.api.addCustomEndpoint(appType, providerId, sanitized);
}
// 更新本地状态
setEntries((prev) => [
...prev,
{
id: randomId(),
url: sanitized,
isCustom: true,
latency: null,
status: undefined,
error: null,
},
]);
setEntries((prev) => {
if (prev.some((e) => e.url === sanitized)) return prev;
return [
...prev,
{
id: randomId(),
url: sanitized,
isCustom: true,
latency: null,
status: undefined,
error: null,
},
];
});
if (!normalizedSelected) {
onChange(sanitized);
@@ -239,19 +275,21 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
setCustomUrl("");
} catch (error) {
setAddError("保存失败,请重试");
const message =
error instanceof Error ? error.message : String(error);
setAddError(message || "保存失败,请重试");
console.error("添加自定义端点失败:", error);
}
},
[customUrl, normalizedSelected, onChange, appType, addError],
[customUrl, entries, normalizedSelected, onChange, appType, providerId],
);
const handleRemoveEndpoint = useCallback(
async (entry: EndpointEntry) => {
// 如果是自定义端点,从后端删除
if (entry.isCustom) {
// 如果是自定义端点,尝试从后端删除(无 providerId 则仅本地删除)
if (entry.isCustom && providerId) {
try {
await window.api.removeCustomEndpoint(appType, entry.url);
await window.api.removeCustomEndpoint(appType, providerId, entry.url);
} catch (error) {
console.error("删除自定义端点失败:", error);
return;
@@ -268,7 +306,7 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
return next;
});
},
[normalizedSelected, onChange, appType],
[normalizedSelected, onChange, appType, providerId],
);
const runSpeedTest = useCallback(async () => {
@@ -339,13 +377,13 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
// 更新最后使用时间(对自定义端点)
const entry = entries.find((e) => e.url === url);
if (entry?.isCustom) {
await window.api.updateEndpointLastUsed(appType, url);
if (entry?.isCustom && providerId) {
await window.api.updateEndpointLastUsed(appType, providerId, url);
}
onChange(url);
},
[normalizedSelected, onChange, appType, entries],
[normalizedSelected, onChange, appType, entries, providerId],
);
// 支持按下 ESC 关闭弹窗

View File

@@ -337,10 +337,18 @@ export const tauriAPI = {
},
// 获取自定义端点列表
getCustomEndpoints: async (appType: AppType): Promise<CustomEndpoint[]> => {
getCustomEndpoints: async (
appType: AppType,
providerId: string,
): Promise<CustomEndpoint[]> => {
try {
return await invoke<CustomEndpoint[]>("get_custom_endpoints", {
// 兼容不同后端参数命名
app_type: appType,
app: appType,
appType: appType,
provider_id: providerId,
providerId: providerId,
});
} catch (error) {
console.error("获取自定义端点列表失败:", error);
@@ -349,26 +357,44 @@ export const tauriAPI = {
},
// 添加自定义端点
addCustomEndpoint: async (appType: AppType, url: string): Promise<void> => {
addCustomEndpoint: async (
appType: AppType,
providerId: string,
url: string,
): Promise<void> => {
try {
await invoke("add_custom_endpoint", {
app_type: appType,
app: appType,
appType: appType,
provider_id: providerId,
providerId: providerId,
url,
});
} catch (error) {
console.error("添加自定义端点失败:", error);
throw error;
// 尽量抛出可读信息
if (error instanceof Error) {
throw error;
} else {
throw new Error(String(error));
}
}
},
// 删除自定义端点
removeCustomEndpoint: async (
appType: AppType,
providerId: string,
url: string,
): Promise<void> => {
try {
await invoke("remove_custom_endpoint", {
app_type: appType,
app: appType,
appType: appType,
provider_id: providerId,
providerId: providerId,
url,
});
} catch (error) {
@@ -380,11 +406,16 @@ export const tauriAPI = {
// 更新端点最后使用时间
updateEndpointLastUsed: async (
appType: AppType,
providerId: string,
url: string,
): Promise<void> => {
try {
await invoke("update_endpoint_last_used", {
app_type: appType,
app: appType,
appType: appType,
provider_id: providerId,
providerId: providerId,
url,
});
} catch (error) {

View File

@@ -13,6 +13,8 @@ export interface Provider {
// 新增:供应商分类(用于差异化提示/能力开关)
category?: ProviderCategory;
createdAt?: number; // 添加时间戳(毫秒)
// 可选:供应商元数据(仅存于 ~/.cc-switch/config.json不写入 live 配置)
meta?: ProviderMeta;
}
export interface AppConfig {
@@ -27,6 +29,12 @@ export interface CustomEndpoint {
lastUsed?: number;
}
// 供应商元数据(字段名与后端一致,保持 snake_case
export interface ProviderMeta {
// 自定义端点:以 URL 为键,值为端点信息
custom_endpoints?: Record<string, CustomEndpoint>;
}
// 应用设置类型(用于 SettingsModal 与 Tauri API
export interface Settings {
// 是否在系统托盘macOS 菜单栏)显示图标

23
src/vite-env.d.ts vendored
View File

@@ -59,10 +59,25 @@ declare global {
error?: string;
}>>;
// 自定义端点管理
getCustomEndpoints: (appType: AppType) => Promise<CustomEndpoint[]>;
addCustomEndpoint: (appType: AppType, url: string) => Promise<void>;
removeCustomEndpoint: (appType: AppType, url: string) => Promise<void>;
updateEndpointLastUsed: (appType: AppType, url: string) => Promise<void>;
getCustomEndpoints: (
appType: AppType,
providerId: string
) => Promise<CustomEndpoint[]>;
addCustomEndpoint: (
appType: AppType,
providerId: string,
url: string
) => Promise<void>;
removeCustomEndpoint: (
appType: AppType,
providerId: string,
url: string
) => Promise<void>;
updateEndpointLastUsed: (
appType: AppType,
providerId: string,
url: string
) => Promise<void>;
};
platform: {
isMac: boolean;