mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
8b3ad9caf9
Adds a new ClaudeDesktop AppType that writes Claude Desktop's third-party
inference profile under configLibrary/, sharing _meta.json with other
launchers (Ollama-compatible) so cc-switch can coexist with them.
Two switch modes:
- direct: provider already exposes claude-* / anthropic/claude-* model
ids on Anthropic Messages, Claude Desktop connects to it directly.
- proxy: cc-switch's local proxy acts as the inference gateway,
presenting only claude-* route names to Claude Desktop and mapping
them to real upstream models. Required after Anthropic restricted
Claude Desktop to claude-family ids.
Backend:
- New module claude_desktop_config with snapshot/rollback, official seed
bypass, /claude-desktop/v1/{models,messages} routes, and a single
source of truth for default proxy routes.
- Gateway token persisted in SQLite, validated on every proxied request.
- get_claude_desktop_status surfaces drift signals (stale models,
missing routes, proxy stopped, base URL mismatch, missing token).
Frontend:
- Slim ClaudeDesktopProviderForm independent from ProviderForm,
controlled by a top-level appId guard.
- ProviderList banner consumes the status query (5s polling) and
renders actionable diagnostics.
- ClaudeDesktopRouteToggle in the header to start/stop the local
gateway without touching takeover state.
- Three-locale i18n synchronised.
685 lines
22 KiB
Rust
685 lines
22 KiB
Rust
use indexmap::IndexMap;
|
|
use tauri::{Emitter, State};
|
|
|
|
use crate::app_config::AppType;
|
|
use crate::commands::copilot::CopilotAuthState;
|
|
use crate::error::AppError;
|
|
use crate::provider::{ClaudeDesktopMode, Provider};
|
|
use crate::services::{
|
|
EndpointLatency, ProviderService, ProviderSortUpdate, SpeedtestService, SwitchResult,
|
|
};
|
|
use crate::store::AppState;
|
|
use std::str::FromStr;
|
|
|
|
// 常量定义
|
|
const TEMPLATE_TYPE_GITHUB_COPILOT: &str = "github_copilot";
|
|
const TEMPLATE_TYPE_TOKEN_PLAN: &str = "token_plan";
|
|
const TEMPLATE_TYPE_BALANCE: &str = "balance";
|
|
const COPILOT_UNIT_PREMIUM: &str = "requests";
|
|
|
|
/// 获取所有供应商
|
|
#[tauri::command]
|
|
pub fn get_providers(
|
|
state: State<'_, AppState>,
|
|
app: String,
|
|
) -> Result<IndexMap<String, Provider>, String> {
|
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
|
ProviderService::list(state.inner(), app_type).map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn get_current_provider(state: State<'_, AppState>, app: String) -> Result<String, String> {
|
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
|
ProviderService::current(state.inner(), app_type).map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn add_provider(
|
|
state: State<'_, AppState>,
|
|
app: String,
|
|
provider: Provider,
|
|
#[allow(non_snake_case)] addToLive: Option<bool>,
|
|
) -> Result<bool, String> {
|
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
|
ProviderService::add(state.inner(), app_type, provider, addToLive.unwrap_or(true))
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn update_provider(
|
|
state: State<'_, AppState>,
|
|
app: String,
|
|
provider: Provider,
|
|
#[allow(non_snake_case)] originalId: Option<String>,
|
|
) -> Result<bool, String> {
|
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
|
ProviderService::update(state.inner(), app_type, originalId.as_deref(), provider)
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn delete_provider(
|
|
state: State<'_, AppState>,
|
|
app: String,
|
|
id: String,
|
|
) -> Result<bool, String> {
|
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
|
ProviderService::delete(state.inner(), app_type, &id)
|
|
.map(|_| true)
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn remove_provider_from_live_config(
|
|
state: tauri::State<'_, AppState>,
|
|
app: String,
|
|
id: String,
|
|
) -> Result<bool, String> {
|
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
|
ProviderService::remove_from_live_config(state.inner(), app_type, &id)
|
|
.map(|_| true)
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
fn switch_provider_internal(
|
|
state: &AppState,
|
|
app_type: AppType,
|
|
id: &str,
|
|
) -> Result<SwitchResult, AppError> {
|
|
ProviderService::switch(state, app_type, id)
|
|
}
|
|
|
|
#[cfg_attr(not(feature = "test-hooks"), doc(hidden))]
|
|
pub fn switch_provider_test_hook(
|
|
state: &AppState,
|
|
app_type: AppType,
|
|
id: &str,
|
|
) -> Result<SwitchResult, AppError> {
|
|
switch_provider_internal(state, app_type, id)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn switch_provider(
|
|
state: State<'_, AppState>,
|
|
app: String,
|
|
id: String,
|
|
) -> Result<SwitchResult, String> {
|
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
|
switch_provider_internal(&state, app_type, &id).map_err(|e| e.to_string())
|
|
}
|
|
|
|
fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result<bool, AppError> {
|
|
let imported = ProviderService::import_default_config(state, app_type.clone())?;
|
|
|
|
if imported {
|
|
// Extract common config snippet (mirrors old startup logic in lib.rs)
|
|
if state
|
|
.db
|
|
.should_auto_extract_config_snippet(app_type.as_str())?
|
|
{
|
|
match ProviderService::extract_common_config_snippet(state, app_type.clone()) {
|
|
Ok(snippet) if !snippet.is_empty() && snippet != "{}" => {
|
|
let _ = state
|
|
.db
|
|
.set_config_snippet(app_type.as_str(), Some(snippet));
|
|
let _ = state
|
|
.db
|
|
.set_config_snippet_cleared(app_type.as_str(), false);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
ProviderService::migrate_legacy_common_config_usage_if_needed(state, app_type.clone())?;
|
|
}
|
|
|
|
Ok(imported)
|
|
}
|
|
|
|
#[cfg_attr(not(feature = "test-hooks"), doc(hidden))]
|
|
pub fn import_default_config_test_hook(
|
|
state: &AppState,
|
|
app_type: AppType,
|
|
) -> Result<bool, AppError> {
|
|
import_default_config_internal(state, app_type)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn import_default_config(state: State<'_, AppState>, app: String) -> Result<bool, String> {
|
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
|
import_default_config_internal(&state, app_type).map_err(Into::into)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn get_claude_desktop_status(
|
|
state: State<'_, AppState>,
|
|
) -> Result<crate::claude_desktop_config::ClaudeDesktopStatus, String> {
|
|
let proxy_running = state.proxy_service.is_running().await;
|
|
crate::claude_desktop_config::get_status(state.db.as_ref(), proxy_running)
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn get_claude_desktop_default_routes(
|
|
) -> Vec<crate::claude_desktop_config::ClaudeDesktopDefaultRoute> {
|
|
crate::claude_desktop_config::default_proxy_routes()
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn import_claude_desktop_providers_from_claude(
|
|
state: State<'_, AppState>,
|
|
) -> Result<usize, String> {
|
|
let claude_providers = state
|
|
.db
|
|
.get_all_providers(AppType::Claude.as_str())
|
|
.map_err(|e| e.to_string())?;
|
|
let existing_ids = state
|
|
.db
|
|
.get_provider_ids(AppType::ClaudeDesktop.as_str())
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
let mut imported = 0usize;
|
|
for provider in claude_providers.values() {
|
|
if existing_ids.contains(&provider.id) {
|
|
continue;
|
|
}
|
|
|
|
if matches!(
|
|
provider
|
|
.meta
|
|
.as_ref()
|
|
.and_then(|meta| meta.provider_type.as_deref()),
|
|
Some("github_copilot") | Some("codex_oauth")
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
let mut desktop_provider = provider.clone();
|
|
desktop_provider.in_failover_queue = false;
|
|
let meta = desktop_provider.meta.get_or_insert_with(Default::default);
|
|
|
|
if crate::claude_desktop_config::is_compatible_direct_provider(provider)
|
|
&& claude_provider_models_are_claude_safe(provider)
|
|
{
|
|
meta.claude_desktop_mode = Some(ClaudeDesktopMode::Direct);
|
|
} else if let Some(routes) = suggested_claude_desktop_routes(provider) {
|
|
meta.claude_desktop_mode = Some(ClaudeDesktopMode::Proxy);
|
|
meta.claude_desktop_model_routes = routes;
|
|
} else {
|
|
continue;
|
|
}
|
|
|
|
state
|
|
.db
|
|
.save_provider(AppType::ClaudeDesktop.as_str(), &desktop_provider)
|
|
.map_err(|e| e.to_string())?;
|
|
imported += 1;
|
|
}
|
|
|
|
Ok(imported)
|
|
}
|
|
|
|
fn claude_provider_models_are_claude_safe(provider: &Provider) -> bool {
|
|
let Some(env) = provider
|
|
.settings_config
|
|
.get("env")
|
|
.and_then(|value| value.as_object())
|
|
else {
|
|
return true;
|
|
};
|
|
|
|
[
|
|
"ANTHROPIC_MODEL",
|
|
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
|
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
|
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
|
]
|
|
.into_iter()
|
|
.filter_map(|key| env.get(key).and_then(|value| value.as_str()))
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.all(crate::claude_desktop_config::is_claude_safe_model_id)
|
|
}
|
|
|
|
fn suggested_claude_desktop_routes(
|
|
provider: &Provider,
|
|
) -> Option<std::collections::HashMap<String, crate::provider::ClaudeDesktopModelRoute>> {
|
|
let env = provider
|
|
.settings_config
|
|
.get("env")
|
|
.and_then(|value| value.as_object())?;
|
|
let mut routes = std::collections::HashMap::new();
|
|
|
|
fn add_route(
|
|
routes: &mut std::collections::HashMap<String, crate::provider::ClaudeDesktopModelRoute>,
|
|
env: &serde_json::Map<String, serde_json::Value>,
|
|
route_id: &str,
|
|
env_key: &str,
|
|
display_name: &str,
|
|
) {
|
|
if let Some(model) = env
|
|
.get(env_key)
|
|
.and_then(|value| value.as_str())
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
{
|
|
routes.insert(
|
|
route_id.to_string(),
|
|
crate::provider::ClaudeDesktopModelRoute {
|
|
model: model.to_string(),
|
|
display_name: Some(display_name.to_string()),
|
|
supports_1m: Some(true),
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
for spec in crate::claude_desktop_config::DEFAULT_PROXY_ROUTES {
|
|
add_route(
|
|
&mut routes,
|
|
env,
|
|
spec.route_id,
|
|
spec.env_key,
|
|
spec.display_name,
|
|
);
|
|
}
|
|
|
|
let primary_route = crate::claude_desktop_config::DEFAULT_PROXY_ROUTES[0];
|
|
if !routes.contains_key(primary_route.route_id) {
|
|
add_route(
|
|
&mut routes,
|
|
env,
|
|
primary_route.route_id,
|
|
"ANTHROPIC_MODEL",
|
|
primary_route.display_name,
|
|
);
|
|
}
|
|
|
|
(!routes.is_empty()).then_some(routes)
|
|
}
|
|
|
|
#[allow(non_snake_case)]
|
|
#[tauri::command]
|
|
pub async fn queryProviderUsage(
|
|
app_handle: tauri::AppHandle,
|
|
state: State<'_, AppState>,
|
|
copilot_state: State<'_, CopilotAuthState>,
|
|
#[allow(non_snake_case)] providerId: String, // 使用 camelCase 匹配前端
|
|
app: String,
|
|
) -> Result<crate::provider::UsageResult, String> {
|
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
|
// inner 可能以两种形式失败:
|
|
// 1) 返回 Ok(UsageResult { success: false, .. }) —— 业务失败(401、脚本报错等)
|
|
// 2) 返回 Err(String) —— RPC/DB/Copilot fetch_usage 等 transport 层失败
|
|
// 两种都要把"失败"写进 UsageCache 并刷新托盘,让 format_script_summary 的
|
|
// success 守卫生效、suffix 自然消失,避免旧 success 快照长期滞留。
|
|
// 同时保持原始 Err 返回给前端 React Query 的 onError 回调,不吞错误。
|
|
let inner =
|
|
query_provider_usage_inner(&state, &copilot_state, app_type.clone(), &providerId).await;
|
|
let snapshot = match &inner {
|
|
Ok(r) => r.clone(),
|
|
Err(err_msg) => crate::provider::UsageResult {
|
|
success: false,
|
|
data: None,
|
|
error: Some(err_msg.clone()),
|
|
},
|
|
};
|
|
let payload = serde_json::json!({
|
|
"kind": "script",
|
|
"appType": app_type.as_str(),
|
|
"providerId": &providerId,
|
|
"data": &snapshot,
|
|
});
|
|
if let Err(e) = app_handle.emit("usage-cache-updated", payload) {
|
|
log::error!("emit usage-cache-updated (script) 失败: {e}");
|
|
}
|
|
state.usage_cache.put_script(app_type, providerId, snapshot);
|
|
crate::tray::schedule_tray_refresh(&app_handle);
|
|
inner
|
|
}
|
|
|
|
async fn query_provider_usage_inner(
|
|
state: &AppState,
|
|
copilot_state: &CopilotAuthState,
|
|
app_type: AppType,
|
|
provider_id: &str,
|
|
) -> Result<crate::provider::UsageResult, String> {
|
|
// 从数据库读取供应商信息,检查特殊模板类型
|
|
let providers = state
|
|
.db
|
|
.get_all_providers(app_type.as_str())
|
|
.map_err(|e| format!("Failed to get providers: {e}"))?;
|
|
let provider = providers.get(provider_id);
|
|
let usage_script = provider
|
|
.and_then(|p| p.meta.as_ref())
|
|
.and_then(|m| m.usage_script.as_ref());
|
|
let template_type = usage_script
|
|
.and_then(|s| s.template_type.as_deref())
|
|
.unwrap_or("");
|
|
|
|
// ── GitHub Copilot 专用路径 ──
|
|
if template_type == TEMPLATE_TYPE_GITHUB_COPILOT {
|
|
let copilot_account_id = provider
|
|
.and_then(|p| p.meta.as_ref())
|
|
.and_then(|m| m.managed_account_id_for(TEMPLATE_TYPE_GITHUB_COPILOT));
|
|
|
|
let auth_manager = copilot_state.0.read().await;
|
|
let usage = match copilot_account_id.as_deref() {
|
|
Some(account_id) => auth_manager
|
|
.fetch_usage_for_account(account_id)
|
|
.await
|
|
.map_err(|e| format!("Failed to fetch Copilot usage: {e}"))?,
|
|
None => auth_manager
|
|
.fetch_usage()
|
|
.await
|
|
.map_err(|e| format!("Failed to fetch Copilot usage: {e}"))?,
|
|
};
|
|
let premium = &usage.quota_snapshots.premium_interactions;
|
|
let used = premium.entitlement - premium.remaining;
|
|
|
|
return Ok(crate::provider::UsageResult {
|
|
success: true,
|
|
data: Some(vec![crate::provider::UsageData {
|
|
plan_name: Some(usage.copilot_plan),
|
|
remaining: Some(premium.remaining as f64),
|
|
total: Some(premium.entitlement as f64),
|
|
used: Some(used as f64),
|
|
unit: Some(COPILOT_UNIT_PREMIUM.to_string()),
|
|
is_valid: Some(true),
|
|
invalid_message: None,
|
|
extra: Some(format!("Reset: {}", usage.quota_reset_date)),
|
|
}]),
|
|
error: None,
|
|
});
|
|
}
|
|
|
|
// ── Coding Plan 专用路径 ──
|
|
if template_type == TEMPLATE_TYPE_TOKEN_PLAN {
|
|
// 从供应商配置中提取 API Key 和 Base URL
|
|
let settings_config = provider
|
|
.map(|p| &p.settings_config)
|
|
.cloned()
|
|
.unwrap_or_default();
|
|
let env = settings_config.get("env");
|
|
let base_url = env
|
|
.and_then(|e| e.get("ANTHROPIC_BASE_URL"))
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("");
|
|
let api_key = env
|
|
.and_then(|e| {
|
|
e.get("ANTHROPIC_AUTH_TOKEN")
|
|
.or_else(|| e.get("ANTHROPIC_API_KEY"))
|
|
})
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("");
|
|
|
|
let quota = crate::services::coding_plan::get_coding_plan_quota(base_url, api_key)
|
|
.await
|
|
.map_err(|e| format!("Failed to query coding plan: {e}"))?;
|
|
|
|
// 将 SubscriptionQuota 转换为 UsageResult
|
|
if !quota.success {
|
|
return Ok(crate::provider::UsageResult {
|
|
success: false,
|
|
data: None,
|
|
error: quota.error,
|
|
});
|
|
}
|
|
|
|
let data: Vec<crate::provider::UsageData> = quota
|
|
.tiers
|
|
.iter()
|
|
.map(|tier| {
|
|
let total = 100.0;
|
|
let used = tier.utilization;
|
|
let remaining = total - used;
|
|
crate::provider::UsageData {
|
|
plan_name: Some(tier.name.clone()),
|
|
remaining: Some(remaining),
|
|
total: Some(total),
|
|
used: Some(used),
|
|
unit: Some("%".to_string()),
|
|
is_valid: Some(true),
|
|
invalid_message: None,
|
|
extra: tier.resets_at.clone(),
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
return Ok(crate::provider::UsageResult {
|
|
success: true,
|
|
data: if data.is_empty() { None } else { Some(data) },
|
|
error: None,
|
|
});
|
|
}
|
|
|
|
// ── 官方余额查询路径 ──
|
|
if template_type == TEMPLATE_TYPE_BALANCE {
|
|
let settings_config = provider
|
|
.map(|p| &p.settings_config)
|
|
.cloned()
|
|
.unwrap_or_default();
|
|
let env = settings_config.get("env");
|
|
let base_url = env
|
|
.and_then(|e| e.get("ANTHROPIC_BASE_URL"))
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("");
|
|
let api_key = env
|
|
.and_then(|e| {
|
|
e.get("ANTHROPIC_AUTH_TOKEN")
|
|
.or_else(|| e.get("ANTHROPIC_API_KEY"))
|
|
})
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("");
|
|
|
|
return crate::services::balance::get_balance(base_url, api_key)
|
|
.await
|
|
.map_err(|e| format!("Failed to query balance: {e}"));
|
|
}
|
|
|
|
// ── 通用 JS 脚本路径 ──
|
|
ProviderService::query_usage(state, app_type, provider_id)
|
|
.await
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[allow(non_snake_case)]
|
|
#[allow(clippy::too_many_arguments)]
|
|
#[tauri::command]
|
|
pub async fn testUsageScript(
|
|
state: State<'_, AppState>,
|
|
#[allow(non_snake_case)] providerId: String,
|
|
app: String,
|
|
#[allow(non_snake_case)] scriptCode: String,
|
|
timeout: Option<u64>,
|
|
#[allow(non_snake_case)] apiKey: Option<String>,
|
|
#[allow(non_snake_case)] baseUrl: Option<String>,
|
|
#[allow(non_snake_case)] accessToken: Option<String>,
|
|
#[allow(non_snake_case)] userId: Option<String>,
|
|
#[allow(non_snake_case)] templateType: Option<String>,
|
|
) -> Result<crate::provider::UsageResult, String> {
|
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
|
ProviderService::test_usage_script(
|
|
state.inner(),
|
|
app_type,
|
|
&providerId,
|
|
&scriptCode,
|
|
timeout.unwrap_or(10),
|
|
apiKey.as_deref(),
|
|
baseUrl.as_deref(),
|
|
accessToken.as_deref(),
|
|
userId.as_deref(),
|
|
templateType.as_deref(),
|
|
)
|
|
.await
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn read_live_provider_settings(app: String) -> Result<serde_json::Value, String> {
|
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
|
ProviderService::read_live_settings(app_type).map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn test_api_endpoints(
|
|
urls: Vec<String>,
|
|
#[allow(non_snake_case)] timeoutSecs: Option<u64>,
|
|
) -> Result<Vec<EndpointLatency>, String> {
|
|
SpeedtestService::test_endpoints(urls, timeoutSecs)
|
|
.await
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn get_custom_endpoints(
|
|
state: State<'_, AppState>,
|
|
app: String,
|
|
#[allow(non_snake_case)] providerId: String,
|
|
) -> Result<Vec<crate::settings::CustomEndpoint>, String> {
|
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
|
ProviderService::get_custom_endpoints(state.inner(), app_type, &providerId)
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn add_custom_endpoint(
|
|
state: State<'_, AppState>,
|
|
app: String,
|
|
#[allow(non_snake_case)] providerId: String,
|
|
url: String,
|
|
) -> Result<(), String> {
|
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
|
ProviderService::add_custom_endpoint(state.inner(), app_type, &providerId, url)
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn remove_custom_endpoint(
|
|
state: State<'_, AppState>,
|
|
app: String,
|
|
#[allow(non_snake_case)] providerId: String,
|
|
url: String,
|
|
) -> Result<(), String> {
|
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
|
ProviderService::remove_custom_endpoint(state.inner(), app_type, &providerId, url)
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn update_endpoint_last_used(
|
|
state: State<'_, AppState>,
|
|
app: String,
|
|
#[allow(non_snake_case)] providerId: String,
|
|
url: String,
|
|
) -> Result<(), String> {
|
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
|
ProviderService::update_endpoint_last_used(state.inner(), app_type, &providerId, url)
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn update_providers_sort_order(
|
|
state: State<'_, AppState>,
|
|
app: String,
|
|
updates: Vec<ProviderSortUpdate>,
|
|
) -> Result<bool, String> {
|
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
|
ProviderService::update_sort_order(state.inner(), app_type, updates).map_err(|e| e.to_string())
|
|
}
|
|
|
|
use crate::provider::UniversalProvider;
|
|
use std::collections::HashMap;
|
|
use tauri::AppHandle;
|
|
|
|
#[derive(Clone, serde::Serialize)]
|
|
pub struct UniversalProviderSyncedEvent {
|
|
pub action: String,
|
|
pub id: String,
|
|
}
|
|
|
|
fn emit_universal_provider_synced(app: &AppHandle, action: &str, id: &str) {
|
|
let _ = app.emit(
|
|
"universal-provider-synced",
|
|
UniversalProviderSyncedEvent {
|
|
action: action.to_string(),
|
|
id: id.to_string(),
|
|
},
|
|
);
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn get_universal_providers(
|
|
state: State<'_, AppState>,
|
|
) -> Result<HashMap<String, UniversalProvider>, String> {
|
|
ProviderService::list_universal(state.inner()).map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn get_universal_provider(
|
|
state: State<'_, AppState>,
|
|
id: String,
|
|
) -> Result<Option<UniversalProvider>, String> {
|
|
ProviderService::get_universal(state.inner(), &id).map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn upsert_universal_provider(
|
|
app: AppHandle,
|
|
state: State<'_, AppState>,
|
|
provider: UniversalProvider,
|
|
) -> Result<bool, String> {
|
|
let id = provider.id.clone();
|
|
let result =
|
|
ProviderService::upsert_universal(state.inner(), provider).map_err(|e| e.to_string())?;
|
|
|
|
emit_universal_provider_synced(&app, "upsert", &id);
|
|
|
|
Ok(result)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn delete_universal_provider(
|
|
app: AppHandle,
|
|
state: State<'_, AppState>,
|
|
id: String,
|
|
) -> Result<bool, String> {
|
|
let result =
|
|
ProviderService::delete_universal(state.inner(), &id).map_err(|e| e.to_string())?;
|
|
|
|
emit_universal_provider_synced(&app, "delete", &id);
|
|
|
|
Ok(result)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn sync_universal_provider(
|
|
app: AppHandle,
|
|
state: State<'_, AppState>,
|
|
id: String,
|
|
) -> Result<bool, String> {
|
|
let result =
|
|
ProviderService::sync_universal_to_apps(state.inner(), &id).map_err(|e| e.to_string())?;
|
|
|
|
emit_universal_provider_synced(&app, "sync", &id);
|
|
|
|
Ok(result)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn import_opencode_providers_from_live(state: State<'_, AppState>) -> Result<usize, String> {
|
|
crate::services::provider::import_opencode_providers_from_live(state.inner())
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn get_opencode_live_provider_ids() -> Result<Vec<String>, String> {
|
|
crate::opencode_config::get_providers()
|
|
.map(|providers| providers.keys().cloned().collect())
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
// ============================================================================
|
|
// OpenClaw 专属命令 → 已迁移至 commands/openclaw.rs
|
|
// ============================================================================
|