feat: 添加版本检查功能

- 添加 GitHub API 版本检查功能
- 实现语义化版本比较逻辑
- 更新前端版本检查 UI 和交互
- 添加新版本提示和下载链接
- 更新仓库信息到 aiclientproxy 组织
- 更新文档链接到官方文档站点
- 保持与 0.14.0 版本兼容性
- 统一代码格式化
This commit is contained in:
L_SEN
2025-12-20 13:12:51 +08:00
parent 5e962dbced
commit 4dd725f095
14 changed files with 8533 additions and 20 deletions
+71
View File
@@ -0,0 +1,71 @@
# ProxyCast 版本检查功能 - 修改文件
本文件夹包含实现版本检查功能时修改的所有文件。
## 功能概述
- 添加 GitHub API 版本检查功能
- 实现版本比较逻辑
- 更新前端显示新版本和下载链接
- 修复字段名匹配问题 (has_update -> hasUpdate)
- 更新仓库链接到新地址
- 更新文档链接到 https://aiclientproxy.github.io/proxycast/
- 统一版本号到 0.13.0
## 修改文件列表
### 后端文件 (Rust)
1. **src-tauri/src/commands/config_cmd.rs** - 核心版本检查逻辑
2. **src-tauri/src/lib.rs** - 命令注册
3. **src-tauri/Cargo.toml** - 版本号和仓库信息
### 前端文件 (TypeScript/React)
4. **src/components/settings/AboutSection.tsx** - 版本检查 UI
### 配置文件
5. **package.json** - 版本号和仓库信息
6. **package-lock.json** - 版本号同步
## 主要变更
### 后端变更
- 新增 `VersionCheckResult` 结构体
- 新增 `check_for_updates()` 异步函数
- 实现语义化版本比较逻辑
- 集成 GitHub Releases API
- 添加 serde 字段重命名支持
### 前端变更
- 更新版本检查接口调用
- 改进 UI 显示逻辑
- 添加新版本提示和下载链接
- 更新文档链接
### 配置变更
- 统一所有文件版本号到 0.13.0
- 更新仓库地址到 aiclientproxy 组织
- 更新主页链接
## 文件结构
```
modified_files/
├── README.md # 本说明文件
├── package.json # 前端配置
├── package-lock.json # 依赖锁定
├── src/
│ └── components/
│ └── settings/
│ └── AboutSection.tsx # 关于页面组件
└── src-tauri/
├── Cargo.toml # Rust 项目配置
└── src/
├── lib.rs # 主库文件
└── commands/
└── config_cmd.rs # 配置命令
```
## 使用说明
这些文件可以直接替换原项目中的对应文件,实现完整的版本检查功能。
生成时间: 2025-12-20
+5503
View File
File diff suppressed because it is too large Load Diff
+66
View File
@@ -0,0 +1,66 @@
{
"name": "proxycast",
"private": true,
"version": "0.13.0",
"type": "module",
"repository": {
"type": "git",
"url": "https://github.com/aiclientproxy/proxycast.git"
},
"homepage": "https://github.com/aiclientproxy/proxycast",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri",
"lint": "eslint src --max-warnings 0",
"format": "prettier --write \"src/**/*.{ts,tsx,css}\"",
"prepare": "husky",
"test": "vitest --run",
"test:watch": "vitest"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-toast": "^1.2.2",
"@radix-ui/react-tooltip": "^1.1.3",
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.4.2",
"@tauri-apps/plugin-shell": "^2.0.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"lucide-react": "^0.460.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwind-merge": "^2.5.4"
},
"devDependencies": {
"@eslint/js": "^9.15.0",
"@fast-check/vitest": "^0.2.4",
"@tauri-apps/cli": "^2.0.0",
"@types/node": "^22.9.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@typescript-eslint/eslint-plugin": "^8.15.0",
"@typescript-eslint/parser": "^8.15.0",
"@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.20",
"eslint": "^9.15.0",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.14",
"fast-check": "^4.4.0",
"globals": "^15.12.0",
"husky": "^9.1.7",
"postcss": "^8.4.47",
"prettier": "^3.3.3",
"tailwindcss": "^3.4.14",
"typescript": "^5.6.3",
"vite": "^5.4.10",
"vitest": "^4.0.16"
}
}
+65
View File
@@ -0,0 +1,65 @@
[package]
name = "proxycast"
version = "0.13.0"
description = "AI API Proxy Desktop App"
authors = ["you"]
edition = "2021"
repository = "https://github.com/aiclientproxy/proxycast"
homepage = "https://github.com/aiclientproxy/proxycast"
[lib]
name = "proxycast_lib"
crate-type = ["lib", "cdylib", "staticlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["tray-icon", "image-png"] }
tauri-plugin-shell = "2"
tauri-plugin-autostart = "2"
tauri-plugin-dialog = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
axum = { version = "0.7", features = ["ws"] }
axum-server = { version = "0.7", features = ["tls-rustls"] }
rustls-pemfile = "2"
tower = "0.4"
tower-http = { version = "0.5", features = ["limit"] }
reqwest = { version = "0.12", features = ["json", "stream"] }
uuid = { version = "1", features = ["v4"] }
chrono = { version = "0.4", features = ["serde"] }
dirs = "5"
tracing = "0.1"
tracing-subscriber = "0.3"
futures = "0.3"
async-stream = "0.3"
regex = "1"
md5 = "0.7"
urlencoding = "2"
rusqlite = { version = "0.31", features = ["bundled"] }
serde_yaml = "0.9"
indexmap = { version = "2", features = ["serde"] }
zip = "0.6"
anyhow = "1"
dashmap = "5"
notify = { version = "6", default-features = false, features = ["macos_fsevent"] }
parking_lot = "0.12"
tiktoken-rs = "0.6"
async-trait = "0.1"
thiserror = "1"
base64 = "0.22"
rand = "0.8"
sha2 = "0.10"
serde_urlencoded = "0.7"
open = "5"
url = "2"
[dev-dependencies]
proptest = "1"
tempfile = "3"
[features]
default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]
@@ -0,0 +1,676 @@
use crate::config::{
Config, ConfigManager, ExportBundle, ExportOptions as ExportServiceOptions, ExportService,
ImportOptions as ImportServiceOptions, ImportService, ValidationResult,
};
use crate::models::AppType;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use tauri::AppHandle;
use tauri_plugin_autostart::ManagerExt;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigStatus {
pub exists: bool,
pub path: String,
pub has_env: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VersionCheckResult {
pub current: String,
pub latest: Option<String>,
#[serde(rename = "hasUpdate")]
pub has_update: bool,
#[serde(rename = "downloadUrl")]
pub download_url: Option<String>,
pub error: Option<String>,
}
/// Get the config directory path for an app type
fn get_config_dir(app_type: &AppType) -> Option<PathBuf> {
let home = dirs::home_dir()?;
match app_type {
AppType::Claude => Some(home.join(".claude")),
AppType::Codex => Some(home.join(".codex")),
AppType::Gemini => Some(home.join(".gemini")),
AppType::ProxyCast => dirs::config_dir().map(|d| d.join("proxycast")),
}
}
#[tauri::command]
pub fn get_config_status(app_type: String) -> Result<ConfigStatus, String> {
let app = app_type.parse::<AppType>().map_err(|e| e.to_string())?;
let config_dir = get_config_dir(&app).ok_or("Cannot determine config directory")?;
let main_config = match app {
AppType::Claude => config_dir.join("settings.json"),
AppType::Codex => config_dir.join("auth.json"),
AppType::Gemini => config_dir.join(".env"),
AppType::ProxyCast => config_dir.join("config.json"),
};
let has_env = match app {
AppType::Claude => {
config_dir.join("settings.json").exists()
&& std::fs::read_to_string(config_dir.join("settings.json"))
.map(|s| s.contains("env"))
.unwrap_or(false)
}
AppType::Codex => config_dir.join("auth.json").exists(),
AppType::Gemini => config_dir.join(".env").exists(),
AppType::ProxyCast => config_dir.join("config.json").exists(),
};
Ok(ConfigStatus {
exists: main_config.exists(),
path: config_dir.to_string_lossy().to_string(),
has_env,
})
}
#[tauri::command]
pub fn get_config_dir_path(app_type: String) -> Result<String, String> {
let app = app_type.parse::<AppType>().map_err(|e| e.to_string())?;
let config_dir = get_config_dir(&app).ok_or("Cannot determine config directory")?;
Ok(config_dir.to_string_lossy().to_string())
}
#[tauri::command]
pub async fn open_config_folder(_handle: AppHandle, app_type: String) -> Result<bool, String> {
let app = app_type.parse::<AppType>().map_err(|e| e.to_string())?;
let config_dir = get_config_dir(&app).ok_or("Cannot determine config directory")?;
if !config_dir.exists() {
std::fs::create_dir_all(&config_dir).map_err(|e| e.to_string())?;
}
#[cfg(target_os = "macos")]
{
std::process::Command::new("open")
.arg(&config_dir)
.spawn()
.map_err(|e| e.to_string())?;
}
#[cfg(target_os = "windows")]
{
std::process::Command::new("explorer")
.arg(&config_dir)
.spawn()
.map_err(|e| e.to_string())?;
}
#[cfg(target_os = "linux")]
{
std::process::Command::new("xdg-open")
.arg(&config_dir)
.spawn()
.map_err(|e| e.to_string())?;
}
Ok(true)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolVersion {
pub name: String,
pub version: Option<String>,
pub installed: bool,
}
#[tauri::command]
pub async fn get_tool_versions() -> Result<Vec<ToolVersion>, String> {
let mut versions = Vec::new();
// Check Claude Code version
let claude_version = std::process::Command::new("claude")
.arg("--version")
.output()
.ok()
.and_then(|o| {
if o.status.success() {
String::from_utf8(o.stdout).ok()
} else {
None
}
})
.map(|s| s.trim().to_string());
versions.push(ToolVersion {
name: "Claude Code".to_string(),
version: claude_version.clone(),
installed: claude_version.is_some(),
});
// Check Codex version
let codex_version = std::process::Command::new("codex")
.arg("--version")
.output()
.ok()
.and_then(|o| {
if o.status.success() {
String::from_utf8(o.stdout).ok()
} else {
None
}
})
.map(|s| s.trim().to_string());
versions.push(ToolVersion {
name: "Codex".to_string(),
version: codex_version.clone(),
installed: codex_version.is_some(),
});
// Check Gemini CLI version
let gemini_version = std::process::Command::new("gemini")
.arg("--version")
.output()
.ok()
.and_then(|o| {
if o.status.success() {
String::from_utf8(o.stdout).ok()
} else {
None
}
})
.map(|s| s.trim().to_string());
versions.push(ToolVersion {
name: "Gemini CLI".to_string(),
version: gemini_version.clone(),
installed: gemini_version.is_some(),
});
Ok(versions)
}
#[tauri::command]
pub async fn get_auto_launch_status(app: AppHandle) -> Result<bool, String> {
let autostart_manager = app.autolaunch();
autostart_manager
.is_enabled()
.map_err(|e| format!("Failed to get autostart status: {e}"))
}
#[tauri::command]
pub async fn set_auto_launch(app: AppHandle, enabled: bool) -> Result<bool, String> {
let autostart_manager = app.autolaunch();
if enabled {
autostart_manager
.enable()
.map_err(|e| format!("Failed to enable autostart: {e}"))?;
} else {
autostart_manager
.disable()
.map_err(|e| format!("Failed to disable autostart: {e}"))?;
}
Ok(enabled)
}
<<<<<<< HEAD
// ============ Config Import/Export Commands ============
/// 配置导出选项
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExportOptions {
/// 是否脱敏敏感信息(API 密钥等)
pub redact_secrets: bool,
}
/// 配置导出结果
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExportResult {
/// YAML 配置内容
pub content: String,
/// 建议的文件名
pub suggested_filename: String,
}
/// 导出配置为 YAML 字符串
///
/// # Arguments
/// * `config` - 当前配置
/// * `redact_secrets` - 是否脱敏敏感信息
#[tauri::command]
pub fn export_config(config: Config, redact_secrets: bool) -> Result<ExportResult, String> {
let manager = ConfigManager::new(PathBuf::from("temp.yaml"));
let mut manager_with_config = manager;
manager_with_config.set_config(config);
let content = manager_with_config
.export(redact_secrets)
.map_err(|e| e.to_string())?;
// 生成带时间戳的文件名
let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S");
let suffix = if redact_secrets { "_redacted" } else { "" };
let suggested_filename = format!("proxycast_config_{}{}.yaml", timestamp, suffix);
Ok(ExportResult {
content,
suggested_filename,
})
}
/// 配置导入选项
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImportOptions {
/// 是否合并到现有配置(true)或替换(false)
pub merge: bool,
}
/// 配置导入结果
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImportResult {
/// 是否成功
pub success: bool,
/// 导入后的配置
pub config: Config,
/// 警告信息(如果有)
pub warnings: Vec<String>,
}
/// 验证配置 YAML 格式
///
/// # Arguments
/// * `yaml_content` - YAML 配置字符串
#[tauri::command]
pub fn validate_config_yaml(yaml_content: String) -> Result<Config, String> {
ConfigManager::parse_yaml(&yaml_content).map_err(|e| e.to_string())
}
/// 导入配置
///
/// # Arguments
/// * `current_config` - 当前配置
/// * `yaml_content` - 要导入的 YAML 配置字符串
/// * `merge` - 是否合并到现有配置(true)或替换(false)
#[tauri::command]
pub fn import_config(
current_config: Config,
yaml_content: String,
merge: bool,
) -> Result<ImportResult, String> {
let mut manager = ConfigManager::new(PathBuf::from("temp.yaml"));
manager.set_config(current_config);
let mut warnings = Vec::new();
// 先验证 YAML 格式
let imported_config = ConfigManager::parse_yaml(&yaml_content).map_err(|e| e.to_string())?;
// 检查是否包含脱敏的密钥
if imported_config.server.api_key == "***REDACTED***" {
warnings.push("导入的配置包含脱敏的 API 密钥,将保留原有值".to_string());
}
if imported_config
.providers
.openai
.api_key
.as_ref()
.map(|k| k == "***REDACTED***")
.unwrap_or(false)
{
warnings.push("导入的配置包含脱敏的 OpenAI API 密钥,将保留原有值".to_string());
}
if imported_config
.providers
.claude
.api_key
.as_ref()
.map(|k| k == "***REDACTED***")
.unwrap_or(false)
{
warnings.push("导入的配置包含脱敏的 Claude API 密钥,将保留原有值".to_string());
}
// 执行导入
manager
.import(&yaml_content, merge)
.map_err(|e| e.to_string())?;
// 如果导入的配置包含脱敏的密钥,恢复原有值
let final_config = manager.config().clone();
Ok(ImportResult {
success: true,
config: final_config,
warnings,
})
}
/// 获取配置文件路径信息
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigPathInfo {
/// YAML 配置文件路径
pub yaml_path: String,
/// JSON 配置文件路径(旧版)
pub json_path: String,
/// YAML 配置是否存在
pub yaml_exists: bool,
/// JSON 配置是否存在
pub json_exists: bool,
}
/// 获取配置文件路径信息
#[tauri::command]
pub fn get_config_paths() -> Result<ConfigPathInfo, String> {
let yaml_path = ConfigManager::default_config_path();
let json_path = dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("proxycast")
.join("config.json");
Ok(ConfigPathInfo {
yaml_path: yaml_path.to_string_lossy().to_string(),
json_path: json_path.to_string_lossy().to_string(),
yaml_exists: yaml_path.exists(),
json_exists: json_path.exists(),
})
}
// ============ Enhanced Export/Import Commands (using ExportService/ImportService) ============
/// 统一导出选项
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UnifiedExportOptions {
/// 是否包含配置
pub include_config: bool,
/// 是否包含凭证
pub include_credentials: bool,
/// 是否脱敏敏感信息
pub redact_secrets: bool,
}
/// 统一导出结果
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UnifiedExportResult {
/// 导出包内容(JSON 格式)
pub content: String,
/// 建议的文件名
pub suggested_filename: String,
/// 是否已脱敏
pub redacted: bool,
/// 是否包含配置
pub has_config: bool,
/// 是否包含凭证
pub has_credentials: bool,
}
/// 导出完整的配置和凭证包
///
/// # Arguments
/// * `config` - 当前配置
/// * `options` - 导出选项
///
/// # Requirements: 3.1, 3.2
#[tauri::command]
pub fn export_bundle(
config: Config,
options: UnifiedExportOptions,
) -> Result<UnifiedExportResult, String> {
let export_options = ExportServiceOptions {
include_config: options.include_config,
include_credentials: options.include_credentials,
redact_secrets: options.redact_secrets,
};
// 获取应用版本
let app_version = env!("CARGO_PKG_VERSION").to_string();
let bundle =
ExportService::export(&config, &export_options, &app_version).map_err(|e| e.to_string())?;
let content = bundle.to_json().map_err(|e| e.to_string())?;
// 生成带时间戳的文件名
let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S");
let suffix = if options.redact_secrets {
"_redacted"
} else {
""
};
let scope = match (options.include_config, options.include_credentials) {
(true, true) => "full",
(true, false) => "config",
(false, true) => "credentials",
(false, false) => "empty",
};
let suggested_filename = format!("proxycast_{}_{}{}.json", scope, timestamp, suffix);
Ok(UnifiedExportResult {
content,
suggested_filename,
redacted: bundle.redacted,
has_config: bundle.has_config(),
has_credentials: bundle.has_credentials(),
})
}
/// 仅导出配置为 YAML
///
/// # Arguments
/// * `config` - 当前配置
/// * `redact_secrets` - 是否脱敏敏感信息
///
/// # Requirements: 3.1, 5.1
#[tauri::command]
pub fn export_config_yaml(config: Config, redact_secrets: bool) -> Result<ExportResult, String> {
let content = ExportService::export_yaml(&config, redact_secrets).map_err(|e| e.to_string())?;
// 生成带时间戳的文件名
let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S");
let suffix = if redact_secrets { "_redacted" } else { "" };
let suggested_filename = format!("proxycast_config_{}{}.yaml", timestamp, suffix);
Ok(ExportResult {
content,
suggested_filename,
})
}
/// 验证导入内容
///
/// # Arguments
/// * `content` - 导入内容(JSON 导出包或 YAML 配置)
///
/// # Requirements: 4.1, 4.2
#[tauri::command]
pub fn validate_import(content: String) -> Result<ValidationResult, String> {
Ok(ImportService::validate(&content))
}
/// 导入完整的导出包
///
/// # Arguments
/// * `current_config` - 当前配置
/// * `content` - 导出包内容(JSON 格式)
/// * `merge` - 是否合并到现有配置
///
/// # Requirements: 4.1, 4.3
#[tauri::command]
pub fn import_bundle(
current_config: Config,
content: String,
merge: bool,
) -> Result<ImportResult, String> {
// 首先尝试解析为 ExportBundle
if let Ok(bundle) = ExportBundle::from_json(&content) {
let options = ImportServiceOptions { merge };
let result =
ImportService::import(&bundle, &current_config, &options, &current_config.auth_dir)
.map_err(|e| e.to_string())?;
return Ok(ImportResult {
success: result.success,
config: result.config,
warnings: result.warnings,
});
}
// 尝试解析为 YAML 配置
let options = ImportServiceOptions { merge };
let result = ImportService::import_yaml(&content, &current_config, &options)
.map_err(|e| e.to_string())?;
Ok(ImportResult {
success: result.success,
config: result.config,
warnings: result.warnings,
})
}
// ============ Path Utility Commands ============
/// 展开路径中的 tilde (~) 为用户主目录
///
/// # Arguments
/// * `path` - 要展开的路径字符串
///
/// # Returns
/// 展开后的完整路径字符串
///
/// # Requirements: 2.3
#[tauri::command]
pub fn expand_path(path: String) -> Result<String, String> {
use crate::config::expand_tilde;
let expanded = expand_tilde(&path);
Ok(expanded.to_string_lossy().to_string())
}
/// 打开认证目录
///
/// # Arguments
/// * `path` - 认证目录路径(支持 tilde 展开)
///
/// # Requirements: 2.2
#[tauri::command]
pub async fn open_auth_dir(path: String) -> Result<bool, String> {
use crate::config::expand_tilde;
let expanded = expand_tilde(&path);
// 确保目录存在
if !expanded.exists() {
std::fs::create_dir_all(&expanded).map_err(|e| e.to_string())?;
}
#[cfg(target_os = "macos")]
{
std::process::Command::new("open")
.arg(&expanded)
.spawn()
.map_err(|e| e.to_string())?;
}
#[cfg(target_os = "windows")]
{
std::process::Command::new("explorer")
.arg(&expanded)
.spawn()
.map_err(|e| e.to_string())?;
}
#[cfg(target_os = "linux")]
{
std::process::Command::new("xdg-open")
.arg(&expanded)
.spawn()
.map_err(|e| e.to_string())?;
}
Ok(true)
}
#[tauri::command]
pub async fn check_for_updates() -> Result<VersionCheckResult, String> {
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
const GITHUB_API_URL: &str =
"https://api.github.com/repos/aiclientproxy/proxycast/releases/latest";
let client = reqwest::Client::new();
match client
.get(GITHUB_API_URL)
.header("User-Agent", "ProxyCast")
.send()
.await
{
Ok(response) => {
if response.status().is_success() {
match response.json::<serde_json::Value>().await {
Ok(data) => {
let latest_version = data["tag_name"]
.as_str()
.unwrap_or("")
.trim_start_matches('v');
let download_url = data["html_url"].as_str().map(|s| s.to_string());
let has_update = version_compare(CURRENT_VERSION, latest_version);
Ok(VersionCheckResult {
current: CURRENT_VERSION.to_string(),
latest: Some(latest_version.to_string()),
has_update,
download_url,
error: None,
})
}
Err(e) => Ok(VersionCheckResult {
current: CURRENT_VERSION.to_string(),
latest: None,
has_update: false,
download_url: None,
error: Some(format!("解析响应失败: {}", e)),
}),
}
} else {
Ok(VersionCheckResult {
current: CURRENT_VERSION.to_string(),
latest: None,
has_update: false,
download_url: None,
error: Some(format!("GitHub API 请求失败: {}", response.status())),
})
}
}
Err(e) => Ok(VersionCheckResult {
current: CURRENT_VERSION.to_string(),
latest: None,
has_update: false,
download_url: None,
error: Some(format!("网络请求失败: {}", e)),
}),
}
}
/// 简单的版本比较函数
/// 返回 true 如果 latest > current
fn version_compare(current: &str, latest: &str) -> bool {
// 移除 'v' 前缀
let current = current.trim_start_matches('v');
let latest = latest.trim_start_matches('v');
let current_parts: Vec<u32> = current.split('.').filter_map(|s| s.parse().ok()).collect();
let latest_parts: Vec<u32> = latest.split('.').filter_map(|s| s.parse().ok()).collect();
let max_len = current_parts.len().max(latest_parts.len());
for i in 0..max_len {
let current_part = current_parts.get(i).unwrap_or(&0);
let latest_part = latest_parts.get(i).unwrap_or(&0);
if latest_part > current_part {
return true;
} else if latest_part < current_part {
return false;
}
}
false
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,254 @@
import { useState, useEffect } from "react";
import {
ExternalLink,
RefreshCw,
CheckCircle2,
AlertCircle,
} from "lucide-react";
import { invoke } from "@tauri-apps/api/core";
interface VersionInfo {
current: string;
latest?: string;
hasUpdate: boolean;
downloadUrl?: string;
error?: string;
}
interface ToolVersion {
name: string;
version: string | null;
installed: boolean;
}
export function AboutSection() {
const [versionInfo, setVersionInfo] = useState<VersionInfo>({
current: "0.13.0",
latest: undefined,
hasUpdate: false,
downloadUrl: undefined,
error: undefined,
});
const [checking, setChecking] = useState(false);
const [toolVersions, setToolVersions] = useState<ToolVersion[]>([]);
const [loadingTools, setLoadingTools] = useState(true);
// 加载本地工具版本
useEffect(() => {
const loadToolVersions = async () => {
try {
const versions = await invoke<ToolVersion[]>("get_tool_versions");
setToolVersions(versions);
} catch (error) {
console.error("Failed to load tool versions:", error);
} finally {
setLoadingTools(false);
}
};
loadToolVersions();
}, []);
const handleCheckUpdate = async () => {
setChecking(true);
try {
const result = await invoke<VersionInfo>("check_for_updates");
setVersionInfo(result);
} catch (error) {
console.error("Failed to check for updates:", error);
setVersionInfo((prev) => ({
...prev,
error: "检查更新失败",
}));
} finally {
setChecking(false);
}
};
return (
<div className="space-y-6 max-w-2xl">
{/* 应用信息 */}
<div className="p-6 rounded-lg border text-center space-y-4">
<div className="w-16 h-16 mx-auto rounded-2xl bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center">
<span className="text-2xl font-bold text-white">PC</span>
</div>
<div>
<h2 className="text-xl font-bold">ProxyCast</h2>
<p className="text-sm text-muted-foreground">AI API 代理服务</p>
</div>
<div className="flex items-center justify-center gap-2">
<span className="text-sm">版本 {versionInfo.current}</span>
{versionInfo.hasUpdate ? (
<span className="px-2 py-0.5 rounded-full bg-green-100 text-green-700 text-xs">
有新版本 {versionInfo.latest}
</span>
) : versionInfo.error ? (
<span className="flex items-center gap-1 text-xs text-red-500">
<AlertCircle className="h-3 w-3" />
{versionInfo.error}
</span>
) : versionInfo.latest ? (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<CheckCircle2 className="h-3 w-3" />
已是最新
</span>
) : null}
</div>
<div className="flex items-center justify-center gap-2">
<button
onClick={handleCheckUpdate}
disabled={checking}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg border text-sm hover:bg-muted disabled:opacity-50"
>
<RefreshCw
className={`h-4 w-4 ${checking ? "animate-spin" : ""}`}
/>
检查更新
</button>
{versionInfo.hasUpdate && versionInfo.downloadUrl && (
<a
href={versionInfo.downloadUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-green-600 text-white text-sm hover:bg-green-700"
>
<ExternalLink className="h-4 w-4" />
下载新版本
</a>
)}
</div>
</div>
{/* 链接 */}
<div className="space-y-3">
<h3 className="text-sm font-medium">相关链接</h3>
<div className="space-y-2">
<a
href="https://github.com/aiclientproxy/proxycast"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between p-3 rounded-lg border hover:bg-muted/50"
>
<span className="text-sm">GitHub 仓库</span>
<ExternalLink className="h-4 w-4 text-muted-foreground" />
</a>
<a
href="https://aiclientproxy.github.io/proxycast/"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between p-3 rounded-lg border hover:bg-muted/50"
>
<span className="text-sm">文档</span>
<ExternalLink className="h-4 w-4 text-muted-foreground" />
</a>
<a
href="https://github.com/aiclientproxy/proxycast/issues"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between p-3 rounded-lg border hover:bg-muted/50"
>
<span className="text-sm">问题反馈</span>
<ExternalLink className="h-4 w-4 text-muted-foreground" />
</a>
</div>
</div>
{/* 使用说明 Q&A */}
<div className="space-y-3">
<h3 className="text-sm font-medium">使用说明</h3>
<div className="space-y-2">
<QAItem
question="ProxyCast 是什么?"
answer="ProxyCast 是一个本地 AI API 代理服务,可以将 Kiro、Gemini CLI 等工具的凭证转换为标准的 OpenAI/Anthropic API,供 Claude Code、Cherry Studio、Cursor 等工具使用。"
/>
<QAItem
question="如何开始使用?"
answer="1. 在「凭证池」添加你的凭证(如 Kiro 凭证文件或 Claude API Key);2. 在「API Server」启动服务并选择默认 Provider;3. 在你的 AI 工具中配置 API 地址为 http://localhost:8999"
/>
<QAItem
question="什么是配置切换?"
answer="配置切换可以一键修改 Claude Code、Codex、Gemini CLI 的配置文件,快速在不同 Provider 间切换。添加 ProxyCast 配置后,这些工具就会使用本地代理服务。"
/>
<QAItem
question="凭证文件在哪里?"
answer="Kiro 凭证:~/.kiro/kiro_creds.json;Gemini CLI 凭证:~/.gemini/oauth_creds.json;Qwen 凭证:~/.qwen-coder/auth.json"
/>
<QAItem
question="支持哪些 AI 工具?"
answer="支持所有兼容 OpenAI API 或 Anthropic API 的工具,如 Claude Code、Cursor、Cherry Studio、Continue、Cline 等。"
/>
</div>
</div>
{/* 本地工具版本 */}
<div className="space-y-3">
<h3 className="text-sm font-medium">本地工具版本</h3>
<div className="p-4 rounded-lg border space-y-3">
{loadingTools ? (
<>
<ToolVersionItem name="Claude Code" version="检测中..." />
<ToolVersionItem name="Codex" version="检测中..." />
<ToolVersionItem name="Gemini CLI" version="检测中..." />
</>
) : (
toolVersions.map((tool) => (
<ToolVersionItem
key={tool.name}
name={tool.name}
version={tool.installed ? tool.version || "已安装" : "未安装"}
/>
))
)}
</div>
</div>
{/* 版权信息 */}
<div className="text-center text-xs text-muted-foreground pt-4 border-t">
<p>Made with love for AI developers</p>
<p className="mt-1">2025-2026 ProxyCast</p>
</div>
</div>
);
}
function ToolVersionItem({ name, version }: { name: string; version: string }) {
const isInstalled = version !== "未安装" && !version.includes("检测");
return (
<div className="flex items-center justify-between">
<span className="text-sm">{name}</span>
<div className="flex items-center gap-2">
{isInstalled ? (
<CheckCircle2 className="h-4 w-4 text-green-500" />
) : (
<AlertCircle className="h-4 w-4 text-muted-foreground" />
)}
<span className="text-sm text-muted-foreground font-mono">
{version}
</span>
</div>
</div>
);
}
function QAItem({ question, answer }: { question: string; answer: string }) {
const [isOpen, setIsOpen] = useState(false);
return (
<div className="rounded-lg border">
<button
onClick={() => setIsOpen(!isOpen)}
className="flex w-full items-center justify-between p-3 text-left hover:bg-muted/50"
>
<span className="text-sm font-medium">{question}</span>
<span className="text-muted-foreground">{isOpen ? "−" : "+"}</span>
</button>
{isOpen && (
<div className="px-3 pb-3 text-sm text-muted-foreground">{answer}</div>
)}
</div>
);
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "proxycast",
"version": "0.12.5",
"version": "0.14.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "proxycast",
"version": "0.12.5",
"version": "0.14.0",
"dependencies": {
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.2",
+5
View File
@@ -3,6 +3,11 @@
"private": true,
"version": "0.14.0",
"type": "module",
"repository": {
"type": "git",
"url": "https://github.com/aiclientproxy/proxycast.git"
},
"homepage": "https://github.com/aiclientproxy/proxycast",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
+1 -1
View File
@@ -3367,7 +3367,7 @@ dependencies = [
[[package]]
name = "proxycast"
version = "0.13.0"
version = "0.14.0"
dependencies = [
"anyhow",
"async-stream",
+2
View File
@@ -4,6 +4,8 @@ version = "0.14.0"
description = "AI API Proxy Desktop App"
authors = ["you"]
edition = "2021"
repository = "https://github.com/aiclientproxy/proxycast"
homepage = "https://github.com/aiclientproxy/proxycast"
[lib]
name = "proxycast_lib"
+105
View File
@@ -573,3 +573,108 @@ pub async fn open_auth_dir(path: String) -> Result<bool, String> {
Ok(true)
}
// ============ Version Check Commands ============
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VersionCheckResult {
pub current: String,
pub latest: Option<String>,
#[serde(rename = "hasUpdate")]
pub has_update: bool,
#[serde(rename = "downloadUrl")]
pub download_url: Option<String>,
pub error: Option<String>,
}
/// 检查应用更新
///
/// 从 GitHub Releases API 获取最新版本信息并与当前版本比较
#[tauri::command]
pub async fn check_for_updates() -> Result<VersionCheckResult, String> {
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
const GITHUB_API_URL: &str =
"https://api.github.com/repos/aiclientproxy/proxycast/releases/latest";
let client = reqwest::Client::new();
match client
.get(GITHUB_API_URL)
.header("User-Agent", "ProxyCast")
.send()
.await
{
Ok(response) => {
if response.status().is_success() {
match response.json::<serde_json::Value>().await {
Ok(data) => {
let latest_version = data["tag_name"]
.as_str()
.unwrap_or("")
.trim_start_matches('v');
let download_url = data["html_url"].as_str().map(|s| s.to_string());
let has_update = version_compare(CURRENT_VERSION, latest_version);
Ok(VersionCheckResult {
current: CURRENT_VERSION.to_string(),
latest: Some(latest_version.to_string()),
has_update,
download_url,
error: None,
})
}
Err(e) => Ok(VersionCheckResult {
current: CURRENT_VERSION.to_string(),
latest: None,
has_update: false,
download_url: None,
error: Some(format!("解析响应失败: {}", e)),
}),
}
} else {
Ok(VersionCheckResult {
current: CURRENT_VERSION.to_string(),
latest: None,
has_update: false,
download_url: None,
error: Some(format!("GitHub API 请求失败: {}", response.status())),
})
}
}
Err(e) => Ok(VersionCheckResult {
current: CURRENT_VERSION.to_string(),
latest: None,
has_update: false,
download_url: None,
error: Some(format!("网络请求失败: {}", e)),
}),
}
}
/// 简单的版本比较函数
/// 返回 true 如果 latest > current
fn version_compare(current: &str, latest: &str) -> bool {
// 移除 'v' 前缀
let current = current.trim_start_matches('v');
let latest = latest.trim_start_matches('v');
let current_parts: Vec<u32> = current.split('.').filter_map(|s| s.parse().ok()).collect();
let latest_parts: Vec<u32> = latest.split('.').filter_map(|s| s.parse().ok()).collect();
let max_len = current_parts.len().max(latest_parts.len());
for i in 0..max_len {
let current_part = current_parts.get(i).unwrap_or(&0);
let latest_part = latest_parts.get(i).unwrap_or(&0);
if latest_part > current_part {
return true;
} else if latest_part < current_part {
return false;
}
}
false
}
+1
View File
@@ -1619,6 +1619,7 @@ pub fn run() {
// Path utility commands
commands::config_cmd::expand_path,
commands::config_cmd::open_auth_dir,
commands::config_cmd::check_for_updates,
// MCP commands
commands::mcp_cmd::get_mcp_servers,
commands::mcp_cmd::add_mcp_server,
+51 -17
View File
@@ -11,6 +11,8 @@ interface VersionInfo {
current: string;
latest?: string;
hasUpdate: boolean;
downloadUrl?: string;
error?: string;
}
interface ToolVersion {
@@ -20,10 +22,12 @@ interface ToolVersion {
}
export function AboutSection() {
const [versionInfo] = useState<VersionInfo>({
current: "0.8.0",
const [versionInfo, setVersionInfo] = useState<VersionInfo>({
current: "0.14.0",
latest: undefined,
hasUpdate: false,
downloadUrl: undefined,
error: undefined,
});
const [checking, setChecking] = useState(false);
const [toolVersions, setToolVersions] = useState<ToolVersion[]>([]);
@@ -46,9 +50,18 @@ export function AboutSection() {
const handleCheckUpdate = async () => {
setChecking(true);
// TODO: 实现版本检查
await new Promise((resolve) => setTimeout(resolve, 1000));
setChecking(false);
try {
const result = await invoke<VersionInfo>("check_for_updates");
setVersionInfo(result);
} catch (error) {
console.error("Failed to check for updates:", error);
setVersionInfo((prev) => ({
...prev,
error: "检查更新失败",
}));
} finally {
setChecking(false);
}
};
return (
@@ -68,24 +81,45 @@ export function AboutSection() {
<span className="text-sm">版本 {versionInfo.current}</span>
{versionInfo.hasUpdate ? (
<span className="px-2 py-0.5 rounded-full bg-green-100 text-green-700 text-xs">
有新版本
有新版本 {versionInfo.latest}
</span>
) : (
) : versionInfo.error ? (
<span className="flex items-center gap-1 text-xs text-red-500">
<AlertCircle className="h-3 w-3" />
{versionInfo.error}
</span>
) : versionInfo.latest ? (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<CheckCircle2 className="h-3 w-3" />
已是最新
</span>
)}
) : null}
</div>
<button
onClick={handleCheckUpdate}
disabled={checking}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg border text-sm hover:bg-muted disabled:opacity-50"
>
<RefreshCw className={`h-4 w-4 ${checking ? "animate-spin" : ""}`} />
检查更新
</button>
<div className="flex items-center justify-center gap-2">
<button
onClick={handleCheckUpdate}
disabled={checking}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg border text-sm hover:bg-muted disabled:opacity-50"
>
<RefreshCw
className={`h-4 w-4 ${checking ? "animate-spin" : ""}`}
/>
检查更新
</button>
{versionInfo.hasUpdate && versionInfo.downloadUrl && (
<a
href={versionInfo.downloadUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-green-600 text-white text-sm hover:bg-green-700"
>
<ExternalLink className="h-4 w-4" />
下载新版本
</a>
)}
</div>
</div>
{/* 链接 */}
@@ -102,7 +136,7 @@ export function AboutSection() {
<ExternalLink className="h-4 w-4 text-muted-foreground" />
</a>
<a
href="https://github.com/aiclientproxy/proxycast/wiki"
href="https://aiclientproxy.github.io/proxycast/"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between p-3 rounded-lg border hover:bg-muted/50"