mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-27 16:26:16 +08:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bfc90fc7c6 | |||
| 99c910e58e | |||
| aed749f7b1 | |||
| 8f7423f011 | |||
| c56523c9c0 | |||
| 6aef472fd2 | |||
| 4a8883ecc3 | |||
| 6dd809701b | |||
| f363bb1dd0 | |||
| a2becf0917 | |||
| debe4232bc | |||
| 49a2e52b20 | |||
| ed9d9b5436 | |||
| 390839a8d5 | |||
| 3dcbe313be |
+239
-776
File diff suppressed because it is too large
Load Diff
@@ -65,6 +65,30 @@ fn wrap_command_for_windows(_obj: &mut Map<String, Value>) {
|
|||||||
// 非 Windows 平台不做任何处理
|
// 非 Windows 平台不做任何处理
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 检测路径是否为 WSL 网络路径(如 \\wsl$\Ubuntu\... 或 \\wsl.localhost\Ubuntu\...)
|
||||||
|
/// WSL 环境运行的是 Linux,不需要 cmd /c 包装
|
||||||
|
/// 注意:仅检测直接 UNC 路径,映射磁盘符(如 Z: -> \\wsl$\...)无法检测
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn is_wsl_path(path: &Path) -> bool {
|
||||||
|
use std::path::{Component, Prefix};
|
||||||
|
if let Some(Component::Prefix(prefix)) = path.components().next() {
|
||||||
|
match prefix.kind() {
|
||||||
|
Prefix::UNC(server, _) | Prefix::VerbatimUNC(server, _) => {
|
||||||
|
let s = server.to_string_lossy();
|
||||||
|
s.eq_ignore_ascii_case("wsl$") || s.eq_ignore_ascii_case("wsl.localhost")
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn is_wsl_path(_path: &Path) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct McpStatus {
|
pub struct McpStatus {
|
||||||
@@ -371,6 +395,11 @@ pub fn set_mcp_servers_map(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 构建 mcpServers 对象:移除 UI 辅助字段(enabled/source),仅保留实际 MCP 规范
|
// 构建 mcpServers 对象:移除 UI 辅助字段(enabled/source),仅保留实际 MCP 规范
|
||||||
|
// 检测目标路径是否为 WSL,若是则跳过 cmd /c 包装
|
||||||
|
let is_wsl_target = is_wsl_path(&path);
|
||||||
|
if is_wsl_target {
|
||||||
|
log::info!("检测到 WSL 路径,跳过 cmd /c 包装: {}", path.display());
|
||||||
|
}
|
||||||
let mut out: Map<String, Value> = Map::new();
|
let mut out: Map<String, Value> = Map::new();
|
||||||
for (id, spec) in servers.iter() {
|
for (id, spec) in servers.iter() {
|
||||||
let mut obj = if let Some(map) = spec.as_object() {
|
let mut obj = if let Some(map) = spec.as_object() {
|
||||||
@@ -397,8 +426,10 @@ pub fn set_mcp_servers_map(
|
|||||||
obj.remove("homepage");
|
obj.remove("homepage");
|
||||||
obj.remove("docs");
|
obj.remove("docs");
|
||||||
|
|
||||||
// Windows 平台自动包装 npx/npm 等命令为 cmd /c 格式
|
// Windows 平台自动包装 npx/npm 等命令为 cmd /c 格式(WSL 路径除外)
|
||||||
wrap_command_for_windows(&mut obj);
|
if !is_wsl_target {
|
||||||
|
wrap_command_for_windows(&mut obj);
|
||||||
|
}
|
||||||
|
|
||||||
out.insert(id.clone(), Value::Object(obj));
|
out.insert(id.clone(), Value::Object(obj));
|
||||||
}
|
}
|
||||||
@@ -545,4 +576,68 @@ mod tests {
|
|||||||
assert_eq!(obj["args"], json!(["/c", "NPX", "-y", "foo"]));
|
assert_eq!(obj["args"], json!(["/c", "NPX", "-y", "foo"]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 测试 WSL 路径检测功能
|
||||||
|
#[test]
|
||||||
|
fn test_is_wsl_path_wsl_dollar() {
|
||||||
|
// wsl$ 格式 - 各种发行版
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
assert!(is_wsl_path(Path::new(r"\\wsl$\Ubuntu\home\user\.claude")));
|
||||||
|
assert!(is_wsl_path(Path::new(r"\\wsl$\Debian\home\user\.claude")));
|
||||||
|
assert!(is_wsl_path(Path::new(
|
||||||
|
r"\\wsl$\openSUSE-Leap-15.2\home\user"
|
||||||
|
)));
|
||||||
|
assert!(is_wsl_path(Path::new(r"\\wsl$\kali-linux\home\user")));
|
||||||
|
assert!(is_wsl_path(Path::new(r"\\wsl$\Arch\home\user")));
|
||||||
|
assert!(is_wsl_path(Path::new(r"\\wsl$\Alpine\home\user")));
|
||||||
|
assert!(is_wsl_path(Path::new(r"\\wsl$\Fedora\home\user")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
// 非 Windows 平台始终返回 false
|
||||||
|
assert!(!is_wsl_path(Path::new(r"\\wsl$\Ubuntu\home\user\.claude")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_wsl_path_wsl_localhost() {
|
||||||
|
// wsl.localhost 格式
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
assert!(is_wsl_path(Path::new(
|
||||||
|
r"\\wsl.localhost\Ubuntu\home\user\.claude"
|
||||||
|
)));
|
||||||
|
assert!(is_wsl_path(Path::new(r"\\wsl.localhost\Debian\home\user")));
|
||||||
|
assert!(is_wsl_path(Path::new(
|
||||||
|
r"\\wsl.localhost\openSUSE-Leap-15.2\home\user"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_wsl_path_case_insensitive() {
|
||||||
|
// 大小写不敏感
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
assert!(is_wsl_path(Path::new(r"\\WSL$\Ubuntu\home\user")));
|
||||||
|
assert!(is_wsl_path(Path::new(r"\\Wsl$\Ubuntu\home\user")));
|
||||||
|
assert!(is_wsl_path(Path::new(r"\\WSL.LOCALHOST\Ubuntu\home\user")));
|
||||||
|
assert!(is_wsl_path(Path::new(r"\\Wsl.Localhost\Ubuntu\home\user")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_wsl_path_non_wsl() {
|
||||||
|
// 非 WSL 路径
|
||||||
|
assert!(!is_wsl_path(Path::new(r"C:\Users\user\.claude")));
|
||||||
|
assert!(!is_wsl_path(Path::new(r"D:\Workspace\project")));
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
assert!(!is_wsl_path(Path::new(r"\\server\share\path")));
|
||||||
|
assert!(!is_wsl_path(Path::new(r"\\localhost\c$\Users")));
|
||||||
|
assert!(!is_wsl_path(Path::new(r"\\192.168.1.1\share")));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
#![allow(non_snake_case)]
|
#![allow(non_snake_case)]
|
||||||
|
|
||||||
|
use crate::app_config::AppType;
|
||||||
use crate::init_status::{InitErrorPayload, SkillsMigrationPayload};
|
use crate::init_status::{InitErrorPayload, SkillsMigrationPayload};
|
||||||
|
use crate::services::ProviderService;
|
||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
|
use std::str::FromStr;
|
||||||
use tauri::AppHandle;
|
use tauri::AppHandle;
|
||||||
|
use tauri::State;
|
||||||
use tauri_plugin_opener::OpenerExt;
|
use tauri_plugin_opener::OpenerExt;
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
@@ -300,3 +304,285 @@ fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
|
|||||||
|
|
||||||
(None, Some("未安装或无法执行".to_string()))
|
(None, Some("未安装或无法执行".to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 打开指定提供商的终端
|
||||||
|
///
|
||||||
|
/// 根据提供商配置的环境变量启动一个带有该提供商特定设置的终端
|
||||||
|
/// 无需检查是否为当前激活的提供商,任何提供商都可以打开终端
|
||||||
|
#[allow(non_snake_case)]
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn open_provider_terminal(
|
||||||
|
state: State<'_, crate::store::AppState>,
|
||||||
|
app: String,
|
||||||
|
#[allow(non_snake_case)] providerId: String,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// 获取提供商配置
|
||||||
|
let providers = ProviderService::list(state.inner(), app_type.clone())
|
||||||
|
.map_err(|e| format!("获取提供商列表失败: {e}"))?;
|
||||||
|
|
||||||
|
let provider = providers
|
||||||
|
.get(&providerId)
|
||||||
|
.ok_or_else(|| format!("提供商 {providerId} 不存在"))?;
|
||||||
|
|
||||||
|
// 从提供商配置中提取环境变量
|
||||||
|
let config = &provider.settings_config;
|
||||||
|
let env_vars = extract_env_vars_from_config(config, &app_type);
|
||||||
|
|
||||||
|
// 根据平台启动终端,传入提供商ID用于生成唯一的配置文件名
|
||||||
|
launch_terminal_with_env(env_vars, &providerId).map_err(|e| format!("启动终端失败: {e}"))?;
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从提供商配置中提取环境变量
|
||||||
|
fn extract_env_vars_from_config(
|
||||||
|
config: &serde_json::Value,
|
||||||
|
app_type: &AppType,
|
||||||
|
) -> Vec<(String, String)> {
|
||||||
|
let mut env_vars = Vec::new();
|
||||||
|
|
||||||
|
let Some(obj) = config.as_object() else {
|
||||||
|
return env_vars;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理 env 字段(Claude/Gemini 通用)
|
||||||
|
if let Some(env) = obj.get("env").and_then(|v| v.as_object()) {
|
||||||
|
for (key, value) in env {
|
||||||
|
if let Some(str_val) = value.as_str() {
|
||||||
|
env_vars.push((key.clone(), str_val.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理 base_url: 根据应用类型添加对应的环境变量
|
||||||
|
let base_url_key = match app_type {
|
||||||
|
AppType::Claude => Some("ANTHROPIC_BASE_URL"),
|
||||||
|
AppType::Gemini => Some("GOOGLE_GEMINI_BASE_URL"),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(key) = base_url_key {
|
||||||
|
if let Some(url_str) = env.get(key).and_then(|v| v.as_str()) {
|
||||||
|
env_vars.push((key.to_string(), url_str.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Codex 使用 auth 字段转换为 OPENAI_API_KEY
|
||||||
|
if *app_type == AppType::Codex {
|
||||||
|
if let Some(auth) = obj.get("auth").and_then(|v| v.as_str()) {
|
||||||
|
env_vars.push(("OPENAI_API_KEY".to_string(), auth.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gemini 使用 api_key 字段转换为 GEMINI_API_KEY
|
||||||
|
if *app_type == AppType::Gemini {
|
||||||
|
if let Some(api_key) = obj.get("api_key").and_then(|v| v.as_str()) {
|
||||||
|
env_vars.push(("GEMINI_API_KEY".to_string(), api_key.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
env_vars
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建临时配置文件并启动 claude 终端
|
||||||
|
/// 使用 --settings 参数传入提供商特定的 API 配置
|
||||||
|
fn launch_terminal_with_env(
|
||||||
|
env_vars: Vec<(String, String)>,
|
||||||
|
provider_id: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let temp_dir = std::env::temp_dir();
|
||||||
|
let config_file = temp_dir.join(format!(
|
||||||
|
"claude_{}_{}.json",
|
||||||
|
provider_id,
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
|
||||||
|
// 创建并写入配置文件
|
||||||
|
write_claude_config(&config_file, &env_vars)?;
|
||||||
|
|
||||||
|
// 转义配置文件路径用于 shell
|
||||||
|
let config_path_escaped = escape_shell_path(&config_file);
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
launch_macos_terminal(&config_file, &config_path_escaped)?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
launch_linux_terminal(&config_file, &config_path_escaped)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
launch_windows_terminal(&temp_dir, &config_file)?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||||
|
Err("不支持的操作系统".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 写入 claude 配置文件
|
||||||
|
fn write_claude_config(
|
||||||
|
config_file: &std::path::Path,
|
||||||
|
env_vars: &[(String, String)],
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut config_obj = serde_json::Map::new();
|
||||||
|
let mut env_obj = serde_json::Map::new();
|
||||||
|
|
||||||
|
for (key, value) in env_vars {
|
||||||
|
env_obj.insert(key.clone(), serde_json::Value::String(value.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
config_obj.insert("env".to_string(), serde_json::Value::Object(env_obj));
|
||||||
|
|
||||||
|
let config_json =
|
||||||
|
serde_json::to_string_pretty(&config_obj).map_err(|e| format!("序列化配置失败: {e}"))?;
|
||||||
|
|
||||||
|
std::fs::write(config_file, config_json).map_err(|e| format!("写入配置文件失败: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 转义 shell 路径
|
||||||
|
fn escape_shell_path(path: &std::path::Path) -> String {
|
||||||
|
path.to_string_lossy()
|
||||||
|
.replace('\\', "\\\\")
|
||||||
|
.replace('"', "\\\"")
|
||||||
|
.replace('$', "\\$")
|
||||||
|
.replace(' ', "\\ ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 生成 bash 包装脚本,用于清理临时文件
|
||||||
|
fn generate_wrapper_script(config_path: &str, escaped_path: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"bash -c 'trap \"rm -f \\\"{}\\\"\" EXIT; echo \"Using provider-specific claude config:\"; echo \"{}\"; claude --settings \"{}\"; exec bash --norc --noprofile'",
|
||||||
|
config_path, escaped_path, escaped_path
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// macOS: 使用 Terminal.app 启动
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn launch_macos_terminal(
|
||||||
|
config_file: &std::path::Path,
|
||||||
|
config_path_escaped: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
let config_path_for_script = config_file.to_string_lossy().replace('\"', "\\\"");
|
||||||
|
|
||||||
|
let shell_script = generate_wrapper_script(&config_path_for_script, config_path_escaped);
|
||||||
|
|
||||||
|
let script = format!(
|
||||||
|
r#"tell application "Terminal"
|
||||||
|
activate
|
||||||
|
do script "{}"
|
||||||
|
end tell"#,
|
||||||
|
shell_script.replace('\"', "\\\"")
|
||||||
|
);
|
||||||
|
|
||||||
|
Command::new("osascript")
|
||||||
|
.arg("-e")
|
||||||
|
.arg(&script)
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| format!("启动 macOS 终端失败: {e}"))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Linux: 尝试使用常见终端启动
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn launch_linux_terminal(
|
||||||
|
config_file: &std::path::Path,
|
||||||
|
config_path_escaped: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
let terminals = [
|
||||||
|
"gnome-terminal",
|
||||||
|
"konsole",
|
||||||
|
"xfce4-terminal",
|
||||||
|
"mate-terminal",
|
||||||
|
"lxterminal",
|
||||||
|
"alacritty",
|
||||||
|
"kitty",
|
||||||
|
];
|
||||||
|
|
||||||
|
let config_path_for_bash = config_file.to_string_lossy();
|
||||||
|
let shell_cmd = generate_wrapper_script(&config_path_for_bash, config_path_escaped);
|
||||||
|
|
||||||
|
let mut last_error = String::from("未找到可用的终端");
|
||||||
|
|
||||||
|
for terminal in terminals {
|
||||||
|
// 检查终端是否存在
|
||||||
|
if std::path::Path::new(&format!("/usr/bin/{}", terminal)).exists()
|
||||||
|
|| std::path::Path::new(&format!("/bin/{}", terminal)).exists()
|
||||||
|
{
|
||||||
|
let result = match terminal {
|
||||||
|
"gnome-terminal" | "mate-terminal" => Command::new(terminal)
|
||||||
|
.arg("--")
|
||||||
|
.arg("bash")
|
||||||
|
.arg("-c")
|
||||||
|
.arg(&shell_cmd)
|
||||||
|
.spawn(),
|
||||||
|
_ => Command::new(terminal)
|
||||||
|
.arg("-e")
|
||||||
|
.arg("bash")
|
||||||
|
.arg("-c")
|
||||||
|
.arg(&shell_cmd)
|
||||||
|
.spawn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(_) => return Ok(()),
|
||||||
|
Err(e) => {
|
||||||
|
last_error = format!("启动 {} 失败: {}", terminal, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理配置文件
|
||||||
|
let _ = std::fs::remove_file(config_file);
|
||||||
|
Err(last_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Windows: 创建临时批处理文件启动
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn launch_windows_terminal(
|
||||||
|
temp_dir: &std::path::Path,
|
||||||
|
config_file: &std::path::Path,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
let bat_file = temp_dir.join(format!("cc_switch_claude_{}.bat", std::process::id()));
|
||||||
|
let config_path_for_batch = config_file.to_string_lossy().replace('&', "^&");
|
||||||
|
|
||||||
|
let content = format!(
|
||||||
|
"@echo off
|
||||||
|
echo Using provider-specific claude config:
|
||||||
|
echo {}
|
||||||
|
claude --settings \"{}\"
|
||||||
|
del \"{}\" >nul 2>&1
|
||||||
|
del \"%~f0\" >nul 2>&1
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo.
|
||||||
|
echo Press any key to close...
|
||||||
|
pause >nul
|
||||||
|
)",
|
||||||
|
config_path_for_batch, config_path_for_batch, config_path_for_batch
|
||||||
|
);
|
||||||
|
|
||||||
|
std::fs::write(&bat_file, content).map_err(|e| format!("写入批处理文件失败: {e}"))?;
|
||||||
|
|
||||||
|
Command::new("cmd")
|
||||||
|
.args(["/C", "start", "cmd", "/C", &bat_file.to_string_lossy()])
|
||||||
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| format!("启动 Windows 终端失败: {e}"))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ pub struct DeepLinkImportRequest {
|
|||||||
/// Provider homepage URL
|
/// Provider homepage URL
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub homepage: Option<String>,
|
pub homepage: Option<String>,
|
||||||
/// API endpoint/base URL
|
/// API endpoint/base URL (supports comma-separated multiple URLs)
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub endpoint: Option<String>,
|
pub endpoint: Option<String>,
|
||||||
/// API key
|
/// API key
|
||||||
|
|||||||
@@ -101,9 +101,13 @@ fn parse_provider_deeplink(
|
|||||||
validate_url(hp, "homepage")?;
|
validate_url(hp, "homepage")?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Validate each endpoint (supports comma-separated multiple URLs)
|
||||||
if let Some(ref ep) = endpoint {
|
if let Some(ref ep) = endpoint {
|
||||||
if !ep.is_empty() {
|
for (i, url) in ep.split(',').enumerate() {
|
||||||
validate_url(ep, "endpoint")?;
|
let trimmed = url.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
validate_url(trimmed, &format!("endpoint[{i}]"))?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,12 +33,12 @@ pub fn import_provider_from_deeplink(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Step 1: Merge config file if provided (v3.8+)
|
// Step 1: Merge config file if provided (v3.8+)
|
||||||
let merged_request = parse_and_merge_config(&request)?;
|
let mut merged_request = parse_and_merge_config(&request)?;
|
||||||
|
|
||||||
// Extract required fields (now as Option)
|
// Extract required fields (now as Option)
|
||||||
let app_str = merged_request
|
let app_str = merged_request
|
||||||
.app
|
.app
|
||||||
.as_ref()
|
.clone()
|
||||||
.ok_or_else(|| AppError::InvalidInput("Missing 'app' field for provider".to_string()))?;
|
.ok_or_else(|| AppError::InvalidInput("Missing 'app' field for provider".to_string()))?;
|
||||||
|
|
||||||
let api_key = merged_request.api_key.as_ref().ok_or_else(|| {
|
let api_key = merged_request.api_key.as_ref().ok_or_else(|| {
|
||||||
@@ -51,14 +51,29 @@ pub fn import_provider_from_deeplink(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let endpoint = merged_request.endpoint.as_ref().ok_or_else(|| {
|
// Get endpoint: supports comma-separated multiple URLs (first is primary)
|
||||||
|
let endpoint_str = merged_request.endpoint.as_ref().ok_or_else(|| {
|
||||||
AppError::InvalidInput("Endpoint is required (either in URL or config file)".to_string())
|
AppError::InvalidInput("Endpoint is required (either in URL or config file)".to_string())
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
if endpoint.is_empty() {
|
// Parse endpoints: split by comma, first is primary
|
||||||
return Err(AppError::InvalidInput(
|
let all_endpoints: Vec<String> = endpoint_str
|
||||||
"Endpoint cannot be empty".to_string(),
|
.split(',')
|
||||||
));
|
.map(|e| e.trim().to_string())
|
||||||
|
.filter(|e| !e.is_empty())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let primary_endpoint = all_endpoints
|
||||||
|
.first()
|
||||||
|
.ok_or_else(|| AppError::InvalidInput("Endpoint cannot be empty".to_string()))?;
|
||||||
|
|
||||||
|
// Auto-infer homepage from endpoint if not provided
|
||||||
|
if merged_request
|
||||||
|
.homepage
|
||||||
|
.as_ref()
|
||||||
|
.is_none_or(|s| s.is_empty())
|
||||||
|
{
|
||||||
|
merged_request.homepage = infer_homepage_from_endpoint(primary_endpoint);
|
||||||
}
|
}
|
||||||
|
|
||||||
let homepage = merged_request.homepage.as_ref().ok_or_else(|| {
|
let homepage = merged_request.homepage.as_ref().ok_or_else(|| {
|
||||||
@@ -73,11 +88,11 @@ pub fn import_provider_from_deeplink(
|
|||||||
|
|
||||||
let name = merged_request
|
let name = merged_request
|
||||||
.name
|
.name
|
||||||
.as_ref()
|
.clone()
|
||||||
.ok_or_else(|| AppError::InvalidInput("Missing 'name' field for provider".to_string()))?;
|
.ok_or_else(|| AppError::InvalidInput("Missing 'name' field for provider".to_string()))?;
|
||||||
|
|
||||||
// Parse app type
|
// Parse app type
|
||||||
let app_type = AppType::from_str(app_str)
|
let app_type = AppType::from_str(&app_str)
|
||||||
.map_err(|_| AppError::InvalidInput(format!("Invalid app type: {app_str}")))?;
|
.map_err(|_| AppError::InvalidInput(format!("Invalid app type: {app_str}")))?;
|
||||||
|
|
||||||
// Build provider configuration based on app type
|
// Build provider configuration based on app type
|
||||||
@@ -97,6 +112,21 @@ pub fn import_provider_from_deeplink(
|
|||||||
// Use ProviderService to add the provider
|
// Use ProviderService to add the provider
|
||||||
ProviderService::add(state, app_type.clone(), provider)?;
|
ProviderService::add(state, app_type.clone(), provider)?;
|
||||||
|
|
||||||
|
// Add extra endpoints as custom endpoints (skip first one as it's the primary)
|
||||||
|
for ep in all_endpoints.iter().skip(1) {
|
||||||
|
let normalized = ep.trim().trim_end_matches('/').to_string();
|
||||||
|
if !normalized.is_empty() {
|
||||||
|
if let Err(e) = ProviderService::add_custom_endpoint(
|
||||||
|
state,
|
||||||
|
app_type.clone(),
|
||||||
|
&provider_id,
|
||||||
|
normalized.clone(),
|
||||||
|
) {
|
||||||
|
log::warn!("Failed to add custom endpoint '{normalized}': {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// If enabled=true, set as current provider
|
// If enabled=true, set as current provider
|
||||||
if merged_request.enabled.unwrap_or(false) {
|
if merged_request.enabled.unwrap_or(false) {
|
||||||
ProviderService::switch(state, app_type.clone(), &provider_id)?;
|
ProviderService::switch(state, app_type.clone(), &provider_id)?;
|
||||||
@@ -138,6 +168,16 @@ pub(crate) fn build_provider_from_request(
|
|||||||
Ok(provider)
|
Ok(provider)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get primary endpoint from request (first one if comma-separated)
|
||||||
|
fn get_primary_endpoint(request: &DeepLinkImportRequest) -> String {
|
||||||
|
request
|
||||||
|
.endpoint
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|ep| ep.split(',').next())
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
/// Build provider meta with usage script configuration
|
/// Build provider meta with usage script configuration
|
||||||
fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<ProviderMeta>, AppError> {
|
fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<ProviderMeta>, AppError> {
|
||||||
// Check if any usage script fields are provided
|
// Check if any usage script fields are provided
|
||||||
@@ -165,6 +205,7 @@ fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<Provide
|
|||||||
let enabled = request.usage_enabled.unwrap_or(!code.is_empty());
|
let enabled = request.usage_enabled.unwrap_or(!code.is_empty());
|
||||||
|
|
||||||
// Build UsageScript - use provider's API key and endpoint as defaults
|
// Build UsageScript - use provider's API key and endpoint as defaults
|
||||||
|
// Note: use primary endpoint only (first one if comma-separated)
|
||||||
let usage_script = UsageScript {
|
let usage_script = UsageScript {
|
||||||
enabled,
|
enabled,
|
||||||
language: "javascript".to_string(),
|
language: "javascript".to_string(),
|
||||||
@@ -174,10 +215,14 @@ fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<Provide
|
|||||||
.usage_api_key
|
.usage_api_key
|
||||||
.clone()
|
.clone()
|
||||||
.or_else(|| request.api_key.clone()),
|
.or_else(|| request.api_key.clone()),
|
||||||
base_url: request
|
base_url: request.usage_base_url.clone().or_else(|| {
|
||||||
.usage_base_url
|
let primary = get_primary_endpoint(request);
|
||||||
.clone()
|
if primary.is_empty() {
|
||||||
.or_else(|| request.endpoint.clone()),
|
None
|
||||||
|
} else {
|
||||||
|
Some(primary)
|
||||||
|
}
|
||||||
|
}),
|
||||||
access_token: request.usage_access_token.clone(),
|
access_token: request.usage_access_token.clone(),
|
||||||
user_id: request.usage_user_id.clone(),
|
user_id: request.usage_user_id.clone(),
|
||||||
auto_query_interval: request.usage_auto_interval,
|
auto_query_interval: request.usage_auto_interval,
|
||||||
@@ -198,7 +243,7 @@ fn build_claude_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
|||||||
);
|
);
|
||||||
env.insert(
|
env.insert(
|
||||||
"ANTHROPIC_BASE_URL".to_string(),
|
"ANTHROPIC_BASE_URL".to_string(),
|
||||||
json!(request.endpoint.clone().unwrap_or_default()),
|
json!(get_primary_endpoint(request)),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add default model if provided
|
// Add default model if provided
|
||||||
@@ -271,11 +316,8 @@ fn build_codex_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
|||||||
.unwrap_or("gpt-5-codex")
|
.unwrap_or("gpt-5-codex")
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
// Endpoint: normalize trailing slashes
|
// Endpoint: normalize trailing slashes (use primary endpoint only)
|
||||||
let endpoint = request
|
let endpoint = get_primary_endpoint(request)
|
||||||
.endpoint
|
|
||||||
.as_deref()
|
|
||||||
.unwrap_or("")
|
|
||||||
.trim()
|
.trim()
|
||||||
.trim_end_matches('/')
|
.trim_end_matches('/')
|
||||||
.to_string();
|
.to_string();
|
||||||
@@ -309,7 +351,7 @@ fn build_gemini_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
|||||||
env.insert("GEMINI_API_KEY".to_string(), json!(request.api_key));
|
env.insert("GEMINI_API_KEY".to_string(), json!(request.api_key));
|
||||||
env.insert(
|
env.insert(
|
||||||
"GOOGLE_GEMINI_BASE_URL".to_string(),
|
"GOOGLE_GEMINI_BASE_URL".to_string(),
|
||||||
json!(request.endpoint),
|
json!(get_primary_endpoint(request)),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add model if provided
|
// Add model if provided
|
||||||
@@ -523,7 +565,11 @@ fn merge_gemini_config(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if request.endpoint.as_ref().is_none_or(|s| s.is_empty()) {
|
if request.endpoint.as_ref().is_none_or(|s| s.is_empty()) {
|
||||||
if let Some(base_url) = config.get("GEMINI_BASE_URL").and_then(|v| v.as_str()) {
|
if let Some(base_url) = config
|
||||||
|
.get("GOOGLE_GEMINI_BASE_URL")
|
||||||
|
.or_else(|| config.get("GEMINI_BASE_URL"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
{
|
||||||
request.endpoint = Some(base_url.to_string());
|
request.endpoint = Some(base_url.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -404,3 +404,57 @@ fn test_parse_skill_deeplink() {
|
|||||||
assert_eq!(request.directory.unwrap(), "skills");
|
assert_eq!(request.directory.unwrap(), "skills");
|
||||||
assert_eq!(request.branch.unwrap(), "dev");
|
assert_eq!(request.branch.unwrap(), "dev");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Multiple Endpoints Tests
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_multiple_endpoints_comma_separated() {
|
||||||
|
let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test&endpoint=https%3A%2F%2Fapi1.example.com,https%3A%2F%2Fapi2.example.com,https%3A%2F%2Fapi3.example.com&apiKey=sk-test";
|
||||||
|
|
||||||
|
let request = parse_deeplink_url(url).unwrap();
|
||||||
|
|
||||||
|
assert!(request.endpoint.is_some());
|
||||||
|
let endpoint = request.endpoint.unwrap();
|
||||||
|
// Should contain all endpoints comma-separated
|
||||||
|
assert!(endpoint.contains("https://api1.example.com"));
|
||||||
|
assert!(endpoint.contains("https://api2.example.com"));
|
||||||
|
assert!(endpoint.contains("https://api3.example.com"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_single_endpoint_backward_compatible() {
|
||||||
|
// Old format with single endpoint should still work
|
||||||
|
let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test&endpoint=https%3A%2F%2Fapi.example.com&apiKey=sk-test";
|
||||||
|
|
||||||
|
let request = parse_deeplink_url(url).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
request.endpoint,
|
||||||
|
Some("https://api.example.com".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_endpoints_with_spaces_trimmed() {
|
||||||
|
let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test&endpoint=https%3A%2F%2Fapi1.example.com%20,%20https%3A%2F%2Fapi2.example.com&apiKey=sk-test";
|
||||||
|
|
||||||
|
let request = parse_deeplink_url(url).unwrap();
|
||||||
|
|
||||||
|
// Validation should pass (spaces are trimmed during validation)
|
||||||
|
assert!(request.endpoint.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_infer_homepage_from_endpoint_without_homepage() {
|
||||||
|
// Test that homepage is auto-inferred from endpoint when not provided
|
||||||
|
assert_eq!(
|
||||||
|
infer_homepage_from_endpoint("https://api.cubence.com/v1"),
|
||||||
|
Some("https://cubence.com".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
infer_homepage_from_endpoint("https://cubence.com"),
|
||||||
|
Some("https://cubence.com".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ mod usage_script;
|
|||||||
|
|
||||||
pub use app_config::{AppType, McpApps, McpServer, MultiAppConfig};
|
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 codex_config::{get_codex_auth_path, get_codex_config_path, write_codex_live_atomic};
|
||||||
|
pub use commands::open_provider_terminal;
|
||||||
pub use commands::*;
|
pub use commands::*;
|
||||||
pub use config::{get_claude_mcp_path, get_claude_settings_path, read_json_file};
|
pub use config::{get_claude_mcp_path, get_claude_settings_path, read_json_file};
|
||||||
pub use database::Database;
|
pub use database::Database;
|
||||||
@@ -832,6 +833,8 @@ pub fn run() {
|
|||||||
commands::get_stream_check_config,
|
commands::get_stream_check_config,
|
||||||
commands::save_stream_check_config,
|
commands::save_stream_check_config,
|
||||||
commands::get_tool_versions,
|
commands::get_tool_versions,
|
||||||
|
// Provider terminal
|
||||||
|
commands::open_provider_terminal,
|
||||||
// Universal Provider management
|
// Universal Provider management
|
||||||
commands::get_universal_providers,
|
commands::get_universal_providers,
|
||||||
commands::get_universal_provider,
|
commands::get_universal_provider,
|
||||||
|
|||||||
@@ -147,6 +147,9 @@ pub struct ProviderMeta {
|
|||||||
/// 用量查询脚本配置
|
/// 用量查询脚本配置
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub usage_script: Option<UsageScript>,
|
pub usage_script: Option<UsageScript>,
|
||||||
|
/// 请求地址管理:测速后自动选择最佳端点
|
||||||
|
#[serde(rename = "endpointAutoSelect", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub endpoint_auto_select: Option<bool>,
|
||||||
/// 合作伙伴标记(前端使用 isPartner,保持字段名一致)
|
/// 合作伙伴标记(前端使用 isPartner,保持字段名一致)
|
||||||
#[serde(rename = "isPartner", skip_serializing_if = "Option::is_none")]
|
#[serde(rename = "isPartner", skip_serializing_if = "Option::is_none")]
|
||||||
pub is_partner: Option<bool>,
|
pub is_partner: Option<bool>,
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
|
|||||||
// Skill sync
|
// Skill sync
|
||||||
for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {
|
for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {
|
||||||
if let Err(e) = crate::services::skill::SkillService::sync_to_app(&state.db, &app_type) {
|
if let Err(e) = crate::services::skill::SkillService::sync_to_app(&state.db, &app_type) {
|
||||||
log::warn!("同步 Skill 到 {:?} 失败: {}", app_type, e);
|
log::warn!("同步 Skill 到 {app_type:?} 失败: {e}");
|
||||||
// Continue syncing other apps, don't abort
|
// Continue syncing other apps, don't abort
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+21
@@ -382,6 +382,26 @@ function App() {
|
|||||||
await addProvider(duplicatedProvider);
|
await addProvider(duplicatedProvider);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 打开提供商终端
|
||||||
|
const handleOpenTerminal = async (provider: Provider) => {
|
||||||
|
try {
|
||||||
|
await providersApi.openTerminal(provider.id, activeApp);
|
||||||
|
toast.success(
|
||||||
|
t("provider.terminalOpened", {
|
||||||
|
defaultValue: "终端已打开",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[App] Failed to open terminal", error);
|
||||||
|
const errorMessage = extractErrorMessage(error);
|
||||||
|
toast.error(
|
||||||
|
t("provider.terminalOpenFailed", {
|
||||||
|
defaultValue: "打开终端失败",
|
||||||
|
}) + (errorMessage ? `: ${errorMessage}` : ""),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 导入配置成功后刷新
|
// 导入配置成功后刷新
|
||||||
const handleImportSuccess = async () => {
|
const handleImportSuccess = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -482,6 +502,7 @@ function App() {
|
|||||||
onDuplicate={handleDuplicateProvider}
|
onDuplicate={handleDuplicateProvider}
|
||||||
onConfigureUsage={setUsageProvider}
|
onConfigureUsage={setUsageProvider}
|
||||||
onOpenWebsite={handleOpenWebsite}
|
onOpenWebsite={handleOpenWebsite}
|
||||||
|
onOpenTerminal={activeApp === "claude" ? handleOpenTerminal : undefined}
|
||||||
onCreate={() => setIsAddOpen(true)}
|
onCreate={() => setIsAddOpen(true)}
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -389,12 +389,27 @@ export function DeepLinkImportDialog() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* API Endpoint */}
|
{/* API Endpoint */}
|
||||||
<div className="grid grid-cols-3 items-center gap-4">
|
<div className="grid grid-cols-3 items-start gap-4">
|
||||||
<div className="font-medium text-sm text-muted-foreground">
|
<div className="font-medium text-sm text-muted-foreground pt-0.5">
|
||||||
{t("deeplink.endpoint")}
|
{t("deeplink.endpoint")}
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2 text-sm break-all">
|
<div className="col-span-2 text-sm break-all space-y-1">
|
||||||
{request.endpoint}
|
{request.endpoint?.split(",").map((ep, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className={
|
||||||
|
idx === 0 ? "font-medium" : "text-muted-foreground"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{idx === 0 ? "🔹 " : "└ "}
|
||||||
|
{ep.trim()}
|
||||||
|
{idx === 0 && request.endpoint?.includes(",") && (
|
||||||
|
<span className="text-xs text-muted-foreground ml-2">
|
||||||
|
({t("deeplink.primaryEndpoint")})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
Play,
|
Play,
|
||||||
Plus,
|
Plus,
|
||||||
|
Terminal,
|
||||||
TestTube2,
|
TestTube2,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -23,6 +24,7 @@ interface ProviderActionsProps {
|
|||||||
onTest?: () => void;
|
onTest?: () => void;
|
||||||
onConfigureUsage: () => void;
|
onConfigureUsage: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
|
onOpenTerminal?: () => void;
|
||||||
// 故障转移相关
|
// 故障转移相关
|
||||||
isAutoFailoverEnabled?: boolean;
|
isAutoFailoverEnabled?: boolean;
|
||||||
isInFailoverQueue?: boolean;
|
isInFailoverQueue?: boolean;
|
||||||
@@ -39,6 +41,7 @@ export function ProviderActions({
|
|||||||
onTest,
|
onTest,
|
||||||
onConfigureUsage,
|
onConfigureUsage,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onOpenTerminal,
|
||||||
// 故障转移相关
|
// 故障转移相关
|
||||||
isAutoFailoverEnabled = false,
|
isAutoFailoverEnabled = false,
|
||||||
isInFailoverQueue = false,
|
isInFailoverQueue = false,
|
||||||
@@ -171,6 +174,21 @@ export function ProviderActions({
|
|||||||
<BarChart3 className="h-4 w-4" />
|
<BarChart3 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{onOpenTerminal && (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onOpenTerminal}
|
||||||
|
title={t("provider.openTerminal", "打开终端")}
|
||||||
|
className={cn(
|
||||||
|
iconButtonClass,
|
||||||
|
"hover:text-emerald-600 dark:hover:text-emerald-400",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Terminal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState, useEffect } from "react";
|
import { useMemo, useState, useEffect, useRef } from "react";
|
||||||
import { GripVertical, ChevronDown, ChevronUp } from "lucide-react";
|
import { GripVertical, ChevronDown, ChevronUp } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type {
|
import type {
|
||||||
@@ -33,6 +33,7 @@ interface ProviderCardProps {
|
|||||||
onOpenWebsite: (url: string) => void;
|
onOpenWebsite: (url: string) => void;
|
||||||
onDuplicate: (provider: Provider) => void;
|
onDuplicate: (provider: Provider) => void;
|
||||||
onTest?: (provider: Provider) => void;
|
onTest?: (provider: Provider) => void;
|
||||||
|
onOpenTerminal?: (provider: Provider) => void;
|
||||||
isTesting?: boolean;
|
isTesting?: boolean;
|
||||||
isProxyRunning: boolean;
|
isProxyRunning: boolean;
|
||||||
isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管,切换为热切换)
|
isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管,切换为热切换)
|
||||||
@@ -91,6 +92,7 @@ export function ProviderCard({
|
|||||||
onOpenWebsite,
|
onOpenWebsite,
|
||||||
onDuplicate,
|
onDuplicate,
|
||||||
onTest,
|
onTest,
|
||||||
|
onOpenTerminal,
|
||||||
isTesting,
|
isTesting,
|
||||||
isProxyRunning,
|
isProxyRunning,
|
||||||
isProxyTakeover = false,
|
isProxyTakeover = false,
|
||||||
@@ -147,6 +149,10 @@ export function ProviderCard({
|
|||||||
// 多套餐默认展开
|
// 多套餐默认展开
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
|
// 操作按钮容器 ref,用于动态计算宽度
|
||||||
|
const actionsRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [actionsWidth, setActionsWidth] = useState(0);
|
||||||
|
|
||||||
// 当检测到多套餐时自动展开
|
// 当检测到多套餐时自动展开
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasMultiplePlans) {
|
if (hasMultiplePlans) {
|
||||||
@@ -154,6 +160,20 @@ export function ProviderCard({
|
|||||||
}
|
}
|
||||||
}, [hasMultiplePlans]);
|
}, [hasMultiplePlans]);
|
||||||
|
|
||||||
|
// 动态获取操作按钮宽度
|
||||||
|
useEffect(() => {
|
||||||
|
if (actionsRef.current) {
|
||||||
|
const updateWidth = () => {
|
||||||
|
const width = actionsRef.current?.offsetWidth || 0;
|
||||||
|
setActionsWidth(width);
|
||||||
|
};
|
||||||
|
updateWidth();
|
||||||
|
// 监听窗口大小变化
|
||||||
|
window.addEventListener("resize", updateWidth);
|
||||||
|
return () => window.removeEventListener("resize", updateWidth);
|
||||||
|
}
|
||||||
|
}, [onTest, onOpenTerminal]); // 按钮数量可能变化时重新计算
|
||||||
|
|
||||||
const handleOpenWebsite = () => {
|
const handleOpenWebsite = () => {
|
||||||
if (!isClickableUrl) {
|
if (!isClickableUrl) {
|
||||||
return;
|
return;
|
||||||
@@ -279,10 +299,13 @@ export function ProviderCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative flex items-center ml-auto min-w-0">
|
<div
|
||||||
|
className="relative flex items-center ml-auto min-w-0 gap-3"
|
||||||
|
style={{ "--actions-width": `${actionsWidth || 320}px` } as React.CSSProperties}
|
||||||
|
>
|
||||||
{/* 用量信息区域 - hover 时向左移动,为操作按钮腾出空间 */}
|
{/* 用量信息区域 - hover 时向左移动,为操作按钮腾出空间 */}
|
||||||
<div className="ml-auto transition-transform duration-200 group-hover:-translate-x-[14.5rem] group-focus-within:-translate-x-[14.5rem] sm:group-hover:-translate-x-[16rem] sm:group-focus-within:-translate-x-[16rem]">
|
<div className="ml-auto">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1 transition-transform duration-200 group-hover:-translate-x-[var(--actions-width)] group-focus-within:-translate-x-[var(--actions-width)]">
|
||||||
{/* 多套餐时显示套餐数量,单套餐时显示详细信息 */}
|
{/* 多套餐时显示套餐数量,单套餐时显示详细信息 */}
|
||||||
{hasMultiplePlans ? (
|
{hasMultiplePlans ? (
|
||||||
<div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
|
<div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
|
||||||
@@ -327,8 +350,11 @@ export function ProviderCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 操作按钮区域 - 绝对定位在右侧,hover 时滑入 */}
|
{/* 操作按钮区域 - 绝对定位在右侧,hover 时滑入,与用量信息保持间距 */}
|
||||||
<div className="absolute right-0 top-1/2 -translate-y-1/2 flex items-center gap-1.5 opacity-0 pointer-events-none group-hover:opacity-100 group-focus-within:opacity-100 group-hover:pointer-events-auto group-focus-within:pointer-events-auto transition-all duration-200 translate-x-2 group-hover:translate-x-0 group-focus-within:translate-x-0">
|
<div
|
||||||
|
ref={actionsRef}
|
||||||
|
className="absolute right-0 top-1/2 -translate-y-1/2 flex items-center gap-1.5 pl-3 opacity-0 pointer-events-none group-hover:opacity-100 group-focus-within:opacity-100 group-hover:pointer-events-auto group-focus-within:pointer-events-auto transition-all duration-200 translate-x-2 group-hover:translate-x-0 group-focus-within:translate-x-0"
|
||||||
|
>
|
||||||
<ProviderActions
|
<ProviderActions
|
||||||
isCurrent={isCurrent}
|
isCurrent={isCurrent}
|
||||||
isTesting={isTesting}
|
isTesting={isTesting}
|
||||||
@@ -339,6 +365,7 @@ export function ProviderCard({
|
|||||||
onTest={onTest ? () => onTest(provider) : undefined}
|
onTest={onTest ? () => onTest(provider) : undefined}
|
||||||
onConfigureUsage={() => onConfigureUsage(provider)}
|
onConfigureUsage={() => onConfigureUsage(provider)}
|
||||||
onDelete={() => onDelete(provider)}
|
onDelete={() => onDelete(provider)}
|
||||||
|
onOpenTerminal={onOpenTerminal ? () => onOpenTerminal(provider) : undefined}
|
||||||
// 故障转移相关
|
// 故障转移相关
|
||||||
isAutoFailoverEnabled={isAutoFailoverEnabled}
|
isAutoFailoverEnabled={isAutoFailoverEnabled}
|
||||||
isInFailoverQueue={isInFailoverQueue}
|
isInFailoverQueue={isInFailoverQueue}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ interface ProviderListProps {
|
|||||||
onDuplicate: (provider: Provider) => void;
|
onDuplicate: (provider: Provider) => void;
|
||||||
onConfigureUsage?: (provider: Provider) => void;
|
onConfigureUsage?: (provider: Provider) => void;
|
||||||
onOpenWebsite: (url: string) => void;
|
onOpenWebsite: (url: string) => void;
|
||||||
|
onOpenTerminal?: (provider: Provider) => void;
|
||||||
onCreate?: () => void;
|
onCreate?: () => void;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
isProxyRunning?: boolean; // 代理服务运行状态
|
isProxyRunning?: boolean; // 代理服务运行状态
|
||||||
@@ -58,6 +59,7 @@ export function ProviderList({
|
|||||||
onDuplicate,
|
onDuplicate,
|
||||||
onConfigureUsage,
|
onConfigureUsage,
|
||||||
onOpenWebsite,
|
onOpenWebsite,
|
||||||
|
onOpenTerminal,
|
||||||
onCreate,
|
onCreate,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
isProxyRunning = false,
|
isProxyRunning = false,
|
||||||
@@ -203,6 +205,7 @@ export function ProviderList({
|
|||||||
onDuplicate={onDuplicate}
|
onDuplicate={onDuplicate}
|
||||||
onConfigureUsage={onConfigureUsage}
|
onConfigureUsage={onConfigureUsage}
|
||||||
onOpenWebsite={onOpenWebsite}
|
onOpenWebsite={onOpenWebsite}
|
||||||
|
onOpenTerminal={onOpenTerminal}
|
||||||
onTest={handleTest}
|
onTest={handleTest}
|
||||||
isTesting={isChecking(provider.id)}
|
isTesting={isChecking(provider.id)}
|
||||||
isProxyRunning={isProxyRunning}
|
isProxyRunning={isProxyRunning}
|
||||||
@@ -311,6 +314,7 @@ interface SortableProviderCardProps {
|
|||||||
onDuplicate: (provider: Provider) => void;
|
onDuplicate: (provider: Provider) => void;
|
||||||
onConfigureUsage?: (provider: Provider) => void;
|
onConfigureUsage?: (provider: Provider) => void;
|
||||||
onOpenWebsite: (url: string) => void;
|
onOpenWebsite: (url: string) => void;
|
||||||
|
onOpenTerminal?: (provider: Provider) => void;
|
||||||
onTest: (provider: Provider) => void;
|
onTest: (provider: Provider) => void;
|
||||||
isTesting: boolean;
|
isTesting: boolean;
|
||||||
isProxyRunning: boolean;
|
isProxyRunning: boolean;
|
||||||
@@ -333,6 +337,7 @@ function SortableProviderCard({
|
|||||||
onDuplicate,
|
onDuplicate,
|
||||||
onConfigureUsage,
|
onConfigureUsage,
|
||||||
onOpenWebsite,
|
onOpenWebsite,
|
||||||
|
onOpenTerminal,
|
||||||
onTest,
|
onTest,
|
||||||
isTesting,
|
isTesting,
|
||||||
isProxyRunning,
|
isProxyRunning,
|
||||||
@@ -371,6 +376,7 @@ function SortableProviderCard({
|
|||||||
onConfigureUsage ? (item) => onConfigureUsage(item) : () => undefined
|
onConfigureUsage ? (item) => onConfigureUsage(item) : () => undefined
|
||||||
}
|
}
|
||||||
onOpenWebsite={onOpenWebsite}
|
onOpenWebsite={onOpenWebsite}
|
||||||
|
onOpenTerminal={onOpenTerminal}
|
||||||
onTest={onTest}
|
onTest={onTest}
|
||||||
isTesting={isTesting}
|
isTesting={isTesting}
|
||||||
isProxyRunning={isProxyRunning}
|
isProxyRunning={isProxyRunning}
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ interface ClaudeFormFieldsProps {
|
|||||||
isEndpointModalOpen: boolean;
|
isEndpointModalOpen: boolean;
|
||||||
onEndpointModalToggle: (open: boolean) => void;
|
onEndpointModalToggle: (open: boolean) => void;
|
||||||
onCustomEndpointsChange?: (endpoints: string[]) => void;
|
onCustomEndpointsChange?: (endpoints: string[]) => void;
|
||||||
|
autoSelect: boolean;
|
||||||
|
onAutoSelectChange: (checked: boolean) => void;
|
||||||
|
|
||||||
// Model Selector
|
// Model Selector
|
||||||
shouldShowModelSelector: boolean;
|
shouldShowModelSelector: boolean;
|
||||||
@@ -83,6 +85,8 @@ export function ClaudeFormFields({
|
|||||||
isEndpointModalOpen,
|
isEndpointModalOpen,
|
||||||
onEndpointModalToggle,
|
onEndpointModalToggle,
|
||||||
onCustomEndpointsChange,
|
onCustomEndpointsChange,
|
||||||
|
autoSelect,
|
||||||
|
onAutoSelectChange,
|
||||||
shouldShowModelSelector,
|
shouldShowModelSelector,
|
||||||
claudeModel,
|
claudeModel,
|
||||||
reasoningModel,
|
reasoningModel,
|
||||||
@@ -170,6 +174,8 @@ export function ClaudeFormFields({
|
|||||||
initialEndpoints={speedTestEndpoints}
|
initialEndpoints={speedTestEndpoints}
|
||||||
visible={isEndpointModalOpen}
|
visible={isEndpointModalOpen}
|
||||||
onClose={() => onEndpointModalToggle(false)}
|
onClose={() => onEndpointModalToggle(false)}
|
||||||
|
autoSelect={autoSelect}
|
||||||
|
onAutoSelectChange={onAutoSelectChange}
|
||||||
onCustomEndpointsChange={onCustomEndpointsChange}
|
onCustomEndpointsChange={onCustomEndpointsChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ interface CodexFormFieldsProps {
|
|||||||
isEndpointModalOpen: boolean;
|
isEndpointModalOpen: boolean;
|
||||||
onEndpointModalToggle: (open: boolean) => void;
|
onEndpointModalToggle: (open: boolean) => void;
|
||||||
onCustomEndpointsChange?: (endpoints: string[]) => void;
|
onCustomEndpointsChange?: (endpoints: string[]) => void;
|
||||||
|
autoSelect: boolean;
|
||||||
|
onAutoSelectChange: (checked: boolean) => void;
|
||||||
|
|
||||||
// Model Name
|
// Model Name
|
||||||
shouldShowModelField?: boolean;
|
shouldShowModelField?: boolean;
|
||||||
@@ -50,6 +52,8 @@ export function CodexFormFields({
|
|||||||
isEndpointModalOpen,
|
isEndpointModalOpen,
|
||||||
onEndpointModalToggle,
|
onEndpointModalToggle,
|
||||||
onCustomEndpointsChange,
|
onCustomEndpointsChange,
|
||||||
|
autoSelect,
|
||||||
|
onAutoSelectChange,
|
||||||
shouldShowModelField = true,
|
shouldShowModelField = true,
|
||||||
modelName = "",
|
modelName = "",
|
||||||
onModelNameChange,
|
onModelNameChange,
|
||||||
@@ -130,6 +134,8 @@ export function CodexFormFields({
|
|||||||
initialEndpoints={speedTestEndpoints}
|
initialEndpoints={speedTestEndpoints}
|
||||||
visible={isEndpointModalOpen}
|
visible={isEndpointModalOpen}
|
||||||
onClose={() => onEndpointModalToggle(false)}
|
onClose={() => onEndpointModalToggle(false)}
|
||||||
|
autoSelect={autoSelect}
|
||||||
|
onAutoSelectChange={onAutoSelectChange}
|
||||||
onCustomEndpointsChange={onCustomEndpointsChange}
|
onCustomEndpointsChange={onCustomEndpointsChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ interface EndpointSpeedTestProps {
|
|||||||
initialEndpoints: EndpointCandidate[];
|
initialEndpoints: EndpointCandidate[];
|
||||||
visible?: boolean;
|
visible?: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
autoSelect: boolean;
|
||||||
|
onAutoSelectChange: (checked: boolean) => void;
|
||||||
// 新建模式:当自定义端点列表变化时回传(仅包含 isCustom 的条目)
|
// 新建模式:当自定义端点列表变化时回传(仅包含 isCustom 的条目)
|
||||||
// 编辑模式:不使用此回调,端点直接保存到后端
|
// 编辑模式:不使用此回调,端点直接保存到后端
|
||||||
onCustomEndpointsChange?: (urls: string[]) => void;
|
onCustomEndpointsChange?: (urls: string[]) => void;
|
||||||
@@ -85,6 +87,8 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
|||||||
initialEndpoints,
|
initialEndpoints,
|
||||||
visible = true,
|
visible = true,
|
||||||
onClose,
|
onClose,
|
||||||
|
autoSelect,
|
||||||
|
onAutoSelectChange,
|
||||||
onCustomEndpointsChange,
|
onCustomEndpointsChange,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -93,7 +97,6 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
|||||||
);
|
);
|
||||||
const [customUrl, setCustomUrl] = useState("");
|
const [customUrl, setCustomUrl] = useState("");
|
||||||
const [addError, setAddError] = useState<string | null>(null);
|
const [addError, setAddError] = useState<string | null>(null);
|
||||||
const [autoSelect, setAutoSelect] = useState(true);
|
|
||||||
const [isTesting, setIsTesting] = useState(false);
|
const [isTesting, setIsTesting] = useState(false);
|
||||||
const [lastError, setLastError] = useState<string | null>(null);
|
const [lastError, setLastError] = useState<string | null>(null);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
@@ -488,7 +491,9 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={autoSelect}
|
checked={autoSelect}
|
||||||
onChange={(event) => setAutoSelect(event.target.checked)}
|
onChange={(event) => {
|
||||||
|
onAutoSelectChange(event.target.checked);
|
||||||
|
}}
|
||||||
className="h-3.5 w-3.5 rounded border-border-default bg-background text-primary focus:ring-2 focus:ring-primary/20"
|
className="h-3.5 w-3.5 rounded border-border-default bg-background text-primary focus:ring-2 focus:ring-primary/20"
|
||||||
/>
|
/>
|
||||||
{t("endpointTest.autoSelect")}
|
{t("endpointTest.autoSelect")}
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ interface GeminiFormFieldsProps {
|
|||||||
isEndpointModalOpen: boolean;
|
isEndpointModalOpen: boolean;
|
||||||
onEndpointModalToggle: (open: boolean) => void;
|
onEndpointModalToggle: (open: boolean) => void;
|
||||||
onCustomEndpointsChange: (endpoints: string[]) => void;
|
onCustomEndpointsChange: (endpoints: string[]) => void;
|
||||||
|
autoSelect: boolean;
|
||||||
|
onAutoSelectChange: (checked: boolean) => void;
|
||||||
|
|
||||||
// Model
|
// Model
|
||||||
shouldShowModelField: boolean;
|
shouldShowModelField: boolean;
|
||||||
@@ -55,6 +57,8 @@ export function GeminiFormFields({
|
|||||||
isEndpointModalOpen,
|
isEndpointModalOpen,
|
||||||
onEndpointModalToggle,
|
onEndpointModalToggle,
|
||||||
onCustomEndpointsChange,
|
onCustomEndpointsChange,
|
||||||
|
autoSelect,
|
||||||
|
onAutoSelectChange,
|
||||||
shouldShowModelField,
|
shouldShowModelField,
|
||||||
model,
|
model,
|
||||||
onModelChange,
|
onModelChange,
|
||||||
@@ -142,6 +146,8 @@ export function GeminiFormFields({
|
|||||||
initialEndpoints={speedTestEndpoints}
|
initialEndpoints={speedTestEndpoints}
|
||||||
visible={isEndpointModalOpen}
|
visible={isEndpointModalOpen}
|
||||||
onClose={() => onEndpointModalToggle(false)}
|
onClose={() => onEndpointModalToggle(false)}
|
||||||
|
autoSelect={autoSelect}
|
||||||
|
onAutoSelectChange={onAutoSelectChange}
|
||||||
onCustomEndpointsChange={onCustomEndpointsChange}
|
onCustomEndpointsChange={onCustomEndpointsChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -124,6 +124,9 @@ export function ProviderForm({
|
|||||||
return [];
|
return [];
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
const [endpointAutoSelect, setEndpointAutoSelect] = useState<boolean>(
|
||||||
|
() => initialData?.meta?.endpointAutoSelect ?? true,
|
||||||
|
);
|
||||||
|
|
||||||
// 使用 category hook
|
// 使用 category hook
|
||||||
const { category } = useProviderCategory({
|
const { category } = useProviderCategory({
|
||||||
@@ -141,6 +144,7 @@ export function ProviderForm({
|
|||||||
if (!initialData) {
|
if (!initialData) {
|
||||||
setDraftCustomEndpoints([]);
|
setDraftCustomEndpoints([]);
|
||||||
}
|
}
|
||||||
|
setEndpointAutoSelect(initialData?.meta?.endpointAutoSelect ?? true);
|
||||||
}, [appId, initialData]);
|
}, [appId, initialData]);
|
||||||
|
|
||||||
const defaultValues: ProviderFormData = useMemo(
|
const defaultValues: ProviderFormData = useMemo(
|
||||||
@@ -647,6 +651,13 @@ export function ProviderForm({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const baseMeta: ProviderMeta | undefined =
|
||||||
|
payload.meta ?? (initialData?.meta ? { ...initialData.meta } : undefined);
|
||||||
|
payload.meta = {
|
||||||
|
...(baseMeta ?? {}),
|
||||||
|
endpointAutoSelect,
|
||||||
|
};
|
||||||
|
|
||||||
onSubmit(payload);
|
onSubmit(payload);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -856,6 +867,8 @@ export function ProviderForm({
|
|||||||
onCustomEndpointsChange={
|
onCustomEndpointsChange={
|
||||||
isEditMode ? undefined : setDraftCustomEndpoints
|
isEditMode ? undefined : setDraftCustomEndpoints
|
||||||
}
|
}
|
||||||
|
autoSelect={endpointAutoSelect}
|
||||||
|
onAutoSelectChange={setEndpointAutoSelect}
|
||||||
shouldShowModelSelector={category !== "official"}
|
shouldShowModelSelector={category !== "official"}
|
||||||
claudeModel={claudeModel}
|
claudeModel={claudeModel}
|
||||||
reasoningModel={reasoningModel}
|
reasoningModel={reasoningModel}
|
||||||
@@ -889,6 +902,8 @@ export function ProviderForm({
|
|||||||
onCustomEndpointsChange={
|
onCustomEndpointsChange={
|
||||||
isEditMode ? undefined : setDraftCustomEndpoints
|
isEditMode ? undefined : setDraftCustomEndpoints
|
||||||
}
|
}
|
||||||
|
autoSelect={endpointAutoSelect}
|
||||||
|
onAutoSelectChange={setEndpointAutoSelect}
|
||||||
shouldShowModelField={category !== "official"}
|
shouldShowModelField={category !== "official"}
|
||||||
modelName={codexModelName}
|
modelName={codexModelName}
|
||||||
onModelNameChange={handleCodexModelNameChange}
|
onModelNameChange={handleCodexModelNameChange}
|
||||||
@@ -917,6 +932,8 @@ export function ProviderForm({
|
|||||||
isEndpointModalOpen={isEndpointModalOpen}
|
isEndpointModalOpen={isEndpointModalOpen}
|
||||||
onEndpointModalToggle={setIsEndpointModalOpen}
|
onEndpointModalToggle={setIsEndpointModalOpen}
|
||||||
onCustomEndpointsChange={setDraftCustomEndpoints}
|
onCustomEndpointsChange={setDraftCustomEndpoints}
|
||||||
|
autoSelect={endpointAutoSelect}
|
||||||
|
onAutoSelectChange={setEndpointAutoSelect}
|
||||||
shouldShowModelField={true}
|
shouldShowModelField={true}
|
||||||
model={geminiModel}
|
model={geminiModel}
|
||||||
onModelChange={handleGeminiModelChange}
|
onModelChange={handleGeminiModelChange}
|
||||||
|
|||||||
@@ -958,6 +958,7 @@
|
|||||||
"configDetails": "Config Details",
|
"configDetails": "Config Details",
|
||||||
"configUrl": "Config File URL",
|
"configUrl": "Config File URL",
|
||||||
"configMergeError": "Failed to merge configuration file",
|
"configMergeError": "Failed to merge configuration file",
|
||||||
|
"primaryEndpoint": "Primary",
|
||||||
"mcp": {
|
"mcp": {
|
||||||
"title": "Batch Import MCP Servers",
|
"title": "Batch Import MCP Servers",
|
||||||
"targetApps": "Target Apps",
|
"targetApps": "Target Apps",
|
||||||
|
|||||||
@@ -958,6 +958,7 @@
|
|||||||
"configDetails": "設定の詳細",
|
"configDetails": "設定の詳細",
|
||||||
"configUrl": "設定ファイル URL",
|
"configUrl": "設定ファイル URL",
|
||||||
"configMergeError": "設定ファイルのマージに失敗しました",
|
"configMergeError": "設定ファイルのマージに失敗しました",
|
||||||
|
"primaryEndpoint": "メイン",
|
||||||
"mcp": {
|
"mcp": {
|
||||||
"title": "MCP サーバーを一括インポート",
|
"title": "MCP サーバーを一括インポート",
|
||||||
"targetApps": "ターゲットアプリ",
|
"targetApps": "ターゲットアプリ",
|
||||||
|
|||||||
@@ -958,6 +958,7 @@
|
|||||||
"configDetails": "配置详情",
|
"configDetails": "配置详情",
|
||||||
"configUrl": "配置文件 URL",
|
"configUrl": "配置文件 URL",
|
||||||
"configMergeError": "合并配置文件失败",
|
"configMergeError": "合并配置文件失败",
|
||||||
|
"primaryEndpoint": "主",
|
||||||
"mcp": {
|
"mcp": {
|
||||||
"title": "批量导入 MCP Servers",
|
"title": "批量导入 MCP Servers",
|
||||||
"targetApps": "目标应用",
|
"targetApps": "目标应用",
|
||||||
|
|||||||
@@ -65,6 +65,15 @@ export const providersApi = {
|
|||||||
handler(payload);
|
handler(payload);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开指定提供商的终端
|
||||||
|
* 任何提供商都可以打开终端,不受是否为当前激活提供商的限制
|
||||||
|
* 终端会使用该提供商特定的 API 配置,不影响全局设置
|
||||||
|
*/
|
||||||
|
async openTerminal(providerId: string, appId: AppId): Promise<boolean> {
|
||||||
|
return await invoke("open_provider_terminal", { providerId, app: appId });
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -92,6 +92,8 @@ export interface ProviderMeta {
|
|||||||
custom_endpoints?: Record<string, CustomEndpoint>;
|
custom_endpoints?: Record<string, CustomEndpoint>;
|
||||||
// 用量查询脚本配置
|
// 用量查询脚本配置
|
||||||
usage_script?: UsageScript;
|
usage_script?: UsageScript;
|
||||||
|
// 请求地址管理:测速后自动选择最佳端点
|
||||||
|
endpointAutoSelect?: boolean;
|
||||||
// 是否为官方合作伙伴
|
// 是否为官方合作伙伴
|
||||||
isPartner?: boolean;
|
isPartner?: boolean;
|
||||||
// 合作伙伴促销 key(用于后端识别 PackyCode 等)
|
// 合作伙伴促销 key(用于后端识别 PackyCode 等)
|
||||||
|
|||||||
Reference in New Issue
Block a user