diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ef7327d..850459b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,21 +9,7 @@ jobs: publish-tauri: permissions: contents: write - strategy: - fail-fast: false - matrix: - include: - - platform: 'macos-latest' - args: '--target aarch64-apple-darwin' - name: 'macOS ARM64' - - platform: 'macos-latest' - args: '--target x86_64-apple-darwin' - name: 'macOS Intel' - - platform: 'windows-latest' - args: '' - name: 'Windows' - - runs-on: ${{ matrix.platform }} + runs-on: 'windows-latest' steps: - uses: actions/checkout@v4 @@ -35,85 +21,21 @@ jobs: - name: Install Rust uses: dtolnay/rust-toolchain@stable - with: - targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }} - name: Install dependencies run: npm install - - name: Import Apple Developer Certificate - if: matrix.platform == 'macos-latest' - env: - APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} - APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} - run: | - echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12 - security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain - security default-keychain -s build.keychain - security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain - security import certificate.p12 -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign - security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain - security find-identity -v -p codesigning build.keychain - - - name: Verify Certificate - if: matrix.platform == 'macos-latest' - run: | - CERT_INFO=$(security find-identity -v -p codesigning build.keychain | grep "Developer ID Application") - CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}') - echo "CERT_ID=$CERT_ID" >> $GITHUB_ENV - echo "Certificate imported." - - name: Build and Release uses: tauri-apps/tauri-action@v0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} - APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - APPLE_SIGNING_IDENTITY: ${{ env.CERT_ID }} with: tagName: v__VERSION__ releaseName: 'DeepRant v__VERSION__' releaseBody: | - 请查看附件下载对应系统的安装包: + 请查看附件下载 Windows 安装包: - Windows: .msi 安装包 - - macOS Intel: .dmg (x64) 安装包 - - macOS Apple Silicon: .dmg (arm64) 安装包 releaseDraft: true prerelease: false - args: ${{ matrix.args }} - - - name: Notarize DMG (macOS) - if: matrix.platform == 'macos-latest' - env: - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - run: | - if [[ "${{ matrix.args }}" == *"aarch64"* ]]; then - xcrun notarytool submit "src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/DeepRant_"*"_aarch64.dmg" \ - --apple-id "$APPLE_ID" \ - --password "$APPLE_ID_PASSWORD" \ - --team-id "$APPLE_TEAM_ID" \ - --wait - else - xcrun notarytool submit "src-tauri/target/x86_64-apple-darwin/release/bundle/dmg/DeepRant_"*"_x64.dmg" \ - --apple-id "$APPLE_ID" \ - --password "$APPLE_ID_PASSWORD" \ - --team-id "$APPLE_TEAM_ID" \ - --wait - fi - - - name: Staple DMG (macOS) - if: matrix.platform == 'macos-latest' - run: | - if [[ "${{ matrix.args }}" == *"aarch64"* ]]; then - xcrun stapler staple "src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/DeepRant_"*"_aarch64.dmg" - else - xcrun stapler staple "src-tauri/target/x86_64-apple-darwin/release/bundle/dmg/DeepRant_"*"_x64.dmg" - fi diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3c3ffd9..56a40ad 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "DeepRant" -version = "1.1.9" +version = "1.2.3" description = "A Tauri App" authors = ["you"] license = "" diff --git a/src-tauri/src/ai_translator.rs b/src-tauri/src/ai_translator.rs index fa08723..04f2877 100644 --- a/src-tauri/src/ai_translator.rs +++ b/src-tauri/src/ai_translator.rs @@ -170,22 +170,271 @@ fn get_model_config(settings: &crate::store::AppSettings) -> crate::store::Model auth: "sk-jleighwqdtyssxeycgmwxqrhbofpsbkhtobofxhbeyebupyh".to_string(), api_url: "https://api.siliconflow.cn/v1/chat/completions".to_string(), model_name: "deepseek-ai/DeepSeek-V3".to_string(), + api_type: "openai".to_string(), }, "deepseek-R1" => crate::store::ModelConfig { auth: "sk-jleighwqdtyssxeycgmwxqrhbofpsbkhtobofxhbeyebupyh".to_string(), api_url: "https://api.siliconflow.cn/v1/chat/completions".to_string(), model_name: "deepseek-ai/DeepSeek-R1".to_string(), + api_type: "openai".to_string(), }, "stepfun" => crate::store::ModelConfig { auth: "605JU1zU7cGmFp0ibbZlZZ3Qra3lRH7FDtpvICyf2pTrRrUaO6CQgW8p3sQatd5Wh".to_string(), api_url: "https://api.stepfun.com/v1/chat/completions".to_string(), model_name: "step-2-16k".to_string(), + api_type: "openai".to_string(), }, "custom" => settings.custom_model.clone(), _ => settings.custom_model.clone(), } } +/// 根据 API 类型自动补全完整请求 URL。 +/// 用户只需填写 base URL(如 https://hk.routeai.cc),路径自动追加。 +/// 如果用户已经填写了完整 URL,则不重复补全。 +fn resolve_api_url(base_url: &str, api_type: &str) -> String { + let trimmed = base_url.trim().trim_end_matches('/'); + + // 已包含路径关键字时直接使用,向后兼容 + if trimmed.contains("/chat/completions") || trimmed.ends_with("/messages") { + return trimmed.to_string(); + } + + let suffix = match api_type.to_lowercase().as_str() { + "anthropic" => "/v1/messages", + // openai 与 opencode-go 都使用 OpenAI 兼容路径 + _ => "/v1/chat/completions", + }; + + format!("{}{}", trimmed, suffix) +} + +/// 屏蔽 API key 中间字符,仅展示前后 4 位 +fn mask_secret(s: &str) -> String { + let len = s.chars().count(); + if len <= 8 { + return "*".repeat(len); + } + let prefix: String = s.chars().take(4).collect(); + let suffix: String = s.chars().skip(len.saturating_sub(4)).collect(); + format!("{}...{}", prefix, suffix) +} + +/// 测试自定义 API 连接性。返回完整诊断信息(成功或失败均带 details)。 +/// 走 Rust 后端 reqwest,不受前端 CORS 限制。 +pub async fn test_api_connection( + api_key: String, + base_url: String, + model_name: String, + api_type: String, +) -> Value { + let started = std::time::Instant::now(); + let api_type_lc = api_type.to_lowercase(); + let resolved_url = resolve_api_url(&base_url, &api_type_lc); + + let body = if api_type_lc == "anthropic" { + json!({ + "model": model_name, + "messages": [ + { + "role": "user", + "content": "Hello, this is a test message. Please reply with 'OK' if you receive this." + } + ], + "max_tokens": 10 + }) + } else { + json!({ + "model": model_name, + "messages": [ + { + "role": "user", + "content": "Hello, this is a test message. Please reply with 'OK' if you receive this." + } + ], + "max_tokens": 10 + }) + }; + + let masked_headers = if api_type_lc == "anthropic" { + json!({ + "Content-Type": "application/json", + "x-api-key": mask_secret(&api_key), + "anthropic-version": "2023-06-01" + }) + } else { + json!({ + "Content-Type": "application/json", + "Authorization": format!("Bearer {}", mask_secret(&api_key)) + }) + }; + + let request_info = json!({ + "url": resolved_url, + "method": "POST", + "apiType": api_type_lc, + "headers": masked_headers, + "bodyPreview": body.to_string() + }); + + let client = match Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + { + Ok(c) => c, + Err(e) => { + return json!({ + "ok": false, + "message": format!("API测试失败:HTTP 客户端初始化错误 - {}", e), + "details": { + "request": request_info, + "elapsedMs": started.elapsed().as_millis() as u64, + "errorType": "ClientBuildError", + "errorMessage": e.to_string() + } + }); + } + }; + + let req = client + .post(&resolved_url) + .header("Content-Type", "application/json"); + + let req = if api_type_lc == "anthropic" { + req.header("x-api-key", &api_key) + .header("anthropic-version", "2023-06-01") + } else { + req.header("Authorization", format!("Bearer {}", api_key)) + }; + + let resp = match req.json(&body).send().await { + Ok(r) => r, + Err(e) => { + return json!({ + "ok": false, + "message": format!("API测试失败:网络层错误 - {}", e), + "details": { + "request": request_info, + "elapsedMs": started.elapsed().as_millis() as u64, + "errorType": "NetworkError", + "errorMessage": e.to_string(), + "hint": "可能的原因:1) URL 错误或域名解析失败;2) HTTPS 证书问题;3) 防火墙/代理拦截;4) 服务端无响应/超时。" + } + }); + } + }; + + let status = resp.status(); + let status_code = status.as_u16(); + let status_text = status.canonical_reason().unwrap_or("").to_string(); + + let raw_text = match resp.text().await { + Ok(t) => t, + Err(e) => { + return json!({ + "ok": false, + "message": format!("API测试失败:读取响应体错误 - {}", e), + "details": { + "request": request_info, + "elapsedMs": started.elapsed().as_millis() as u64, + "errorType": "ReadBodyError", + "errorMessage": e.to_string() + } + }); + } + }; + + let parsed: Option = serde_json::from_str(&raw_text).ok(); + let body_preview: String = if raw_text.chars().count() > 2000 { + let truncated: String = raw_text.chars().take(2000).collect(); + format!("{}...(truncated)", truncated) + } else { + raw_text.clone() + }; + + let response_info = json!({ + "status": status_code, + "statusText": status_text, + "ok": status.is_success(), + "bodyPreview": body_preview, + "parseError": if parsed.is_none() && !raw_text.is_empty() { Some("响应非有效 JSON") } else { None } + }); + + if !status.is_success() { + let api_msg = parsed + .as_ref() + .and_then(|v| v.get("error").and_then(|e| e.get("message")).and_then(|m| m.as_str())) + .or_else(|| parsed.as_ref().and_then(|v| v.get("message")).and_then(|m| m.as_str())) + .map(|s| s.to_string()) + .unwrap_or_else(|| raw_text.chars().take(200).collect()); + + return json!({ + "ok": false, + "message": format!("API测试失败:HTTP {} - {}", status_code, api_msg), + "details": { + "request": request_info, + "elapsedMs": started.elapsed().as_millis() as u64, + "response": response_info + } + }); + } + + if let Some(ref v) = parsed { + if let Some(err) = v.get("error") { + let msg = err.get("message").and_then(|m| m.as_str()).unwrap_or("未知错误"); + return json!({ + "ok": false, + "message": format!("API测试失败:{}", msg), + "details": { + "request": request_info, + "elapsedMs": started.elapsed().as_millis() as u64, + "response": response_info + } + }); + } + } + + let success = if api_type_lc == "anthropic" { + parsed + .as_ref() + .and_then(|v| v.get("content")) + .and_then(|c| c.as_array()) + .and_then(|arr| arr.first()) + .and_then(|item| item.get("text")) + .is_some() + } else { + parsed + .as_ref() + .and_then(|v| v.get("choices")) + .and_then(|c| c.as_array()) + .and_then(|arr| arr.first()) + .and_then(|c| c.get("message")) + .is_some() + }; + + if !success { + return json!({ + "ok": false, + "message": "API测试失败:响应格式不正确", + "details": { + "request": request_info, + "elapsedMs": started.elapsed().as_millis() as u64, + "response": response_info + } + }); + } + + json!({ + "ok": true, + "message": "API连接测试成功", + "details": { + "request": request_info, + "elapsedMs": started.elapsed().as_millis() as u64, + "response": response_info + } + }) +} + pub async fn translate_with_gpt(app: &AppHandle, original: &str) -> Result { let settings = crate::store::get_settings(app)?; println!("当前翻译设置:"); @@ -198,9 +447,12 @@ pub async fn translate_with_gpt(app: &AppHandle, original: &str) -> Result Result Result Result { - let text = text.trim(); - // 如果找到标签,只保留其后内容 - if let Some(end_pos) = text.find("") { - text[(end_pos + 8)..].trim().to_string() - } else { - text.to_string() + let translated = if api_type == "anthropic" { + match response + .get("content") + .and_then(|content| content.as_array()) + .and_then(|content| content.first()) + .and_then(|item| item.get("text")) + .and_then(|text| text.as_str()) + { + Some(text) => { + let text = text.trim(); + // 如果找到标签,只保留其后内容 + if let Some(end_pos) = text.find("") { + text[(end_pos + 8)..].trim().to_string() + } else { + text.to_string() + } + } + None => { + println!("无法从Anthropic响应中提取翻译结果: {:?}", response); + return Ok("[错误] 服务器返回的数据格式异常".to_string()); } } - None => { - println!("无法从响应中提取翻译结果: {:?}", response); - return Ok("[错误] 服务器返回的数据格式异常".to_string()); + } else { + match response + .get("choices") + .and_then(|choices| choices.as_array()) + .and_then(|choices| choices.first()) + .and_then(|choice| choice.get("message")) + .and_then(|message| message.get("content")) + .and_then(|content| content.as_str()) + { + Some(text) => { + let text = text.trim(); + // 如果找到标签,只保留其后内容 + if let Some(end_pos) = text.find("") { + text[(end_pos + 8)..].trim().to_string() + } else { + text.to_string() + } + } + None => { + println!("无法从响应中提取翻译结果: {:?}", response); + return Ok("[错误] 服务器返回的数据格式异常".to_string()); + } } }; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c14bb8e..36684b8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -34,6 +34,16 @@ async fn get_settings(app_handle: tauri::AppHandle) -> Result serde_json::Value { + ai_translator::test_api_connection(api_key, base_url, model_name, api_type).await +} + pub fn run() { println!("Starting application..."); @@ -74,7 +84,8 @@ pub fn run() { update_translator_shortcut, log_to_backend, get_settings, - get_version + get_version, + test_api_connection ]); // 只在非Windows系统上添加窗口事件监听 diff --git a/src-tauri/src/store.rs b/src-tauri/src/store.rs index 69ea4ea..b70dba8 100644 --- a/src-tauri/src/store.rs +++ b/src-tauri/src/store.rs @@ -10,6 +10,12 @@ pub struct ModelConfig { pub auth: String, pub api_url: String, pub model_name: String, + #[serde(default = "default_api_type")] + pub api_type: String, +} + +fn default_api_type() -> String { + "openai".to_string() } // 添加常用语结构体 @@ -102,8 +108,9 @@ pub fn initialize_settings(app: &AppHandle) -> Result<(), anyhow::Error> { "model_type": "deepseek", "custom_model": { "auth": "", - "api_url": "https://api.openai.com/v1/chat/completions", - "model_name": "gpt-3.5-turbo" + "api_url": "https://api.openai.com", + "model_name": "gpt-3.5-turbo", + "api_type": "openai" }, "phrases": phrases }); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index aee161d..c3f7c1a 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "DeepRant", - "version": "1.1.9", + "version": "1.2.3", "identifier": "com.DeepRant.app", "build": { "beforeDevCommand": "npm run dev", @@ -74,7 +74,7 @@ }, "plugins": { "updater": { - "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDEyRUQyRTlGMEZGRjZCNUQKUldSZGEvOFBueTd0RXB1eStiY2ovR1c0M2lIK0JzTXR0TW9nTjR1QXNsbU9VOGNndG12VHg5L3UK", + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDc3NjE5NEI5NUUwRDk4NEEKUldSS21BMWV1WlJoZDFZTEk0QldhNFNGWkwvVEZBazVxRERmWVAwekNZdC9GdHJzd2IyQmxWOUQK", "endpoints": [ "https://github.com/liseami/DeepRant/releases/latest/download/latest.json" ], diff --git a/src/pages/Settings.jsx b/src/pages/Settings.jsx index b6bae98..32db0b1 100644 --- a/src/pages/Settings.jsx +++ b/src/pages/Settings.jsx @@ -3,44 +3,23 @@ import { Server, Crown, Sparkles, Cube } from '../icons'; import { useState, useEffect } from 'react'; import { useStore } from '../components/StoreProvider'; import { showSuccess, showError } from '../utils/toast'; +import { invoke } from '@tauri-apps/api/core'; -// 添加测试函数 -const testOpenAIConnection = async (apiKey, baseUrl, modelName) => { - try { - const headers = { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${apiKey}` - }; - - const response = await fetch(`${baseUrl}`, { - method: 'POST', - headers: headers, - body: JSON.stringify({ - model: modelName, - messages: [ - { - role: "user", - content: "Hello, this is a test message. Please reply with 'OK' if you receive this." - } - ], - max_tokens: 10 - }) - }); - - const data = await response.json(); - - if (data.error) { - throw new Error(data.error.message || '未知错误'); - } - - if (data.choices && data.choices[0] && data.choices[0].message) { - return true; - } - - throw new Error('响应格式不正确'); - } catch (error) { - throw new Error(`API测试失败: ${error.message}`); +// 通过 Rust 后端测试连接(绕过前端 webview 的 CORS 限制)。 +// 后端返回 { ok, message, details } 结构,前端原样展示。 +const testOpenAIConnection = async (apiKey, baseUrl, modelName, apiType = 'openai') => { + const result = await invoke('test_api_connection', { + apiKey, + baseUrl, + modelName, + apiType, + }); + if (!result?.ok) { + const err = new Error(result?.message || 'API测试失败'); + err.details = result?.details || {}; + throw err; } + return result; }; const MODEL_OPTIONS = [ @@ -70,6 +49,8 @@ export default function Settings() { const { settings, updateSettings } = useStore(); const [activeModel, setActiveModel] = useState(settings?.model_type || 'deepseek'); const [isTestingConnection, setIsTestingConnection] = useState(false); + // testResult: { ok: boolean, message: string, details: object } | null + const [testResult, setTestResult] = useState(null); useEffect(() => { if (settings?.model_type) { @@ -148,6 +129,24 @@ export default function Settings() { 自定义API配置
+
+ + +
@@ -217,17 +216,21 @@ export default function Settings() { } setIsTestingConnection(true); + setTestResult(null); try { const result = await testOpenAIConnection( settings.custom_model.auth, settings.custom_model.api_url, - settings.custom_model.model_name + settings.custom_model.model_name, + settings.custom_model.api_type ); - if (result) { + if (result?.ok) { showSuccess('API连接测试成功!'); + setTestResult({ ok: true, message: 'API连接测试成功', details: result.details }); } } catch (error) { showError(error.message); + setTestResult({ ok: false, message: error.message, details: error.details || { errorMessage: error.message, errorStack: error.stack } }); } finally { setIsTestingConnection(false); } @@ -264,6 +267,49 @@ export default function Settings() { )}
+ {/* 测试详情面板 */} + {activeModel === 'custom' && testResult && ( +
+
+ + {testResult.ok ? '✓ ' : '✗ '}{testResult.message} + +
+ + +
+
+
+{JSON.stringify(testResult.details, null, 2)}
+                                
+ {!testResult.ok && testResult.details?.hint && ( +
+ 💡 {testResult.details.hint} +
+ )} +
+ )}