mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-03-24 16:33:48 +08:00
Feat/auto failover switch (#440)
* feat(failover): add auto-failover master switch with proxy integration
- Add persistent auto_failover_enabled setting in database
- Add get/set_auto_failover_enabled commands
- Provider router respects master switch state
- Proxy shutdown automatically disables failover
- Enabling failover auto-starts proxy server
- Optimistic updates for failover queue toggle
* feat(proxy): persist proxy takeover state across app restarts
- Add proxy_takeover_{app_type} settings for per-app state tracking
- Restore proxy takeover state automatically on app startup
- Preserve state on normal exit, clear on manual stop
- Add stop_with_restore_keep_state method for graceful shutdown
* fix(proxy): set takeover state for all apps in start_with_takeover
* fix(windows): hide console window when checking CLI versions
Add CREATE_NO_WINDOW flag to prevent command prompt from flashing
when detecting claude/codex/gemini CLI versions on Windows.
* refactor(failover): make auto-failover toggle per-app independent
- Change setting key from 'auto_failover_enabled' to 'auto_failover_enabled_{app_type}'
- Update provider_router to check per-app failover setting
- When failover disabled, use current provider only; when enabled, use queue order
- Add unit tests for failover enabled/disabled behavior
* feat(failover): auto-switch to higher priority provider on recovery
- After circuit breaker reset, check if recovered provider has higher priority
- Automatically switch back if queue_order is lower (higher priority)
- Stream health check now resets circuit breaker on success/degraded
* chore: remove unused start_proxy_with_takeover command
- Remove command registration from lib.rs
- Add comment clarifying failover queue is preserved on proxy stop
* feat(ui): integrate failover controls into provider cards
- Add failover toggle button to provider card actions
- Show priority badge (P1, P2, ...) for queued providers
- Highlight active provider with green border in failover mode
- Sync drag-drop order with failover queue
- Move per-app failover toggle to FailoverQueueManager
- Simplify SettingsPage failover section
* test(providers): add mocks for failover hooks in ProviderList tests
* refactor(failover): merge failover_queue table into providers
- Add in_failover_queue field to providers table
- Remove standalone failover_queue table and related indexes
- Simplify queue ordering by reusing sort_index field
- Remove reorder_failover_queue and set_failover_item_enabled commands
- Update frontend to use simplified FailoverQueueItem type
* fix(database): ensure in_failover_queue column exists for v2 databases
Add column check in create_tables to handle existing v2 databases
that were created before the failover queue refactor.
* fix(ui): differentiate active provider border color by proxy mode
- Use green border/gradient when proxy takeover is active
- Use blue border/gradient in normal mode (no proxy)
- Improves visual distinction between proxy and non-proxy states
* fix(database): clear provider health record when removing from failover queue
When a provider is removed from the failover queue, its health monitoring
is no longer needed. This change ensures the health record is also deleted
from the database to prevent stale data.
* fix(failover): improve cache cleanup for provider health and circuit breaker
- Use removeQueries instead of invalidateQueries when stopping proxy to
completely clear health and circuit breaker caches
- Clear provider health and circuit breaker caches when removing from
failover queue
- Refresh failover queue after drag-sort since queue order depends on
sort_index
- Only show health badge when provider is in failover queue
* style: apply prettier formatting to App.tsx and ProviderList.tsx
* fix(proxy): handle missing health records and clear health on proxy stop
- Return default healthy state when provider health record not found
- Add clear_provider_health_for_app to clear health for specific app
- Clear app health records when stopping proxy takeover
* fix(proxy): track actual provider used in forwarding for accurate logging
Introduce ForwardResult and ForwardError structs to return the actual
provider that handled the request. This ensures usage statistics and
error logs reflect the correct provider after failover.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
//! 故障转移队列命令
|
||||
//!
|
||||
//! 管理代理模式下的故障转移队列
|
||||
//! 管理代理模式下的故障转移队列(基于 providers 表的 in_failover_queue 字段)
|
||||
|
||||
use crate::database::FailoverQueueItem;
|
||||
use crate::provider::Provider;
|
||||
@@ -56,54 +56,35 @@ pub async fn remove_from_failover_queue(
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 重新排序故障转移队列
|
||||
/// 获取指定应用的自动故障转移开关状态
|
||||
#[tauri::command]
|
||||
pub async fn reorder_failover_queue(
|
||||
pub async fn get_auto_failover_enabled(
|
||||
state: tauri::State<'_, AppState>,
|
||||
app_type: String,
|
||||
provider_ids: Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
) -> Result<bool, String> {
|
||||
let key = format!("auto_failover_enabled_{app_type}");
|
||||
state
|
||||
.db
|
||||
.reorder_failover_queue(&app_type, &provider_ids)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 设置故障转移队列项的启用状态
|
||||
#[tauri::command]
|
||||
pub async fn set_failover_item_enabled(
|
||||
state: tauri::State<'_, AppState>,
|
||||
app_type: String,
|
||||
provider_id: String,
|
||||
enabled: bool,
|
||||
) -> Result<(), String> {
|
||||
state
|
||||
.db
|
||||
.set_failover_item_enabled(&app_type, &provider_id, enabled)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 获取自动故障转移总开关状态
|
||||
#[tauri::command]
|
||||
pub async fn get_auto_failover_enabled(state: tauri::State<'_, AppState>) -> Result<bool, String> {
|
||||
state
|
||||
.db
|
||||
.get_setting("auto_failover_enabled")
|
||||
.get_setting(&key)
|
||||
.map(|v| v.map(|s| s == "true").unwrap_or(false)) // 默认关闭
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 设置自动故障转移总开关状态
|
||||
/// 设置指定应用的自动故障转移开关状态
|
||||
///
|
||||
/// 注意:关闭故障转移时不会清除队列,队列内容会保留供下次开启时使用
|
||||
#[tauri::command]
|
||||
pub async fn set_auto_failover_enabled(
|
||||
state: tauri::State<'_, AppState>,
|
||||
app_type: String,
|
||||
enabled: bool,
|
||||
) -> Result<(), String> {
|
||||
state
|
||||
.db
|
||||
.set_setting(
|
||||
"auto_failover_enabled",
|
||||
if enabled { "true" } else { "false" },
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
let key = format!("auto_failover_enabled_{app_type}");
|
||||
let value = if enabled { "true" } else { "false" };
|
||||
|
||||
log::info!(
|
||||
"[Failover] Setting auto_failover_enabled: key='{key}', value='{value}', app_type='{app_type}'"
|
||||
);
|
||||
|
||||
state.db.set_setting(&key, value).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -4,6 +4,12 @@ use crate::init_status::InitErrorPayload;
|
||||
use tauri::AppHandle;
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
|
||||
/// 打开外部链接
|
||||
#[tauri::command]
|
||||
pub async fn open_external(app: AppHandle, url: String) -> Result<bool, String> {
|
||||
@@ -142,11 +148,16 @@ fn extract_version(raw: &str) -> String {
|
||||
fn try_get_version(tool: &str) -> (Option<String>, Option<String>) {
|
||||
use std::process::Command;
|
||||
|
||||
let output = if cfg!(target_os = "windows") {
|
||||
#[cfg(target_os = "windows")]
|
||||
let output = {
|
||||
Command::new("cmd")
|
||||
.args(["/C", &format!("{tool} --version")])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output()
|
||||
} else {
|
||||
};
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let output = {
|
||||
Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(format!("{tool} --version"))
|
||||
@@ -239,10 +250,22 @@ fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
|
||||
let current_path = std::env::var("PATH").unwrap_or_default();
|
||||
let new_path = format!("{}:{}", path.display(), current_path);
|
||||
|
||||
let output = Command::new(&tool_path)
|
||||
.arg("--version")
|
||||
.env("PATH", &new_path)
|
||||
.output();
|
||||
#[cfg(target_os = "windows")]
|
||||
let output = {
|
||||
Command::new(&tool_path)
|
||||
.arg("--version")
|
||||
.env("PATH", &new_path)
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output()
|
||||
};
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let output = {
|
||||
Command::new(&tool_path)
|
||||
.arg("--version")
|
||||
.env("PATH", &new_path)
|
||||
.output()
|
||||
};
|
||||
|
||||
if let Ok(out) = output {
|
||||
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
|
||||
@@ -14,14 +14,6 @@ pub async fn start_proxy_server(
|
||||
state.proxy_service.start().await
|
||||
}
|
||||
|
||||
/// 启动代理服务器(带 Live 配置接管)
|
||||
#[tauri::command]
|
||||
pub async fn start_proxy_with_takeover(
|
||||
state: tauri::State<'_, AppState>,
|
||||
) -> Result<ProxyServerInfo, String> {
|
||||
state.proxy_service.start_with_takeover().await
|
||||
}
|
||||
|
||||
/// 停止代理服务器(恢复 Live 配置)
|
||||
#[tauri::command]
|
||||
pub async fn stop_proxy_with_restore(state: tauri::State<'_, AppState>) -> Result<(), String> {
|
||||
@@ -111,8 +103,13 @@ pub async fn get_provider_health(
|
||||
}
|
||||
|
||||
/// 重置熔断器
|
||||
///
|
||||
/// 重置后会检查是否应该切回队列中优先级更高的供应商:
|
||||
/// 1. 检查自动故障转移是否开启
|
||||
/// 2. 如果恢复的供应商在队列中优先级更高(queue_order 更小),则自动切换
|
||||
#[tauri::command]
|
||||
pub async fn reset_circuit_breaker(
|
||||
app_handle: tauri::AppHandle,
|
||||
state: tauri::State<'_, AppState>,
|
||||
provider_id: String,
|
||||
app_type: String,
|
||||
@@ -129,6 +126,75 @@ pub async fn reset_circuit_breaker(
|
||||
.reset_provider_circuit_breaker(&provider_id, &app_type)
|
||||
.await?;
|
||||
|
||||
// 3. 检查是否应该切回优先级更高的供应商
|
||||
let failover_key = format!("auto_failover_enabled_{app_type}");
|
||||
let auto_failover_enabled = match db.get_setting(&failover_key) {
|
||||
Ok(Some(value)) => value == "true",
|
||||
Ok(None) => {
|
||||
log::debug!(
|
||||
"[{app_type}] Failover setting '{failover_key}' not found, defaulting to disabled"
|
||||
);
|
||||
false
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!(
|
||||
"[{app_type}] Failed to read failover setting '{failover_key}': {e}, defaulting to disabled"
|
||||
);
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
if auto_failover_enabled && state.proxy_service.is_running().await {
|
||||
// 获取当前供应商 ID
|
||||
let current_id = db
|
||||
.get_current_provider(&app_type)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if let Some(current_id) = current_id {
|
||||
// 获取故障转移队列
|
||||
let queue = db
|
||||
.get_failover_queue(&app_type)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// 找到恢复的供应商和当前供应商在队列中的位置(使用 sort_index)
|
||||
let restored_order = queue
|
||||
.iter()
|
||||
.find(|item| item.provider_id == provider_id)
|
||||
.and_then(|item| item.sort_index);
|
||||
|
||||
let current_order = queue
|
||||
.iter()
|
||||
.find(|item| item.provider_id == current_id)
|
||||
.and_then(|item| item.sort_index);
|
||||
|
||||
// 如果恢复的供应商优先级更高(sort_index 更小),则切换
|
||||
if let (Some(restored), Some(current)) = (restored_order, current_order) {
|
||||
if restored < current {
|
||||
log::info!(
|
||||
"[Recovery] 供应商 {provider_id} 已恢复且优先级更高 (P{restored} vs P{current}),自动切换"
|
||||
);
|
||||
|
||||
// 获取供应商名称用于日志和事件
|
||||
let provider_name = db
|
||||
.get_all_providers(&app_type)
|
||||
.ok()
|
||||
.and_then(|providers| providers.get(&provider_id).map(|p| p.name.clone()))
|
||||
.unwrap_or_else(|| provider_id.clone());
|
||||
|
||||
// 创建故障转移切换管理器并执行切换
|
||||
let switch_manager =
|
||||
crate::proxy::failover_switch::FailoverSwitchManager::new(db.clone());
|
||||
if let Err(e) = switch_manager
|
||||
.try_switch(Some(&app_handle), &app_type, &provider_id, &provider_name)
|
||||
.await
|
||||
{
|
||||
log::error!("[Recovery] 自动切换失败: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -52,9 +52,7 @@ pub async fn stream_check_all_providers(
|
||||
}
|
||||
if let Ok(queue) = state.db.get_failover_queue(app_type.as_str()) {
|
||||
for item in queue {
|
||||
if item.enabled {
|
||||
ids.insert(item.provider_id);
|
||||
}
|
||||
ids.insert(item.provider_id);
|
||||
}
|
||||
}
|
||||
Some(ids)
|
||||
|
||||
@@ -1,36 +1,32 @@
|
||||
//! 故障转移队列 DAO
|
||||
//!
|
||||
//! 管理代理模式下的故障转移队列
|
||||
//! 管理代理模式下的故障转移队列(基于 providers 表的 in_failover_queue 字段)
|
||||
|
||||
use crate::database::{lock_conn, Database};
|
||||
use crate::error::AppError;
|
||||
use crate::provider::Provider;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// 故障转移队列条目
|
||||
/// 故障转移队列条目(简化版,用于前端展示)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FailoverQueueItem {
|
||||
pub provider_id: String,
|
||||
pub provider_name: String,
|
||||
pub queue_order: i32,
|
||||
pub enabled: bool,
|
||||
pub created_at: i64,
|
||||
pub sort_index: Option<usize>,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
/// 获取故障转移队列(按 queue_order 排序)
|
||||
/// 获取故障转移队列(按 sort_index 排序)
|
||||
pub fn get_failover_queue(&self, app_type: &str) -> Result<Vec<FailoverQueueItem>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT fq.provider_id, p.name, fq.queue_order, fq.enabled, fq.created_at
|
||||
FROM failover_queue fq
|
||||
JOIN providers p ON fq.provider_id = p.id AND fq.app_type = p.app_type
|
||||
WHERE fq.app_type = ?1
|
||||
ORDER BY fq.queue_order ASC",
|
||||
"SELECT id, name, sort_index
|
||||
FROM providers
|
||||
WHERE app_type = ?1 AND in_failover_queue = 1
|
||||
ORDER BY COALESCE(sort_index, 999999), id ASC",
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
@@ -39,9 +35,7 @@ impl Database {
|
||||
Ok(FailoverQueueItem {
|
||||
provider_id: row.get(0)?,
|
||||
provider_name: row.get(1)?,
|
||||
queue_order: row.get(2)?,
|
||||
enabled: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
sort_index: row.get(2)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| AppError::Database(e.to_string()))?
|
||||
@@ -53,43 +47,23 @@ impl Database {
|
||||
|
||||
/// 获取故障转移队列中的供应商(完整 Provider 信息,按顺序)
|
||||
pub fn get_failover_providers(&self, app_type: &str) -> Result<Vec<Provider>, AppError> {
|
||||
let queue = self.get_failover_queue(app_type)?;
|
||||
let all_providers = self.get_all_providers(app_type)?;
|
||||
|
||||
let mut result = Vec::new();
|
||||
for item in queue {
|
||||
if item.enabled {
|
||||
if let Some(provider) = all_providers.get(&item.provider_id) {
|
||||
result.push(provider.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let result: Vec<Provider> = all_providers
|
||||
.into_values()
|
||||
.filter(|p| p.in_failover_queue)
|
||||
.collect();
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// 添加供应商到故障转移队列末尾
|
||||
/// 添加供应商到故障转移队列
|
||||
pub fn add_to_failover_queue(&self, app_type: &str, provider_id: &str) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
// 获取当前最大 queue_order
|
||||
let max_order: i32 = conn
|
||||
.query_row(
|
||||
"SELECT COALESCE(MAX(queue_order), 0) FROM failover_queue WHERE app_type = ?1",
|
||||
[app_type],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64;
|
||||
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO failover_queue (app_type, provider_id, queue_order, enabled, created_at)
|
||||
VALUES (?1, ?2, ?3, 1, ?4)",
|
||||
rusqlite::params![app_type, provider_id, max_order + 1, now],
|
||||
"UPDATE providers SET in_failover_queue = 1 WHERE id = ?1 AND app_type = ?2",
|
||||
rusqlite::params![provider_id, app_type],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
@@ -104,103 +78,21 @@ impl Database {
|
||||
) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
// 获取被删除项的 queue_order
|
||||
let removed_order: Option<i32> = conn
|
||||
.query_row(
|
||||
"SELECT queue_order FROM failover_queue WHERE app_type = ?1 AND provider_id = ?2",
|
||||
[app_type, provider_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.ok();
|
||||
|
||||
// 删除该项
|
||||
// 1. 从队列中移除
|
||||
conn.execute(
|
||||
"DELETE FROM failover_queue WHERE app_type = ?1 AND provider_id = ?2",
|
||||
[app_type, provider_id],
|
||||
"UPDATE providers SET in_failover_queue = 0 WHERE id = ?1 AND app_type = ?2",
|
||||
rusqlite::params![provider_id, app_type],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// 重新排序后面的项(填补空隙)
|
||||
if let Some(order) = removed_order {
|
||||
conn.execute(
|
||||
"UPDATE failover_queue
|
||||
SET queue_order = queue_order - 1
|
||||
WHERE app_type = ?1 AND queue_order > ?2",
|
||||
rusqlite::params![app_type, order],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
}
|
||||
// 2. 清除该供应商的健康状态(退出队列后不再需要健康监控)
|
||||
conn.execute(
|
||||
"DELETE FROM provider_health WHERE provider_id = ?1 AND app_type = ?2",
|
||||
rusqlite::params![provider_id, app_type],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 重新排序故障转移队列
|
||||
/// provider_ids: 按新顺序排列的 provider_id 列表
|
||||
pub fn reorder_failover_queue(
|
||||
&self,
|
||||
app_type: &str,
|
||||
provider_ids: &[String],
|
||||
) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
// 使用事务确保原子性
|
||||
conn.execute("BEGIN TRANSACTION", [])
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
let result = (|| {
|
||||
for (index, provider_id) in provider_ids.iter().enumerate() {
|
||||
conn.execute(
|
||||
"UPDATE failover_queue
|
||||
SET queue_order = ?3
|
||||
WHERE app_type = ?1 AND provider_id = ?2",
|
||||
rusqlite::params![app_type, provider_id, (index + 1) as i32],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
|
||||
match result {
|
||||
Ok(_) => {
|
||||
conn.execute("COMMIT", [])
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
conn.execute("ROLLBACK", []).ok();
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置故障转移队列中供应商的启用状态
|
||||
pub fn set_failover_item_enabled(
|
||||
&self,
|
||||
app_type: &str,
|
||||
provider_id: &str,
|
||||
enabled: bool,
|
||||
) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
let rows_affected = conn
|
||||
.execute(
|
||||
"UPDATE failover_queue SET enabled = ?3 WHERE app_type = ?1 AND provider_id = ?2",
|
||||
rusqlite::params![app_type, provider_id, enabled],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
if rows_affected == 0 {
|
||||
log::warn!(
|
||||
"set_failover_item_enabled: 未找到匹配记录 app_type={app_type}, provider_id={provider_id}"
|
||||
);
|
||||
return Err(AppError::Database(format!(
|
||||
"未找到故障转移队列项: app_type={app_type}, provider_id={provider_id}"
|
||||
)));
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"set_failover_item_enabled: 已更新 app_type={app_type}, provider_id={provider_id}, enabled={enabled}"
|
||||
);
|
||||
log::info!("已从故障转移队列移除供应商 {provider_id} ({app_type}), 并清除其健康状态");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -209,8 +101,11 @@ impl Database {
|
||||
pub fn clear_failover_queue(&self, app_type: &str) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
conn.execute("DELETE FROM failover_queue WHERE app_type = ?1", [app_type])
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
conn.execute(
|
||||
"UPDATE providers SET in_failover_queue = 0 WHERE app_type = ?1",
|
||||
[app_type],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -223,15 +118,15 @@ impl Database {
|
||||
) -> Result<bool, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
let count: i32 = conn
|
||||
let in_queue: bool = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM failover_queue WHERE app_type = ?1 AND provider_id = ?2",
|
||||
[app_type, provider_id],
|
||||
"SELECT in_failover_queue FROM providers WHERE id = ?1 AND app_type = ?2",
|
||||
rusqlite::params![provider_id, app_type],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
.unwrap_or(false);
|
||||
|
||||
Ok(count > 0)
|
||||
Ok(in_queue)
|
||||
}
|
||||
|
||||
/// 获取可添加到故障转移队列的供应商(不在队列中的)
|
||||
@@ -240,14 +135,10 @@ impl Database {
|
||||
app_type: &str,
|
||||
) -> Result<Vec<Provider>, AppError> {
|
||||
let all_providers = self.get_all_providers(app_type)?;
|
||||
let queue = self.get_failover_queue(app_type)?;
|
||||
|
||||
let queue_ids: std::collections::HashSet<_> =
|
||||
queue.iter().map(|item| &item.provider_id).collect();
|
||||
|
||||
let available: Vec<Provider> = all_providers
|
||||
.into_values()
|
||||
.filter(|p| !queue_ids.contains(&p.id))
|
||||
.filter(|p| !p.in_failover_queue)
|
||||
.collect();
|
||||
|
||||
Ok(available)
|
||||
|
||||
@@ -17,7 +17,7 @@ impl Database {
|
||||
) -> Result<IndexMap<String, Provider>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta
|
||||
"SELECT id, name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta, in_failover_queue
|
||||
FROM providers WHERE app_type = ?1
|
||||
ORDER BY COALESCE(sort_index, 999999), created_at ASC, id ASC"
|
||||
).map_err(|e| AppError::Database(e.to_string()))?;
|
||||
@@ -35,6 +35,7 @@ impl Database {
|
||||
let icon: Option<String> = row.get(8)?;
|
||||
let icon_color: Option<String> = row.get(9)?;
|
||||
let meta_str: String = row.get(10)?;
|
||||
let in_failover_queue: bool = row.get(11)?;
|
||||
|
||||
let settings_config =
|
||||
serde_json::from_str(&settings_config_str).unwrap_or(serde_json::Value::Null);
|
||||
@@ -54,6 +55,7 @@ impl Database {
|
||||
meta: Some(meta),
|
||||
icon,
|
||||
icon_color,
|
||||
in_failover_queue,
|
||||
},
|
||||
))
|
||||
})
|
||||
@@ -129,7 +131,7 @@ impl Database {
|
||||
) -> Result<Option<Provider>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let result = conn.query_row(
|
||||
"SELECT name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta
|
||||
"SELECT name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta, in_failover_queue
|
||||
FROM providers WHERE id = ?1 AND app_type = ?2",
|
||||
params![id, app_type],
|
||||
|row| {
|
||||
@@ -143,6 +145,7 @@ impl Database {
|
||||
let icon: Option<String> = row.get(7)?;
|
||||
let icon_color: Option<String> = row.get(8)?;
|
||||
let meta_str: String = row.get(9)?;
|
||||
let in_failover_queue: bool = row.get(10)?;
|
||||
|
||||
let settings_config = serde_json::from_str(&settings_config_str).unwrap_or(serde_json::Value::Null);
|
||||
let meta: ProviderMeta = serde_json::from_str(&meta_str).unwrap_or_default();
|
||||
@@ -159,6 +162,7 @@ impl Database {
|
||||
meta: Some(meta),
|
||||
icon,
|
||||
icon_color,
|
||||
in_failover_queue,
|
||||
})
|
||||
},
|
||||
);
|
||||
@@ -184,17 +188,18 @@ impl Database {
|
||||
let mut meta_clone = provider.meta.clone().unwrap_or_default();
|
||||
let endpoints = std::mem::take(&mut meta_clone.custom_endpoints);
|
||||
|
||||
// 检查是否存在(用于判断新增/更新,以及保留 is_current)
|
||||
let existing: Option<bool> = tx
|
||||
// 检查是否存在(用于判断新增/更新,以及保留 is_current 和 in_failover_queue)
|
||||
let existing: Option<(bool, bool)> = tx
|
||||
.query_row(
|
||||
"SELECT is_current FROM providers WHERE id = ?1 AND app_type = ?2",
|
||||
"SELECT is_current, in_failover_queue FROM providers WHERE id = ?1 AND app_type = ?2",
|
||||
params![provider.id, app_type],
|
||||
|row| row.get(0),
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.ok();
|
||||
|
||||
let is_update = existing.is_some();
|
||||
let is_current = existing.unwrap_or(false);
|
||||
let (is_current, in_failover_queue) =
|
||||
existing.unwrap_or((false, provider.in_failover_queue));
|
||||
|
||||
if is_update {
|
||||
// 更新模式:使用 UPDATE 避免触发 ON DELETE CASCADE
|
||||
@@ -210,8 +215,9 @@ impl Database {
|
||||
icon = ?8,
|
||||
icon_color = ?9,
|
||||
meta = ?10,
|
||||
is_current = ?11
|
||||
WHERE id = ?12 AND app_type = ?13",
|
||||
is_current = ?11,
|
||||
in_failover_queue = ?12
|
||||
WHERE id = ?13 AND app_type = ?14",
|
||||
params![
|
||||
provider.name,
|
||||
serde_json::to_string(&provider.settings_config).unwrap(),
|
||||
@@ -224,6 +230,7 @@ impl Database {
|
||||
provider.icon_color,
|
||||
serde_json::to_string(&meta_clone).unwrap(),
|
||||
is_current,
|
||||
in_failover_queue,
|
||||
provider.id,
|
||||
app_type,
|
||||
],
|
||||
@@ -234,8 +241,8 @@ impl Database {
|
||||
tx.execute(
|
||||
"INSERT INTO providers (
|
||||
id, app_type, name, settings_config, website_url, category,
|
||||
created_at, sort_index, notes, icon, icon_color, meta, is_current
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
||||
created_at, sort_index, notes, icon, icon_color, meta, is_current, in_failover_queue
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
|
||||
params![
|
||||
provider.id,
|
||||
app_type,
|
||||
@@ -250,6 +257,7 @@ impl Database {
|
||||
provider.icon_color,
|
||||
serde_json::to_string(&meta_clone).unwrap(),
|
||||
is_current,
|
||||
in_failover_queue,
|
||||
],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
@@ -102,28 +102,45 @@ impl Database {
|
||||
provider_id: &str,
|
||||
app_type: &str,
|
||||
) -> Result<ProviderHealth, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let result = {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
conn.query_row(
|
||||
"SELECT provider_id, app_type, is_healthy, consecutive_failures,
|
||||
last_success_at, last_failure_at, last_error, updated_at
|
||||
FROM provider_health
|
||||
WHERE provider_id = ?1 AND app_type = ?2",
|
||||
rusqlite::params![provider_id, app_type],
|
||||
|row| {
|
||||
Ok(ProviderHealth {
|
||||
provider_id: row.get(0)?,
|
||||
app_type: row.get(1)?,
|
||||
is_healthy: row.get::<_, i64>(2)? != 0,
|
||||
consecutive_failures: row.get::<_, i64>(3)? as u32,
|
||||
last_success_at: row.get(4)?,
|
||||
last_failure_at: row.get(5)?,
|
||||
last_error: row.get(6)?,
|
||||
updated_at: row.get(7)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))
|
||||
conn.query_row(
|
||||
"SELECT provider_id, app_type, is_healthy, consecutive_failures,
|
||||
last_success_at, last_failure_at, last_error, updated_at
|
||||
FROM provider_health
|
||||
WHERE provider_id = ?1 AND app_type = ?2",
|
||||
rusqlite::params![provider_id, app_type],
|
||||
|row| {
|
||||
Ok(ProviderHealth {
|
||||
provider_id: row.get(0)?,
|
||||
app_type: row.get(1)?,
|
||||
is_healthy: row.get::<_, i64>(2)? != 0,
|
||||
consecutive_failures: row.get::<_, i64>(3)? as u32,
|
||||
last_success_at: row.get(4)?,
|
||||
last_failure_at: row.get(5)?,
|
||||
last_error: row.get(6)?,
|
||||
updated_at: row.get(7)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(health) => Ok(health),
|
||||
// 缺少记录时视为健康(关闭后清空状态,再次打开时默认正常)
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(ProviderHealth {
|
||||
provider_id: provider_id.to_string(),
|
||||
app_type: app_type.to_string(),
|
||||
is_healthy: true,
|
||||
consecutive_failures: 0,
|
||||
last_success_at: None,
|
||||
last_failure_at: None,
|
||||
last_error: None,
|
||||
updated_at: chrono::Utc::now().to_rfc3339(),
|
||||
}),
|
||||
Err(e) => Err(AppError::Database(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新Provider健康状态
|
||||
@@ -228,6 +245,20 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 清空指定应用的健康状态(关闭单个代理时使用)
|
||||
pub async fn clear_provider_health_for_app(&self, app_type: &str) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
conn.execute(
|
||||
"DELETE FROM provider_health WHERE app_type = ?1",
|
||||
[app_type],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
log::debug!("Cleared provider health records for app {app_type}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 清空所有Provider健康状态(代理停止时调用)
|
||||
pub async fn clear_all_provider_health(&self) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
@@ -31,6 +31,7 @@ impl Database {
|
||||
icon_color TEXT,
|
||||
meta TEXT NOT NULL DEFAULT '{}',
|
||||
is_current BOOLEAN NOT NULL DEFAULT 0,
|
||||
in_failover_queue BOOLEAN NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (id, app_type)
|
||||
)",
|
||||
[],
|
||||
@@ -312,29 +313,24 @@ impl Database {
|
||||
[],
|
||||
);
|
||||
|
||||
// 14. Failover Queue 表 (故障转移队列)
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS failover_queue (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
app_type TEXT NOT NULL,
|
||||
provider_id TEXT NOT NULL,
|
||||
queue_order INTEGER NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL,
|
||||
UNIQUE (app_type, provider_id),
|
||||
FOREIGN KEY (provider_id, app_type) REFERENCES providers(id, app_type) ON DELETE CASCADE
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
// 确保 in_failover_queue 列存在(对于已存在的 v2 数据库)
|
||||
Self::add_column_if_missing(
|
||||
conn,
|
||||
"providers",
|
||||
"in_failover_queue",
|
||||
"BOOLEAN NOT NULL DEFAULT 0",
|
||||
)?;
|
||||
|
||||
// 为故障转移队列创建索引
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_failover_queue_order
|
||||
ON failover_queue(app_type, queue_order)",
|
||||
// 删除旧的 failover_queue 表(如果存在)
|
||||
let _ = conn.execute("DROP INDEX IF EXISTS idx_failover_queue_order", []);
|
||||
let _ = conn.execute("DROP TABLE IF EXISTS failover_queue", []);
|
||||
|
||||
// 为故障转移队列创建索引(基于 providers 表)
|
||||
let _ = conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_providers_failover
|
||||
ON providers(app_type, in_failover_queue, sort_index)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -472,6 +468,26 @@ impl Database {
|
||||
Self::add_column_if_missing(conn, "providers", "limit_daily_usd", "TEXT")?;
|
||||
Self::add_column_if_missing(conn, "providers", "limit_monthly_usd", "TEXT")?;
|
||||
Self::add_column_if_missing(conn, "providers", "provider_type", "TEXT")?;
|
||||
Self::add_column_if_missing(
|
||||
conn,
|
||||
"providers",
|
||||
"in_failover_queue",
|
||||
"BOOLEAN NOT NULL DEFAULT 0",
|
||||
)?;
|
||||
|
||||
// 删除旧的 failover_queue 表(如果存在)
|
||||
conn.execute("DROP INDEX IF EXISTS idx_failover_queue_order", [])
|
||||
.map_err(|e| AppError::Database(format!("删除 failover_queue 索引失败: {e}")))?;
|
||||
conn.execute("DROP TABLE IF EXISTS failover_queue", [])
|
||||
.map_err(|e| AppError::Database(format!("删除 failover_queue 表失败: {e}")))?;
|
||||
|
||||
// 创建 failover 索引
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_providers_failover
|
||||
ON providers(app_type, in_failover_queue, sort_index)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("创建 failover 索引失败: {e}")))?;
|
||||
|
||||
// proxy_request_logs 表(包含所有字段)
|
||||
conn.execute(
|
||||
|
||||
@@ -245,6 +245,7 @@ fn dry_run_validates_schema_compatibility() {
|
||||
meta: None,
|
||||
icon: None,
|
||||
icon_color: None,
|
||||
in_failover_queue: false,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -132,6 +132,7 @@ pub(crate) fn build_provider_from_request(
|
||||
meta,
|
||||
icon: request.icon.clone(),
|
||||
icon_color: None,
|
||||
in_failover_queue: false,
|
||||
};
|
||||
|
||||
Ok(provider)
|
||||
|
||||
@@ -650,7 +650,6 @@ pub fn run() {
|
||||
commands::get_auto_launch_status,
|
||||
// Proxy server management
|
||||
commands::start_proxy_server,
|
||||
commands::start_proxy_with_takeover,
|
||||
commands::stop_proxy_with_restore,
|
||||
commands::get_proxy_takeover_status,
|
||||
commands::set_proxy_takeover_for_app,
|
||||
@@ -671,8 +670,6 @@ pub fn run() {
|
||||
commands::get_available_providers_for_failover,
|
||||
commands::add_to_failover_queue,
|
||||
commands::remove_from_failover_queue,
|
||||
commands::reorder_failover_queue,
|
||||
commands::set_failover_item_enabled,
|
||||
commands::get_auto_failover_enabled,
|
||||
commands::set_auto_failover_enabled,
|
||||
// Usage statistics
|
||||
|
||||
@@ -36,6 +36,10 @@ pub struct Provider {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "iconColor")]
|
||||
pub icon_color: Option<String>,
|
||||
/// 是否加入故障转移队列
|
||||
#[serde(default)]
|
||||
#[serde(rename = "inFailoverQueue")]
|
||||
pub in_failover_queue: bool,
|
||||
}
|
||||
|
||||
impl Provider {
|
||||
@@ -58,6 +62,7 @@ impl Provider {
|
||||
meta: None,
|
||||
icon: None,
|
||||
icon_color: None,
|
||||
in_failover_queue: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,16 @@ use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
pub struct ForwardResult {
|
||||
pub response: Response,
|
||||
pub provider: Provider,
|
||||
}
|
||||
|
||||
pub struct ForwardError {
|
||||
pub error: ProxyError,
|
||||
pub provider: Option<Provider>,
|
||||
}
|
||||
|
||||
pub struct RequestForwarder {
|
||||
client: Client,
|
||||
/// 共享的 ProviderRouter(持有熔断器状态)
|
||||
@@ -134,13 +144,16 @@ impl RequestForwarder {
|
||||
body: Value,
|
||||
headers: axum::http::HeaderMap,
|
||||
providers: Vec<Provider>,
|
||||
) -> Result<Response, ProxyError> {
|
||||
) -> Result<ForwardResult, ForwardError> {
|
||||
// 获取适配器
|
||||
let adapter = get_adapter(app_type);
|
||||
let app_type_str = app_type.as_str();
|
||||
|
||||
if providers.is_empty() {
|
||||
return Err(ProxyError::NoAvailableProvider);
|
||||
return Err(ForwardError {
|
||||
error: ProxyError::NoAvailableProvider,
|
||||
provider: None,
|
||||
});
|
||||
}
|
||||
|
||||
log::info!(
|
||||
@@ -150,6 +163,7 @@ impl RequestForwarder {
|
||||
);
|
||||
|
||||
let mut last_error = None;
|
||||
let mut last_provider = None;
|
||||
let mut attempted_providers = 0usize;
|
||||
|
||||
// 依次尝试每个供应商
|
||||
@@ -269,7 +283,10 @@ impl RequestForwarder {
|
||||
latency
|
||||
);
|
||||
|
||||
return Ok(response);
|
||||
return Ok(ForwardResult {
|
||||
response,
|
||||
provider: provider.clone(),
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
let latency = start.elapsed().as_millis() as u64;
|
||||
@@ -310,6 +327,7 @@ impl RequestForwarder {
|
||||
);
|
||||
|
||||
last_error = Some(e);
|
||||
last_provider = Some(provider.clone());
|
||||
// 继续尝试下一个供应商
|
||||
continue;
|
||||
}
|
||||
@@ -331,7 +349,10 @@ impl RequestForwarder {
|
||||
provider.name,
|
||||
e
|
||||
);
|
||||
return Err(e);
|
||||
return Err(ForwardError {
|
||||
error: e,
|
||||
provider: Some(provider.clone()),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -349,7 +370,10 @@ impl RequestForwarder {
|
||||
(status.success_requests as f32 / status.total_requests as f32) * 100.0;
|
||||
}
|
||||
}
|
||||
return Err(ProxyError::NoAvailableProvider);
|
||||
return Err(ForwardError {
|
||||
error: ProxyError::NoAvailableProvider,
|
||||
provider: None,
|
||||
});
|
||||
}
|
||||
|
||||
// 所有供应商都失败了
|
||||
@@ -369,7 +393,10 @@ impl RequestForwarder {
|
||||
providers.len()
|
||||
);
|
||||
|
||||
Err(last_error.unwrap_or(ProxyError::MaxRetriesExceeded))
|
||||
Err(ForwardError {
|
||||
error: last_error.unwrap_or(ProxyError::MaxRetriesExceeded),
|
||||
provider: last_provider,
|
||||
})
|
||||
}
|
||||
|
||||
/// 转发单个请求(使用适配器)
|
||||
|
||||
@@ -61,27 +61,16 @@ pub async fn handle_messages(
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let ctx = RequestContext::new(&state, &body, AppType::Claude, "Claude", "claude").await?;
|
||||
|
||||
// 检查是否需要格式转换(OpenRouter 等中转服务)
|
||||
let adapter = get_adapter(&AppType::Claude);
|
||||
let needs_transform = adapter.needs_transform(&ctx.provider);
|
||||
let mut ctx = RequestContext::new(&state, &body, AppType::Claude, "Claude", "claude").await?;
|
||||
|
||||
let is_stream = body
|
||||
.get("stream")
|
||||
.and_then(|s| s.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
log::info!(
|
||||
"[Claude] Provider: {}, needs_transform: {}, is_stream: {}",
|
||||
ctx.provider.name,
|
||||
needs_transform,
|
||||
is_stream
|
||||
);
|
||||
|
||||
// 转发请求
|
||||
let forwarder = ctx.create_forwarder(&state);
|
||||
let response = match forwarder
|
||||
let result = match forwarder
|
||||
.forward_with_retry(
|
||||
&AppType::Claude,
|
||||
"/v1/messages",
|
||||
@@ -91,13 +80,30 @@ pub async fn handle_messages(
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(resp) => resp,
|
||||
Err(e) => {
|
||||
log_forward_error(&state, &ctx, is_stream, &e);
|
||||
return Err(e);
|
||||
Ok(result) => result,
|
||||
Err(mut err) => {
|
||||
if let Some(provider) = err.provider.take() {
|
||||
ctx.provider = provider;
|
||||
}
|
||||
log_forward_error(&state, &ctx, is_stream, &err.error);
|
||||
return Err(err.error);
|
||||
}
|
||||
};
|
||||
|
||||
ctx.provider = result.provider;
|
||||
let response = result.response;
|
||||
|
||||
// 检查是否需要格式转换(OpenRouter 等中转服务)
|
||||
let adapter = get_adapter(&AppType::Claude);
|
||||
let needs_transform = adapter.needs_transform(&ctx.provider);
|
||||
|
||||
log::info!(
|
||||
"[Claude] Provider: {}, needs_transform: {}, is_stream: {}",
|
||||
ctx.provider.name,
|
||||
needs_transform,
|
||||
is_stream
|
||||
);
|
||||
|
||||
let status = response.status();
|
||||
log::info!("[Claude] 上游响应状态: {status}");
|
||||
|
||||
@@ -295,7 +301,7 @@ pub async fn handle_chat_completions(
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
log::info!("[Codex] ====== /v1/chat/completions 请求开始 ======");
|
||||
|
||||
let ctx = RequestContext::new(&state, &body, AppType::Codex, "Codex", "codex").await?;
|
||||
let mut ctx = RequestContext::new(&state, &body, AppType::Codex, "Codex", "codex").await?;
|
||||
|
||||
let is_stream = body
|
||||
.get("stream")
|
||||
@@ -309,7 +315,7 @@ pub async fn handle_chat_completions(
|
||||
);
|
||||
|
||||
let forwarder = ctx.create_forwarder(&state);
|
||||
let response = match forwarder
|
||||
let result = match forwarder
|
||||
.forward_with_retry(
|
||||
&AppType::Codex,
|
||||
"/v1/chat/completions",
|
||||
@@ -319,13 +325,19 @@ pub async fn handle_chat_completions(
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(resp) => resp,
|
||||
Err(e) => {
|
||||
log_forward_error(&state, &ctx, is_stream, &e);
|
||||
return Err(e);
|
||||
Ok(result) => result,
|
||||
Err(mut err) => {
|
||||
if let Some(provider) = err.provider.take() {
|
||||
ctx.provider = provider;
|
||||
}
|
||||
log_forward_error(&state, &ctx, is_stream, &err.error);
|
||||
return Err(err.error);
|
||||
}
|
||||
};
|
||||
|
||||
ctx.provider = result.provider;
|
||||
let response = result.response;
|
||||
|
||||
log::info!("[Codex] 上游响应状态: {}", response.status());
|
||||
|
||||
process_response(response, &ctx, &state, &OPENAI_PARSER_CONFIG).await
|
||||
@@ -337,7 +349,7 @@ pub async fn handle_responses(
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let ctx = RequestContext::new(&state, &body, AppType::Codex, "Codex", "codex").await?;
|
||||
let mut ctx = RequestContext::new(&state, &body, AppType::Codex, "Codex", "codex").await?;
|
||||
|
||||
let is_stream = body
|
||||
.get("stream")
|
||||
@@ -345,7 +357,7 @@ pub async fn handle_responses(
|
||||
.unwrap_or(false);
|
||||
|
||||
let forwarder = ctx.create_forwarder(&state);
|
||||
let response = match forwarder
|
||||
let result = match forwarder
|
||||
.forward_with_retry(
|
||||
&AppType::Codex,
|
||||
"/v1/responses",
|
||||
@@ -355,13 +367,19 @@ pub async fn handle_responses(
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(resp) => resp,
|
||||
Err(e) => {
|
||||
log_forward_error(&state, &ctx, is_stream, &e);
|
||||
return Err(e);
|
||||
Ok(result) => result,
|
||||
Err(mut err) => {
|
||||
if let Some(provider) = err.provider.take() {
|
||||
ctx.provider = provider;
|
||||
}
|
||||
log_forward_error(&state, &ctx, is_stream, &err.error);
|
||||
return Err(err.error);
|
||||
}
|
||||
};
|
||||
|
||||
ctx.provider = result.provider;
|
||||
let response = result.response;
|
||||
|
||||
log::info!("[Codex] 上游响应状态: {}", response.status());
|
||||
|
||||
process_response(response, &ctx, &state, &CODEX_PARSER_CONFIG).await
|
||||
@@ -379,7 +397,7 @@ pub async fn handle_gemini(
|
||||
Json(body): Json<Value>,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
// Gemini 的模型名称在 URI 中
|
||||
let ctx = RequestContext::new(&state, &body, AppType::Gemini, "Gemini", "gemini")
|
||||
let mut ctx = RequestContext::new(&state, &body, AppType::Gemini, "Gemini", "gemini")
|
||||
.await?
|
||||
.with_model_from_uri(&uri);
|
||||
|
||||
@@ -397,7 +415,7 @@ pub async fn handle_gemini(
|
||||
.unwrap_or(false);
|
||||
|
||||
let forwarder = ctx.create_forwarder(&state);
|
||||
let response = match forwarder
|
||||
let result = match forwarder
|
||||
.forward_with_retry(
|
||||
&AppType::Gemini,
|
||||
endpoint,
|
||||
@@ -407,13 +425,19 @@ pub async fn handle_gemini(
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(resp) => resp,
|
||||
Err(e) => {
|
||||
log_forward_error(&state, &ctx, is_stream, &e);
|
||||
return Err(e);
|
||||
Ok(result) => result,
|
||||
Err(mut err) => {
|
||||
if let Some(provider) = err.provider.take() {
|
||||
ctx.provider = provider;
|
||||
}
|
||||
log_forward_error(&state, &ctx, is_stream, &err.error);
|
||||
return Err(err.error);
|
||||
}
|
||||
};
|
||||
|
||||
ctx.provider = result.provider;
|
||||
let response = result.response;
|
||||
|
||||
log::info!("[Gemini] 上游响应状态: {}", response.status());
|
||||
|
||||
process_response(response, &ctx, &state, &GEMINI_PARSER_CONFIG).await
|
||||
|
||||
@@ -30,85 +30,92 @@ impl ProviderRouter {
|
||||
/// 选择可用的供应商(支持故障转移)
|
||||
///
|
||||
/// 返回按优先级排序的可用供应商列表:
|
||||
/// 1. 当前供应商(is_current=true)始终第一位
|
||||
/// 2. 故障转移队列中的其他供应商(按 queue_order 排序)- 仅当自动故障转移开关开启时
|
||||
/// 3. 只返回熔断器未打开的供应商
|
||||
/// - 故障转移关闭时:仅返回当前供应商
|
||||
/// - 故障转移开启时:完全按照故障转移队列顺序返回,忽略当前供应商设置
|
||||
pub async fn select_providers(&self, app_type: &str) -> Result<Vec<Provider>, AppError> {
|
||||
let mut result = Vec::new();
|
||||
let all_providers = self.db.get_all_providers(app_type)?;
|
||||
|
||||
// 检查自动故障转移总开关是否开启
|
||||
let auto_failover_enabled = self
|
||||
.db
|
||||
.get_setting("auto_failover_enabled")
|
||||
.map(|v| v.map(|s| s == "true").unwrap_or(false)) // 默认关闭
|
||||
.unwrap_or(false);
|
||||
// 检查该应用的自动故障转移开关是否开启
|
||||
let failover_key = format!("auto_failover_enabled_{app_type}");
|
||||
let auto_failover_enabled = match self.db.get_setting(&failover_key) {
|
||||
Ok(Some(value)) => {
|
||||
let enabled = value == "true";
|
||||
log::info!(
|
||||
"[{app_type}] Failover setting '{failover_key}' = '{value}', enabled: {enabled}"
|
||||
);
|
||||
enabled
|
||||
}
|
||||
Ok(None) => {
|
||||
log::warn!(
|
||||
"[{app_type}] Failover setting '{failover_key}' not found in database, defaulting to disabled"
|
||||
);
|
||||
false
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!(
|
||||
"[{app_type}] Failed to read failover setting '{failover_key}': {e}, defaulting to disabled"
|
||||
);
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
// 1. 当前供应商始终第一位
|
||||
if let Some(current_id) = self.db.get_current_provider(app_type)? {
|
||||
if let Some(current) = all_providers.get(¤t_id) {
|
||||
let circuit_key = format!("{}:{}", app_type, current.id);
|
||||
if auto_failover_enabled {
|
||||
// 故障转移开启:使用 in_failover_queue 标记的供应商,按 sort_index 排序
|
||||
let failover_providers = self.db.get_failover_providers(app_type)?;
|
||||
log::info!(
|
||||
"[{}] Failover enabled, using queue order ({} items)",
|
||||
app_type,
|
||||
failover_providers.len()
|
||||
);
|
||||
|
||||
for provider in failover_providers {
|
||||
// 检查熔断器状态
|
||||
let circuit_key = format!("{}:{}", app_type, provider.id);
|
||||
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
|
||||
|
||||
if breaker.is_available().await {
|
||||
log::info!(
|
||||
"[{}] Current provider available: {} ({})",
|
||||
"[{}] Queue provider available: {} ({}) at sort_index {:?}",
|
||||
app_type,
|
||||
current.name,
|
||||
current.id
|
||||
provider.name,
|
||||
provider.id,
|
||||
provider.sort_index
|
||||
);
|
||||
result.push(current.clone());
|
||||
result.push(provider);
|
||||
} else {
|
||||
log::warn!(
|
||||
"[{}] Current provider {} circuit breaker open, checking failover queue",
|
||||
log::debug!(
|
||||
"[{}] Queue provider {} circuit breaker open, skipping",
|
||||
app_type,
|
||||
current.name
|
||||
provider.name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 故障转移关闭:仅使用当前供应商
|
||||
log::info!("[{app_type}] Failover disabled, using current provider only");
|
||||
|
||||
// 2. 获取故障转移队列中的供应商(仅当自动故障转移开关开启时)
|
||||
if auto_failover_enabled {
|
||||
let queue = self.db.get_failover_queue(app_type)?;
|
||||
|
||||
for item in queue {
|
||||
// 跳过已添加的当前供应商
|
||||
if result.iter().any(|p| p.id == item.provider_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 跳过禁用的队列项
|
||||
if !item.enabled {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 获取供应商信息
|
||||
if let Some(provider) = all_providers.get(&item.provider_id) {
|
||||
// 检查熔断器状态
|
||||
let circuit_key = format!("{}:{}", app_type, provider.id);
|
||||
if let Some(current_id) = self.db.get_current_provider(app_type)? {
|
||||
if let Some(current) = self.db.get_provider_by_id(¤t_id, app_type)? {
|
||||
let circuit_key = format!("{}:{}", app_type, current.id);
|
||||
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
|
||||
|
||||
if breaker.is_available().await {
|
||||
log::info!(
|
||||
"[{}] Failover provider available: {} ({}) at queue position {}",
|
||||
"[{}] Current provider available: {} ({})",
|
||||
app_type,
|
||||
provider.name,
|
||||
provider.id,
|
||||
item.queue_order
|
||||
current.name,
|
||||
current.id
|
||||
);
|
||||
result.push(provider.clone());
|
||||
result.push(current);
|
||||
} else {
|
||||
log::debug!(
|
||||
"[{}] Failover provider {} circuit breaker open, skipping",
|
||||
log::warn!(
|
||||
"[{}] Current provider {} circuit breaker open",
|
||||
app_type,
|
||||
provider.name
|
||||
current.name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log::info!("[{app_type}] Auto-failover disabled, using only current provider");
|
||||
}
|
||||
|
||||
if result.is_empty() {
|
||||
@@ -118,7 +125,7 @@ impl ProviderRouter {
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"[{}] Failover chain: {} provider(s) available",
|
||||
"[{}] Provider chain: {} provider(s) available",
|
||||
app_type,
|
||||
result.len()
|
||||
);
|
||||
@@ -275,25 +282,14 @@ mod tests {
|
||||
let db = Arc::new(Database::memory().unwrap());
|
||||
let router = ProviderRouter::new(db);
|
||||
|
||||
// 测试创建熔断器
|
||||
let breaker = router.get_or_create_circuit_breaker("claude:test").await;
|
||||
assert!(breaker.allow_request().await.allowed);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn select_providers_does_not_consume_half_open_permit() {
|
||||
async fn test_failover_disabled_uses_current_provider() {
|
||||
let db = Arc::new(Database::memory().unwrap());
|
||||
|
||||
// 配置:让熔断器 Open 后立刻进入 HalfOpen(timeout_seconds=0),并用 1 次失败就打开熔断器
|
||||
db.update_circuit_breaker_config(&CircuitBreakerConfig {
|
||||
failure_threshold: 1,
|
||||
timeout_seconds: 0,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 准备 2 个 Provider:A(当前)+ B(队列)
|
||||
let provider_a =
|
||||
Provider::with_id("a".to_string(), "Provider A".to_string(), json!({}), None);
|
||||
let provider_b =
|
||||
@@ -304,19 +300,78 @@ mod tests {
|
||||
db.set_current_provider("claude", "a").unwrap();
|
||||
db.add_to_failover_queue("claude", "b").unwrap();
|
||||
|
||||
let router = ProviderRouter::new(db.clone());
|
||||
let providers = router.select_providers("claude").await.unwrap();
|
||||
|
||||
assert_eq!(providers.len(), 1);
|
||||
assert_eq!(providers[0].id, "a");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_failover_enabled_uses_queue_order() {
|
||||
let db = Arc::new(Database::memory().unwrap());
|
||||
|
||||
// 设置 sort_index 来控制顺序:b=1, a=2
|
||||
let mut provider_a =
|
||||
Provider::with_id("a".to_string(), "Provider A".to_string(), json!({}), None);
|
||||
provider_a.sort_index = Some(2);
|
||||
let mut provider_b =
|
||||
Provider::with_id("b".to_string(), "Provider B".to_string(), json!({}), None);
|
||||
provider_b.sort_index = Some(1);
|
||||
|
||||
db.save_provider("claude", &provider_a).unwrap();
|
||||
db.save_provider("claude", &provider_b).unwrap();
|
||||
db.set_current_provider("claude", "a").unwrap();
|
||||
|
||||
db.add_to_failover_queue("claude", "b").unwrap();
|
||||
db.add_to_failover_queue("claude", "a").unwrap();
|
||||
db.set_setting("auto_failover_enabled_claude", "true")
|
||||
.unwrap();
|
||||
|
||||
let router = ProviderRouter::new(db.clone());
|
||||
let providers = router.select_providers("claude").await.unwrap();
|
||||
|
||||
assert_eq!(providers.len(), 2);
|
||||
// 按 sort_index 排序:b(1) 在前,a(2) 在后
|
||||
assert_eq!(providers[0].id, "b");
|
||||
assert_eq!(providers[1].id, "a");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_select_providers_does_not_consume_half_open_permit() {
|
||||
let db = Arc::new(Database::memory().unwrap());
|
||||
|
||||
db.update_circuit_breaker_config(&CircuitBreakerConfig {
|
||||
failure_threshold: 1,
|
||||
timeout_seconds: 0,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let provider_a =
|
||||
Provider::with_id("a".to_string(), "Provider A".to_string(), json!({}), None);
|
||||
let provider_b =
|
||||
Provider::with_id("b".to_string(), "Provider B".to_string(), json!({}), None);
|
||||
|
||||
db.save_provider("claude", &provider_a).unwrap();
|
||||
db.save_provider("claude", &provider_b).unwrap();
|
||||
|
||||
db.add_to_failover_queue("claude", "a").unwrap();
|
||||
db.add_to_failover_queue("claude", "b").unwrap();
|
||||
db.set_setting("auto_failover_enabled_claude", "true")
|
||||
.unwrap();
|
||||
|
||||
let router = ProviderRouter::new(db.clone());
|
||||
|
||||
// 让 B 进入 Open 状态(failure_threshold=1)
|
||||
router
|
||||
.record_result("b", "claude", false, false, Some("fail".to_string()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// select_providers 只做“可用性判断”,不应占用 HalfOpen 探测名额
|
||||
let providers = router.select_providers("claude").await.unwrap();
|
||||
assert_eq!(providers.len(), 2);
|
||||
|
||||
// 如果 select_providers 错误地消耗了 HalfOpen 名额,这里会返回 false(被限流拒绝)
|
||||
assert!(router.allow_provider_request("b", "claude").await.allowed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,6 +270,7 @@ mod tests {
|
||||
meta: None,
|
||||
icon: None,
|
||||
icon_color: None,
|
||||
in_failover_queue: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -174,6 +174,7 @@ mod tests {
|
||||
meta: None,
|
||||
icon: None,
|
||||
icon_color: None,
|
||||
in_failover_queue: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -250,6 +250,7 @@ mod tests {
|
||||
meta: None,
|
||||
icon: None,
|
||||
icon_color: None,
|
||||
in_failover_queue: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -209,6 +209,7 @@ mod tests {
|
||||
meta: None,
|
||||
icon: None,
|
||||
icon_color: None,
|
||||
in_failover_queue: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -394,6 +394,7 @@ mod tests {
|
||||
meta: None,
|
||||
icon: None,
|
||||
icon_color: None,
|
||||
in_failover_queue: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -258,7 +258,13 @@ impl ProxyService {
|
||||
.set_proxy_takeover_enabled(app_type_str, false)
|
||||
.map_err(|e| format!("清除 {app_type_str} 接管状态失败: {e}"))?;
|
||||
|
||||
// 4) 若无其它接管,更新旧标志,并停止代理服务
|
||||
// 4) 清除该应用的健康状态(关闭代理时重置队列状态)
|
||||
self.db
|
||||
.clear_provider_health_for_app(app_type_str)
|
||||
.await
|
||||
.map_err(|e| format!("清除 {app_type_str} 健康状态失败: {e}"))?;
|
||||
|
||||
// 5) 若无其它接管,更新旧标志,并停止代理服务
|
||||
let has_any_backup = self
|
||||
.db
|
||||
.has_any_live_backup()
|
||||
@@ -538,6 +544,7 @@ impl ProxyService {
|
||||
.await
|
||||
.map_err(|e| format!("重置健康状态失败: {e}"))?;
|
||||
|
||||
// 注意:不清除故障转移队列和开关状态,保留供下次开启代理时使用
|
||||
log::info!("代理已停止,Live 配置已恢复");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
44
src/App.tsx
44
src/App.tsx
@@ -72,9 +72,20 @@ function App() {
|
||||
"bg-orange-500 hover:bg-orange-600 dark:bg-orange-500 dark:hover:bg-orange-600 text-white shadow-lg shadow-orange-500/30 dark:shadow-orange-500/40 rounded-full w-8 h-8";
|
||||
|
||||
// 获取代理服务状态
|
||||
const { isRunning: isProxyRunning, takeoverStatus } = useProxyStatus();
|
||||
const {
|
||||
isRunning: isProxyRunning,
|
||||
takeoverStatus,
|
||||
status: proxyStatus,
|
||||
} = useProxyStatus();
|
||||
// 当前应用的代理是否开启
|
||||
const isCurrentAppTakeoverActive = takeoverStatus?.[activeApp] || false;
|
||||
// 当前应用代理实际使用的供应商 ID(从 active_targets 中获取)
|
||||
const activeProviderId = useMemo(() => {
|
||||
const target = proxyStatus?.active_targets?.find(
|
||||
(t) => t.app_type === activeApp,
|
||||
);
|
||||
return target?.provider_id;
|
||||
}, [proxyStatus?.active_targets, activeApp]);
|
||||
|
||||
// 获取供应商列表,当代理服务运行时自动刷新
|
||||
const { data, isLoading, refetch } = useProvidersQuery(activeApp, {
|
||||
@@ -105,7 +116,7 @@ function App() {
|
||||
if (event.appType === activeApp) {
|
||||
await refetch();
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("[App] Failed to subscribe provider switch event", error);
|
||||
@@ -135,7 +146,7 @@ function App() {
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[App] Failed to check environment conflicts on startup:",
|
||||
error
|
||||
error,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -151,7 +162,7 @@ function App() {
|
||||
if (migrated) {
|
||||
toast.success(
|
||||
t("migration.success", { defaultValue: "配置迁移成功" }),
|
||||
{ closeButton: true }
|
||||
{ closeButton: true },
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -172,10 +183,10 @@ function App() {
|
||||
// 合并新检测到的冲突
|
||||
setEnvConflicts((prev) => {
|
||||
const existingKeys = new Set(
|
||||
prev.map((c) => `${c.varName}:${c.sourcePath}`)
|
||||
prev.map((c) => `${c.varName}:${c.sourcePath}`),
|
||||
);
|
||||
const newConflicts = conflicts.filter(
|
||||
(c) => !existingKeys.has(`${c.varName}:${c.sourcePath}`)
|
||||
(c) => !existingKeys.has(`${c.varName}:${c.sourcePath}`),
|
||||
);
|
||||
return [...prev, ...newConflicts];
|
||||
});
|
||||
@@ -187,7 +198,7 @@ function App() {
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[App] Failed to check environment conflicts on app switch:",
|
||||
error
|
||||
error,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -248,7 +259,7 @@ function App() {
|
||||
(p) =>
|
||||
p.sortIndex !== undefined &&
|
||||
p.sortIndex >= newSortIndex! &&
|
||||
p.id !== provider.id
|
||||
p.id !== provider.id,
|
||||
)
|
||||
.map((p) => ({
|
||||
id: p.id,
|
||||
@@ -264,7 +275,7 @@ function App() {
|
||||
toast.error(
|
||||
t("provider.sortUpdateFailed", {
|
||||
defaultValue: "排序更新失败",
|
||||
})
|
||||
}),
|
||||
);
|
||||
return; // 如果排序更新失败,不继续添加
|
||||
}
|
||||
@@ -334,7 +345,9 @@ function App() {
|
||||
/>
|
||||
);
|
||||
case "agents":
|
||||
return <AgentsPanel onOpenChange={() => setCurrentView("providers")} />;
|
||||
return (
|
||||
<AgentsPanel onOpenChange={() => setCurrentView("providers")} />
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div className="mx-auto max-w-[56rem] px-5 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
|
||||
@@ -355,7 +368,10 @@ function App() {
|
||||
appId={activeApp}
|
||||
isLoading={isLoading}
|
||||
isProxyRunning={isProxyRunning}
|
||||
isProxyTakeover={isProxyRunning && isCurrentAppTakeoverActive}
|
||||
isProxyTakeover={
|
||||
isProxyRunning && isCurrentAppTakeoverActive
|
||||
}
|
||||
activeProviderId={activeProviderId}
|
||||
onSwitch={switchProvider}
|
||||
onEdit={setEditingProvider}
|
||||
onDelete={setConfirmDelete}
|
||||
@@ -418,7 +434,7 @@ function App() {
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[App] Failed to re-check conflicts after deletion:",
|
||||
error
|
||||
error,
|
||||
);
|
||||
}
|
||||
}}
|
||||
@@ -475,7 +491,7 @@ function App() {
|
||||
"text-xl font-semibold transition-colors",
|
||||
isProxyRunning && isCurrentAppTakeoverActive
|
||||
? "text-emerald-500 hover:text-emerald-600 dark:text-emerald-400 dark:hover:text-emerald-300"
|
||||
: "text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
: "text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300",
|
||||
)}
|
||||
>
|
||||
CC Switch
|
||||
@@ -557,7 +573,7 @@ function App() {
|
||||
"transition-all duration-200 ease-in-out overflow-hidden",
|
||||
hasSkillsSupport
|
||||
? "opacity-100 w-8 scale-100 px-2"
|
||||
: "opacity-0 w-0 scale-75 pointer-events-none px-0 -ml-1"
|
||||
: "opacity-0 w-0 scale-75 pointer-events-none px-0 -ml-1",
|
||||
)}
|
||||
title={t("skills.manage")}
|
||||
>
|
||||
|
||||
34
src/components/providers/FailoverPriorityBadge.tsx
Normal file
34
src/components/providers/FailoverPriorityBadge.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface FailoverPriorityBadgeProps {
|
||||
priority: number; // 1, 2, 3, ...
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 故障转移优先级徽章
|
||||
* 显示供应商在故障转移队列中的优先级顺序
|
||||
*/
|
||||
export function FailoverPriorityBadge({
|
||||
priority,
|
||||
className,
|
||||
}: FailoverPriorityBadgeProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center px-1.5 py-0.5 rounded text-xs font-semibold",
|
||||
"bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
|
||||
className,
|
||||
)}
|
||||
title={t("failover.priority.tooltip", {
|
||||
priority,
|
||||
defaultValue: `故障转移优先级 ${priority}`,
|
||||
})}
|
||||
>
|
||||
P{priority}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Edit,
|
||||
Loader2,
|
||||
Play,
|
||||
Plus,
|
||||
TestTube2,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
@@ -22,6 +23,10 @@ interface ProviderActionsProps {
|
||||
onTest?: () => void;
|
||||
onConfigureUsage: () => void;
|
||||
onDelete: () => void;
|
||||
// 故障转移相关
|
||||
isAutoFailoverEnabled?: boolean;
|
||||
isInFailoverQueue?: boolean;
|
||||
onToggleFailover?: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export function ProviderActions({
|
||||
@@ -34,38 +39,88 @@ export function ProviderActions({
|
||||
onTest,
|
||||
onConfigureUsage,
|
||||
onDelete,
|
||||
// 故障转移相关
|
||||
isAutoFailoverEnabled = false,
|
||||
isInFailoverQueue = false,
|
||||
onToggleFailover,
|
||||
}: ProviderActionsProps) {
|
||||
const { t } = useTranslation();
|
||||
const iconButtonClass = "h-8 w-8 p-1";
|
||||
|
||||
// 故障转移模式下的按钮逻辑
|
||||
const isFailoverMode = isAutoFailoverEnabled && onToggleFailover;
|
||||
|
||||
// 处理主按钮点击
|
||||
const handleMainButtonClick = () => {
|
||||
if (isFailoverMode) {
|
||||
// 故障转移模式:切换队列状态
|
||||
onToggleFailover(!isInFailoverQueue);
|
||||
} else {
|
||||
// 普通模式:切换供应商
|
||||
onSwitch();
|
||||
}
|
||||
};
|
||||
|
||||
// 主按钮的状态和样式
|
||||
const getMainButtonState = () => {
|
||||
if (isFailoverMode) {
|
||||
// 故障转移模式
|
||||
if (isInFailoverQueue) {
|
||||
return {
|
||||
disabled: false,
|
||||
variant: "secondary" as const,
|
||||
className:
|
||||
"bg-blue-100 text-blue-600 hover:bg-blue-200 dark:bg-blue-900/50 dark:text-blue-400 dark:hover:bg-blue-900/70",
|
||||
icon: <Check className="h-4 w-4" />,
|
||||
text: t("failover.inQueue", { defaultValue: "已加入" }),
|
||||
};
|
||||
}
|
||||
return {
|
||||
disabled: false,
|
||||
variant: "default" as const,
|
||||
className:
|
||||
"bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700",
|
||||
icon: <Plus className="h-4 w-4" />,
|
||||
text: t("failover.addQueue", { defaultValue: "加入" }),
|
||||
};
|
||||
}
|
||||
|
||||
// 普通模式
|
||||
if (isCurrent) {
|
||||
return {
|
||||
disabled: true,
|
||||
variant: "secondary" as const,
|
||||
className:
|
||||
"bg-gray-200 text-muted-foreground hover:bg-gray-200 hover:text-muted-foreground dark:bg-gray-700 dark:hover:bg-gray-700",
|
||||
icon: <Check className="h-4 w-4" />,
|
||||
text: t("provider.inUse"),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
disabled: false,
|
||||
variant: "default" as const,
|
||||
className: isProxyTakeover
|
||||
? "bg-emerald-500 hover:bg-emerald-600 dark:bg-emerald-600 dark:hover:bg-emerald-700"
|
||||
: "",
|
||||
icon: <Play className="h-4 w-4" />,
|
||||
text: t("provider.enable"),
|
||||
};
|
||||
};
|
||||
|
||||
const buttonState = getMainButtonState();
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isCurrent ? "secondary" : "default"}
|
||||
onClick={onSwitch}
|
||||
disabled={isCurrent}
|
||||
className={cn(
|
||||
"w-[4.5rem] px-2.5",
|
||||
isCurrent &&
|
||||
"bg-gray-200 text-muted-foreground hover:bg-gray-200 hover:text-muted-foreground dark:bg-gray-700 dark:hover:bg-gray-700",
|
||||
// 代理接管模式下启用按钮使用绿色
|
||||
!isCurrent &&
|
||||
isProxyTakeover &&
|
||||
"bg-emerald-500 hover:bg-emerald-600 dark:bg-emerald-600 dark:hover:bg-emerald-700",
|
||||
)}
|
||||
variant={buttonState.variant}
|
||||
onClick={handleMainButtonClick}
|
||||
disabled={buttonState.disabled}
|
||||
className={cn("w-[4.5rem] px-2.5", buttonState.className)}
|
||||
>
|
||||
{isCurrent ? (
|
||||
<>
|
||||
<Check className="h-4 w-4" />
|
||||
{t("provider.inUse")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="h-4 w-4" />
|
||||
{t("provider.enable")}
|
||||
</>
|
||||
)}
|
||||
{buttonState.icon}
|
||||
{buttonState.text}
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
|
||||
@@ -12,6 +12,7 @@ import { ProviderActions } from "@/components/providers/ProviderActions";
|
||||
import { ProviderIcon } from "@/components/ProviderIcon";
|
||||
import UsageFooter from "@/components/UsageFooter";
|
||||
import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge";
|
||||
import { FailoverPriorityBadge } from "@/components/providers/FailoverPriorityBadge";
|
||||
import { useProviderHealth } from "@/lib/query/failover";
|
||||
import { useUsageQuery } from "@/lib/query/queries";
|
||||
|
||||
@@ -36,6 +37,12 @@ interface ProviderCardProps {
|
||||
isProxyRunning: boolean;
|
||||
isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管,切换为热切换)
|
||||
dragHandleProps?: DragHandleProps;
|
||||
// 故障转移相关
|
||||
isAutoFailoverEnabled?: boolean; // 是否开启自动故障转移
|
||||
failoverPriority?: number; // 故障转移优先级(1 = P1, 2 = P2, ...)
|
||||
isInFailoverQueue?: boolean; // 是否在故障转移队列中
|
||||
onToggleFailover?: (enabled: boolean) => void; // 切换故障转移队列
|
||||
activeProviderId?: string; // 代理当前实际使用的供应商 ID(用于故障转移模式下标注绿色边框)
|
||||
}
|
||||
|
||||
const extractApiUrl = (provider: Provider, fallbackText: string) => {
|
||||
@@ -88,6 +95,12 @@ export function ProviderCard({
|
||||
isProxyRunning,
|
||||
isProxyTakeover = false,
|
||||
dragHandleProps,
|
||||
// 故障转移相关
|
||||
isAutoFailoverEnabled = false,
|
||||
failoverPriority,
|
||||
isInFailoverQueue = false,
|
||||
onToggleFailover,
|
||||
activeProviderId,
|
||||
}: ProviderCardProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -148,21 +161,32 @@ export function ProviderCard({
|
||||
onOpenWebsite(displayUrl);
|
||||
};
|
||||
|
||||
// 判断是否是"当前使用中"的供应商
|
||||
// - 故障转移模式:代理实际使用的供应商(activeProviderId)
|
||||
// - 代理接管模式(非故障转移):isCurrent
|
||||
// - 普通模式:isCurrent
|
||||
const isActiveProvider = isAutoFailoverEnabled
|
||||
? activeProviderId === provider.id
|
||||
: isCurrent;
|
||||
|
||||
// 判断是否使用绿色(代理接管模式)还是蓝色(普通模式)
|
||||
const shouldUseGreen = isProxyTakeover && isActiveProvider;
|
||||
const shouldUseBlue = !isProxyTakeover && isActiveProvider;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative overflow-hidden rounded-xl border border-border p-4 transition-all duration-300",
|
||||
"bg-card text-card-foreground group",
|
||||
// 代理接管模式下 hover 使用绿色边框,否则使用蓝色
|
||||
isProxyTakeover
|
||||
// hover 时的边框效果
|
||||
isAutoFailoverEnabled || isProxyTakeover
|
||||
? "hover:border-emerald-500/50"
|
||||
: "hover:border-border-active",
|
||||
// 代理接管模式下当前供应商使用绿色边框
|
||||
isProxyTakeover && isCurrent
|
||||
? "border-emerald-500/60 shadow-sm shadow-emerald-500/10"
|
||||
: isCurrent
|
||||
? "border-primary/50 shadow-sm"
|
||||
: "hover:shadow-sm",
|
||||
// 当前激活的供应商边框样式
|
||||
shouldUseGreen &&
|
||||
"border-emerald-500/60 shadow-sm shadow-emerald-500/10",
|
||||
shouldUseBlue && "border-blue-500/60 shadow-sm shadow-blue-500/10",
|
||||
!isActiveProvider && "hover:shadow-sm",
|
||||
dragHandleProps?.isDragging &&
|
||||
"cursor-grabbing border-primary shadow-lg scale-105 z-10",
|
||||
)}
|
||||
@@ -170,11 +194,11 @@ export function ProviderCard({
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 bg-gradient-to-r to-transparent transition-opacity duration-500 pointer-events-none",
|
||||
// 代理接管模式下使用绿色渐变,否则使用蓝色主色调
|
||||
isProxyTakeover && isCurrent
|
||||
? "from-emerald-500/10"
|
||||
: "from-primary/10",
|
||||
isCurrent ? "opacity-100" : "opacity-0",
|
||||
// 代理接管模式使用绿色渐变,普通模式使用蓝色渐变
|
||||
shouldUseGreen && "from-emerald-500/10",
|
||||
shouldUseBlue && "from-blue-500/10",
|
||||
!isActiveProvider && "from-primary/10",
|
||||
isActiveProvider ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="relative flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
@@ -209,13 +233,20 @@ export function ProviderCard({
|
||||
{provider.name}
|
||||
</h3>
|
||||
|
||||
{/* 健康状态徽章和优先级 */}
|
||||
{isProxyRunning && health && (
|
||||
{/* 健康状态徽章 */}
|
||||
{isProxyRunning && isInFailoverQueue && health && (
|
||||
<ProviderHealthBadge
|
||||
consecutiveFailures={health.consecutive_failures}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 故障转移优先级徽章 */}
|
||||
{isAutoFailoverEnabled &&
|
||||
isInFailoverQueue &&
|
||||
failoverPriority && (
|
||||
<FailoverPriorityBadge priority={failoverPriority} />
|
||||
)}
|
||||
|
||||
{provider.category === "third_party" &&
|
||||
provider.meta?.isPartner && (
|
||||
<span
|
||||
@@ -308,6 +339,10 @@ export function ProviderCard({
|
||||
onTest={onTest ? () => onTest(provider) : undefined}
|
||||
onConfigureUsage={() => onConfigureUsage(provider)}
|
||||
onDelete={() => onDelete(provider)}
|
||||
// 故障转移相关
|
||||
isAutoFailoverEnabled={isAutoFailoverEnabled}
|
||||
isInFailoverQueue={isInFailoverQueue}
|
||||
onToggleFailover={onToggleFailover}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,6 +21,13 @@ import { useDragSort } from "@/hooks/useDragSort";
|
||||
import { useStreamCheck } from "@/hooks/useStreamCheck";
|
||||
import { ProviderCard } from "@/components/providers/ProviderCard";
|
||||
import { ProviderEmptyState } from "@/components/providers/ProviderEmptyState";
|
||||
import {
|
||||
useAutoFailoverEnabled,
|
||||
useFailoverQueue,
|
||||
useAddToFailoverQueue,
|
||||
useRemoveFromFailoverQueue,
|
||||
} from "@/lib/query/failover";
|
||||
import { useCallback } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
@@ -38,6 +45,7 @@ interface ProviderListProps {
|
||||
isLoading?: boolean;
|
||||
isProxyRunning?: boolean; // 代理服务运行状态
|
||||
isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管)
|
||||
activeProviderId?: string; // 代理当前实际使用的供应商 ID(用于故障转移模式下标注绿色边框)
|
||||
}
|
||||
|
||||
export function ProviderList({
|
||||
@@ -52,18 +60,62 @@ export function ProviderList({
|
||||
onOpenWebsite,
|
||||
onCreate,
|
||||
isLoading = false,
|
||||
isProxyRunning = false, // 默认值为 false
|
||||
isProxyTakeover = false, // 默认值为 false
|
||||
isProxyRunning = false,
|
||||
isProxyTakeover = false,
|
||||
activeProviderId,
|
||||
}: ProviderListProps) {
|
||||
const { t } = useTranslation();
|
||||
const { sortedProviders, sensors, handleDragEnd } = useDragSort(
|
||||
providers,
|
||||
appId
|
||||
appId,
|
||||
);
|
||||
|
||||
// 流式健康检查
|
||||
const { checkProvider, isChecking } = useStreamCheck(appId);
|
||||
|
||||
// 故障转移相关
|
||||
const { data: isAutoFailoverEnabled } = useAutoFailoverEnabled(appId);
|
||||
const { data: failoverQueue } = useFailoverQueue(appId);
|
||||
const addToQueue = useAddToFailoverQueue();
|
||||
const removeFromQueue = useRemoveFromFailoverQueue();
|
||||
|
||||
// 联动状态:只有当前应用开启代理接管且故障转移开启时才启用故障转移模式
|
||||
const isFailoverModeActive =
|
||||
isProxyTakeover === true && isAutoFailoverEnabled === true;
|
||||
|
||||
// 计算供应商在故障转移队列中的优先级(基于 sortIndex 排序)
|
||||
const getFailoverPriority = useCallback(
|
||||
(providerId: string): number | undefined => {
|
||||
if (!isFailoverModeActive || !failoverQueue) return undefined;
|
||||
const index = failoverQueue.findIndex(
|
||||
(item) => item.providerId === providerId,
|
||||
);
|
||||
return index >= 0 ? index + 1 : undefined;
|
||||
},
|
||||
[isFailoverModeActive, failoverQueue],
|
||||
);
|
||||
|
||||
// 判断供应商是否在故障转移队列中
|
||||
const isInFailoverQueue = useCallback(
|
||||
(providerId: string): boolean => {
|
||||
if (!isFailoverModeActive || !failoverQueue) return false;
|
||||
return failoverQueue.some((item) => item.providerId === providerId);
|
||||
},
|
||||
[isFailoverModeActive, failoverQueue],
|
||||
);
|
||||
|
||||
// 切换供应商的故障转移队列状态
|
||||
const handleToggleFailover = useCallback(
|
||||
(providerId: string, enabled: boolean) => {
|
||||
if (enabled) {
|
||||
addToQueue.mutate({ appType: appId, providerId });
|
||||
} else {
|
||||
removeFromQueue.mutate({ appType: appId, providerId });
|
||||
}
|
||||
},
|
||||
[appId, addToQueue, removeFromQueue],
|
||||
);
|
||||
|
||||
const handleTest = (provider: Provider) => {
|
||||
checkProvider(provider.id, provider.name);
|
||||
};
|
||||
@@ -106,7 +158,7 @@ export function ProviderList({
|
||||
return sortedProviders.filter((provider) => {
|
||||
const fields = [provider.name, provider.notes, provider.websiteUrl];
|
||||
return fields.some((field) =>
|
||||
field?.toString().toLowerCase().includes(keyword)
|
||||
field?.toString().toLowerCase().includes(keyword),
|
||||
);
|
||||
});
|
||||
}, [searchTerm, sortedProviders]);
|
||||
@@ -155,6 +207,14 @@ export function ProviderList({
|
||||
isTesting={isChecking(provider.id)}
|
||||
isProxyRunning={isProxyRunning}
|
||||
isProxyTakeover={isProxyTakeover}
|
||||
// 故障转移相关:联动状态
|
||||
isAutoFailoverEnabled={isFailoverModeActive}
|
||||
failoverPriority={getFailoverPriority(provider.id)}
|
||||
isInFailoverQueue={isInFailoverQueue(provider.id)}
|
||||
onToggleFailover={(enabled) =>
|
||||
handleToggleFailover(provider.id, enabled)
|
||||
}
|
||||
activeProviderId={activeProviderId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -255,6 +315,12 @@ interface SortableProviderCardProps {
|
||||
isTesting: boolean;
|
||||
isProxyRunning: boolean;
|
||||
isProxyTakeover: boolean;
|
||||
// 故障转移相关
|
||||
isAutoFailoverEnabled: boolean;
|
||||
failoverPriority?: number;
|
||||
isInFailoverQueue: boolean;
|
||||
onToggleFailover: (enabled: boolean) => void;
|
||||
activeProviderId?: string;
|
||||
}
|
||||
|
||||
function SortableProviderCard({
|
||||
@@ -271,6 +337,11 @@ function SortableProviderCard({
|
||||
isTesting,
|
||||
isProxyRunning,
|
||||
isProxyTakeover,
|
||||
isAutoFailoverEnabled,
|
||||
failoverPriority,
|
||||
isInFailoverQueue,
|
||||
onToggleFailover,
|
||||
activeProviderId,
|
||||
}: SortableProviderCardProps) {
|
||||
const {
|
||||
setNodeRef,
|
||||
@@ -309,6 +380,12 @@ function SortableProviderCard({
|
||||
listeners,
|
||||
isDragging,
|
||||
}}
|
||||
// 故障转移相关
|
||||
isAutoFailoverEnabled={isAutoFailoverEnabled}
|
||||
failoverPriority={failoverPriority}
|
||||
isInFailoverQueue={isInFailoverQueue}
|
||||
onToggleFailover={onToggleFailover}
|
||||
activeProviderId={activeProviderId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -12,14 +12,14 @@ import {
|
||||
} from "@/lib/query/failover";
|
||||
|
||||
export interface AutoFailoverConfigPanelProps {
|
||||
enabled: boolean;
|
||||
onEnabledChange: (enabled: boolean) => void;
|
||||
enabled?: boolean;
|
||||
onEnabledChange?: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export function AutoFailoverConfigPanel({
|
||||
enabled,
|
||||
enabled = true,
|
||||
onEnabledChange: _onEnabledChange,
|
||||
}: AutoFailoverConfigPanelProps) {
|
||||
}: AutoFailoverConfigPanelProps = {}) {
|
||||
// Note: onEnabledChange is currently unused but kept in the interface
|
||||
// for potential future use by parent components
|
||||
void _onEnabledChange;
|
||||
|
||||
@@ -2,37 +2,14 @@
|
||||
* 故障转移队列管理组件
|
||||
*
|
||||
* 允许用户管理代理模式下的故障转移队列,支持:
|
||||
* - 拖拽排序
|
||||
* - 添加/移除供应商
|
||||
* - 启用/禁用队列项
|
||||
* - 队列顺序基于首页供应商列表的 sort_index
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { DndContext, closestCenter } from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import {
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
GripVertical,
|
||||
Plus,
|
||||
Trash2,
|
||||
Loader2,
|
||||
Info,
|
||||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
import { Plus, Trash2, Loader2, Info, AlertTriangle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
@@ -51,8 +28,8 @@ import {
|
||||
useAvailableProvidersForFailover,
|
||||
useAddToFailoverQueue,
|
||||
useRemoveFromFailoverQueue,
|
||||
useReorderFailoverQueue,
|
||||
useSetFailoverItemEnabled,
|
||||
useAutoFailoverEnabled,
|
||||
useSetAutoFailoverEnabled,
|
||||
} from "@/lib/query/failover";
|
||||
|
||||
interface FailoverQueueManagerProps {
|
||||
@@ -67,6 +44,10 @@ export function FailoverQueueManager({
|
||||
const { t } = useTranslation();
|
||||
const [selectedProviderId, setSelectedProviderId] = useState<string>("");
|
||||
|
||||
// 故障转移开关状态(每个应用独立)
|
||||
const { data: isFailoverEnabled = false } = useAutoFailoverEnabled(appType);
|
||||
const setFailoverEnabled = useSetAutoFailoverEnabled();
|
||||
|
||||
// 查询数据
|
||||
const {
|
||||
data: queue,
|
||||
@@ -79,59 +60,11 @@ export function FailoverQueueManager({
|
||||
// Mutations
|
||||
const addToQueue = useAddToFailoverQueue();
|
||||
const removeFromQueue = useRemoveFromFailoverQueue();
|
||||
const reorderQueue = useReorderFailoverQueue();
|
||||
const setItemEnabled = useSetFailoverItemEnabled();
|
||||
|
||||
// 拖拽配置
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 8 },
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
|
||||
// 排序后的队列
|
||||
const sortedQueue = useMemo(() => {
|
||||
if (!queue) return [];
|
||||
return [...queue].sort((a, b) => a.queueOrder - b.queueOrder);
|
||||
}, [queue]);
|
||||
|
||||
// 处理拖拽结束
|
||||
const handleDragEnd = useCallback(
|
||||
async (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id || !sortedQueue) return;
|
||||
|
||||
const oldIndex = sortedQueue.findIndex(
|
||||
(item) => item.providerId === active.id,
|
||||
);
|
||||
const newIndex = sortedQueue.findIndex(
|
||||
(item) => item.providerId === over.id,
|
||||
);
|
||||
|
||||
if (oldIndex === -1 || newIndex === -1) return;
|
||||
|
||||
const reordered = arrayMove(sortedQueue, oldIndex, newIndex);
|
||||
const providerIds = reordered.map((item) => item.providerId);
|
||||
|
||||
try {
|
||||
await reorderQueue.mutateAsync({ appType, providerIds });
|
||||
toast.success(
|
||||
t("proxy.failoverQueue.reorderSuccess", "队列顺序已更新"),
|
||||
{ closeButton: true },
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
t("proxy.failoverQueue.reorderFailed", "更新顺序失败") +
|
||||
": " +
|
||||
String(error),
|
||||
);
|
||||
}
|
||||
},
|
||||
[sortedQueue, appType, reorderQueue, t],
|
||||
);
|
||||
// 切换故障转移开关
|
||||
const handleToggleFailover = (enabled: boolean) => {
|
||||
setFailoverEnabled.mutate({ appType, enabled });
|
||||
};
|
||||
|
||||
// 添加供应商到队列
|
||||
const handleAddProvider = async () => {
|
||||
@@ -171,19 +104,6 @@ export function FailoverQueueManager({
|
||||
}
|
||||
};
|
||||
|
||||
// 切换启用状态
|
||||
const handleToggleEnabled = async (providerId: string, enabled: boolean) => {
|
||||
try {
|
||||
await setItemEnabled.mutateAsync({ appType, providerId, enabled });
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
t("proxy.failoverQueue.toggleFailed", "状态更新失败") +
|
||||
": " +
|
||||
String(error),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (isQueueLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
@@ -203,13 +123,41 @@ export function FailoverQueueManager({
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 自动故障转移开关 */}
|
||||
<div className="flex items-center justify-between p-4 rounded-lg bg-muted/50 border border-border/50">
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
{t("proxy.failover.autoSwitch", {
|
||||
defaultValue: "自动故障转移",
|
||||
})}
|
||||
</span>
|
||||
{isFailoverEnabled && (
|
||||
<span className="px-2 py-0.5 text-xs rounded-full bg-emerald-500/20 text-emerald-600 dark:text-emerald-400">
|
||||
{t("common.enabled", { defaultValue: "已开启" })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("proxy.failover.autoSwitchDescription", {
|
||||
defaultValue: "开启后,请求失败时自动切换到队列中的下一个供应商",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={isFailoverEnabled}
|
||||
onCheckedChange={handleToggleFailover}
|
||||
disabled={disabled || setFailoverEnabled.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 说明信息 */}
|
||||
<Alert className="border-blue-500/40 bg-blue-500/10">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription className="text-sm">
|
||||
{t(
|
||||
"proxy.failoverQueue.info",
|
||||
"当前激活的供应商始终优先。当请求失败时,系统会按队列顺序依次尝试其他供应商。",
|
||||
"队列顺序与首页供应商列表顺序一致。当请求失败时,系统会按顺序依次尝试队列中的供应商。",
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
@@ -260,7 +208,7 @@ export function FailoverQueueManager({
|
||||
</div>
|
||||
|
||||
{/* 队列列表 */}
|
||||
{sortedQueue.length === 0 ? (
|
||||
{!queue || queue.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-muted-foreground/40 p-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
@@ -270,39 +218,26 @@ export function FailoverQueueManager({
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={sortedQueue.map((item) => item.providerId)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{sortedQueue.map((item, index) => (
|
||||
<SortableQueueItem
|
||||
key={item.providerId}
|
||||
item={item}
|
||||
index={index}
|
||||
disabled={disabled}
|
||||
onToggleEnabled={handleToggleEnabled}
|
||||
onRemove={handleRemoveProvider}
|
||||
isRemoving={removeFromQueue.isPending}
|
||||
isToggling={setItemEnabled.isPending}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
<div className="space-y-2">
|
||||
{queue.map((item, index) => (
|
||||
<QueueItem
|
||||
key={item.providerId}
|
||||
item={item}
|
||||
index={index}
|
||||
disabled={disabled}
|
||||
onRemove={handleRemoveProvider}
|
||||
isRemoving={removeFromQueue.isPending}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 队列说明 */}
|
||||
{sortedQueue.length > 0 && (
|
||||
{queue && queue.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"proxy.failoverQueue.dragHint",
|
||||
"拖拽供应商可调整故障转移顺序,序号越小优先级越高。",
|
||||
"proxy.failoverQueue.orderHint",
|
||||
"队列顺序与首页供应商列表顺序一致,可在首页拖拽调整顺序。",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
@@ -310,65 +245,29 @@ export function FailoverQueueManager({
|
||||
);
|
||||
}
|
||||
|
||||
interface SortableQueueItemProps {
|
||||
interface QueueItemProps {
|
||||
item: FailoverQueueItem;
|
||||
index: number;
|
||||
disabled: boolean;
|
||||
onToggleEnabled: (providerId: string, enabled: boolean) => void;
|
||||
onRemove: (providerId: string) => void;
|
||||
isRemoving: boolean;
|
||||
isToggling: boolean;
|
||||
}
|
||||
|
||||
function SortableQueueItem({
|
||||
function QueueItem({
|
||||
item,
|
||||
index,
|
||||
disabled,
|
||||
onToggleEnabled,
|
||||
onRemove,
|
||||
isRemoving,
|
||||
isToggling,
|
||||
}: SortableQueueItemProps) {
|
||||
}: QueueItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
setNodeRef,
|
||||
attributes,
|
||||
listeners,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: item.providerId, disabled });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg border bg-card p-3 transition-colors",
|
||||
isDragging && "opacity-50 shadow-lg",
|
||||
!item.enabled && "opacity-60",
|
||||
)}
|
||||
>
|
||||
{/* 拖拽手柄 */}
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"cursor-grab touch-none text-muted-foreground hover:text-foreground",
|
||||
disabled && "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
disabled={disabled}
|
||||
aria-label={t("provider.dragHandle", "拖拽排序")}
|
||||
>
|
||||
<GripVertical className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
{/* 序号 */}
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-muted text-xs font-medium">
|
||||
{index + 1}
|
||||
@@ -376,24 +275,11 @@ function SortableQueueItem({
|
||||
|
||||
{/* 供应商名称 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-medium truncate block",
|
||||
!item.enabled && "text-muted-foreground line-through",
|
||||
)}
|
||||
>
|
||||
<span className="text-sm font-medium truncate block">
|
||||
{item.providerName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 启用开关 */}
|
||||
<Switch
|
||||
checked={item.enabled}
|
||||
onCheckedChange={(checked) => onToggleEnabled(item.providerId, checked)}
|
||||
disabled={disabled || isToggling}
|
||||
aria-label={t("proxy.failoverQueue.toggleEnabled", "启用/禁用")}
|
||||
/>
|
||||
|
||||
{/* 删除按钮 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -147,13 +147,10 @@ export function ProxyPanel() {
|
||||
<ProviderQueueGroup
|
||||
appType="claude"
|
||||
appLabel="Claude"
|
||||
targets={claudeQueue
|
||||
.filter((item) => item.enabled)
|
||||
.sort((a, b) => a.queueOrder - b.queueOrder)
|
||||
.map((item) => ({
|
||||
id: item.providerId,
|
||||
name: item.providerName,
|
||||
}))}
|
||||
targets={claudeQueue.map((item) => ({
|
||||
id: item.providerId,
|
||||
name: item.providerName,
|
||||
}))}
|
||||
status={status}
|
||||
/>
|
||||
)}
|
||||
@@ -163,13 +160,10 @@ export function ProxyPanel() {
|
||||
<ProviderQueueGroup
|
||||
appType="codex"
|
||||
appLabel="Codex"
|
||||
targets={codexQueue
|
||||
.filter((item) => item.enabled)
|
||||
.sort((a, b) => a.queueOrder - b.queueOrder)
|
||||
.map((item) => ({
|
||||
id: item.providerId,
|
||||
name: item.providerName,
|
||||
}))}
|
||||
targets={codexQueue.map((item) => ({
|
||||
id: item.providerId,
|
||||
name: item.providerName,
|
||||
}))}
|
||||
status={status}
|
||||
/>
|
||||
)}
|
||||
@@ -179,13 +173,10 @@ export function ProxyPanel() {
|
||||
<ProviderQueueGroup
|
||||
appType="gemini"
|
||||
appLabel="Gemini"
|
||||
targets={geminiQueue
|
||||
.filter((item) => item.enabled)
|
||||
.sort((a, b) => a.queueOrder - b.queueOrder)
|
||||
.map((item) => ({
|
||||
id: item.providerId,
|
||||
name: item.providerName,
|
||||
}))}
|
||||
targets={geminiQueue.map((item) => ({
|
||||
id: item.providerId,
|
||||
name: item.providerName,
|
||||
}))}
|
||||
status={status}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -47,10 +47,6 @@ import type { SettingsFormState } from "@/hooks/useSettings";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useProxyStatus } from "@/hooks/useProxyStatus";
|
||||
import {
|
||||
useAutoFailoverEnabled,
|
||||
useSetAutoFailoverEnabled,
|
||||
} from "@/lib/query/failover";
|
||||
|
||||
interface SettingsDialogProps {
|
||||
open: boolean;
|
||||
@@ -187,17 +183,9 @@ export function SettingsPage({
|
||||
isPending: isProxyPending,
|
||||
} = useProxyStatus();
|
||||
|
||||
// 使用持久化的自动故障转移开关状态
|
||||
const { data: failoverEnabled = false } = useAutoFailoverEnabled();
|
||||
const setAutoFailoverEnabled = useSetAutoFailoverEnabled();
|
||||
|
||||
const handleToggleProxy = async (checked: boolean) => {
|
||||
try {
|
||||
if (!checked) {
|
||||
// 关闭代理时,同时关闭故障转移
|
||||
if (failoverEnabled) {
|
||||
setAutoFailoverEnabled.mutate(false);
|
||||
}
|
||||
await stopWithRestore();
|
||||
} else {
|
||||
await startProxyServer();
|
||||
@@ -207,19 +195,6 @@ export function SettingsPage({
|
||||
}
|
||||
};
|
||||
|
||||
// 处理故障转移开关:开启时自动启动代理
|
||||
const handleToggleFailover = async (checked: boolean) => {
|
||||
try {
|
||||
if (checked && !isRunning) {
|
||||
// 开启故障转移时,先启动代理
|
||||
await startProxyServer();
|
||||
}
|
||||
setAutoFailoverEnabled.mutate(checked);
|
||||
} catch (error) {
|
||||
console.error("Toggle failover failed:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[56rem] flex flex-col h-[calc(100vh-8rem)] overflow-hidden px-6">
|
||||
{isBusy ? (
|
||||
@@ -380,37 +355,36 @@ export function SettingsPage({
|
||||
|
||||
<AccordionItem
|
||||
value="failover"
|
||||
className="rounded-xl glass-card overflow-hidden [&[data-state=open]>.accordion-header]:bg-muted/50"
|
||||
className="rounded-xl glass-card overflow-hidden"
|
||||
>
|
||||
<AccordionPrimitive.Header className="accordion-header flex items-center justify-between px-6 py-4 hover:bg-muted/50">
|
||||
<AccordionPrimitive.Trigger className="flex flex-1 items-center justify-between hover:no-underline [&[data-state=open]>svg]:rotate-180">
|
||||
<div className="flex items-center gap-3">
|
||||
<Activity className="h-5 w-5 text-orange-500" />
|
||||
<div className="text-left">
|
||||
<h3 className="text-base font-semibold">
|
||||
{t("settings.advanced.failover.title")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground font-normal">
|
||||
{t("settings.advanced.failover.description")}
|
||||
</p>
|
||||
</div>
|
||||
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<Activity className="h-5 w-5 text-orange-500" />
|
||||
<div className="text-left">
|
||||
<h3 className="text-base font-semibold">
|
||||
{t("settings.advanced.failover.title")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground font-normal">
|
||||
{t("settings.advanced.failover.description")}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
|
||||
<div className="flex items-center gap-2 pl-4">
|
||||
<Switch
|
||||
checked={failoverEnabled && isRunning}
|
||||
onCheckedChange={handleToggleFailover}
|
||||
disabled={
|
||||
setAutoFailoverEnabled.isPending || isProxyPending
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</AccordionPrimitive.Header>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
||||
<div className="space-y-6">
|
||||
{/* 故障转移队列管理 */}
|
||||
{/* 代理未运行时的提示 */}
|
||||
{!isRunning && (
|
||||
<div className="p-4 rounded-lg bg-yellow-500/10 border border-yellow-500/20">
|
||||
<p className="text-sm text-yellow-600 dark:text-yellow-400">
|
||||
{t("proxy.failover.proxyRequired", {
|
||||
defaultValue:
|
||||
"需要先启动代理服务才能配置故障转移",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 故障转移队列管理 - 每个应用独立 */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold">
|
||||
@@ -429,30 +403,27 @@ export function SettingsPage({
|
||||
<TabsContent value="claude" className="mt-4">
|
||||
<FailoverQueueManager
|
||||
appType="claude"
|
||||
disabled={!failoverEnabled || !isRunning}
|
||||
disabled={!isRunning}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="codex" className="mt-4">
|
||||
<FailoverQueueManager
|
||||
appType="codex"
|
||||
disabled={!failoverEnabled || !isRunning}
|
||||
disabled={!isRunning}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="gemini" className="mt-4">
|
||||
<FailoverQueueManager
|
||||
appType="gemini"
|
||||
disabled={!failoverEnabled || !isRunning}
|
||||
disabled={!isRunning}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* 熔断器配置 */}
|
||||
{/* 熔断器配置 - 全局共享 */}
|
||||
<div className="border-t border-border/50 pt-6">
|
||||
<AutoFailoverConfigPanel
|
||||
enabled={failoverEnabled && isRunning}
|
||||
onEnabledChange={handleToggleFailover}
|
||||
/>
|
||||
<AutoFailoverConfigPanel />
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
|
||||
@@ -75,6 +75,11 @@ export function useDragSort(providers: Record<string, Provider>, appId: AppId) {
|
||||
queryKey: ["providers", appId],
|
||||
});
|
||||
|
||||
// 刷新故障转移队列(因为队列顺序依赖 sort_index)
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["failoverQueue", appId],
|
||||
});
|
||||
|
||||
// 更新托盘菜单以反映新的排序(失败不影响主操作)
|
||||
try {
|
||||
await providersApi.updateTrayMenu();
|
||||
|
||||
@@ -73,8 +73,11 @@ export function useProxyStatus() {
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["proxyTakeoverStatus"] });
|
||||
// 清除所有供应商健康状态缓存(后端已清空数据库记录)
|
||||
queryClient.invalidateQueries({ queryKey: ["providerHealth"] });
|
||||
// 彻底删除所有供应商健康状态缓存(后端已清空数据库记录)
|
||||
queryClient.removeQueries({ queryKey: ["providerHealth"] });
|
||||
// 彻底删除所有熔断器统计缓存(代理停止后熔断器状态已重置)
|
||||
queryClient.removeQueries({ queryKey: ["circuitBreakerStats"] });
|
||||
// 注意:故障转移队列和开关状态会保留,不需要刷新
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
const detail =
|
||||
|
||||
@@ -6,10 +6,12 @@ import {
|
||||
type StreamCheckResult,
|
||||
} from "@/lib/api/model-test";
|
||||
import type { AppId } from "@/lib/api";
|
||||
import { useResetCircuitBreaker } from "@/lib/query/failover";
|
||||
|
||||
export function useStreamCheck(appId: AppId) {
|
||||
const { t } = useTranslation();
|
||||
const [checkingIds, setCheckingIds] = useState<Set<string>>(new Set());
|
||||
const resetCircuitBreaker = useResetCircuitBreaker();
|
||||
|
||||
const checkProvider = useCallback(
|
||||
async (
|
||||
@@ -30,6 +32,9 @@ export function useStreamCheck(appId: AppId) {
|
||||
}),
|
||||
{ closeButton: true },
|
||||
);
|
||||
|
||||
// 测试通过后重置熔断器状态
|
||||
resetCircuitBreaker.mutate({ providerId, appType: appId });
|
||||
} else if (result.status === "degraded") {
|
||||
toast.warning(
|
||||
t("streamCheck.degraded", {
|
||||
@@ -38,6 +43,9 @@ export function useStreamCheck(appId: AppId) {
|
||||
defaultValue: `${providerName} 响应较慢 (${result.responseTimeMs}ms)`,
|
||||
}),
|
||||
);
|
||||
|
||||
// 降级状态也重置熔断器,因为至少能通信
|
||||
resetCircuitBreaker.mutate({ providerId, appType: appId });
|
||||
} else {
|
||||
toast.error(
|
||||
t("streamCheck.failed", {
|
||||
@@ -66,7 +74,7 @@ export function useStreamCheck(appId: AppId) {
|
||||
});
|
||||
}
|
||||
},
|
||||
[appId, t],
|
||||
[appId, t, resetCircuitBreaker],
|
||||
);
|
||||
|
||||
const isChecking = useCallback(
|
||||
|
||||
@@ -84,34 +84,16 @@ export const failoverApi = {
|
||||
return invoke("remove_from_failover_queue", { appType, providerId });
|
||||
},
|
||||
|
||||
// 重新排序故障转移队列
|
||||
async reorderFailoverQueue(
|
||||
appType: string,
|
||||
providerIds: string[],
|
||||
): Promise<void> {
|
||||
return invoke("reorder_failover_queue", { appType, providerIds });
|
||||
// 获取指定应用的自动故障转移开关状态
|
||||
async getAutoFailoverEnabled(appType: string): Promise<boolean> {
|
||||
return invoke("get_auto_failover_enabled", { appType });
|
||||
},
|
||||
|
||||
// 设置故障转移队列项的启用状态
|
||||
async setFailoverItemEnabled(
|
||||
// 设置指定应用的自动故障转移开关状态
|
||||
async setAutoFailoverEnabled(
|
||||
appType: string,
|
||||
providerId: string,
|
||||
enabled: boolean,
|
||||
): Promise<void> {
|
||||
return invoke("set_failover_item_enabled", {
|
||||
appType,
|
||||
providerId,
|
||||
enabled,
|
||||
});
|
||||
},
|
||||
|
||||
// 获取自动故障转移总开关状态
|
||||
async getAutoFailoverEnabled(): Promise<boolean> {
|
||||
return invoke("get_auto_failover_enabled");
|
||||
},
|
||||
|
||||
// 设置自动故障转移总开关状态
|
||||
async setAutoFailoverEnabled(enabled: boolean): Promise<void> {
|
||||
return invoke("set_auto_failover_enabled", { enabled });
|
||||
return invoke("set_auto_failover_enabled", { appType, enabled });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -18,6 +18,9 @@ export function useProviderHealth(providerId: string, appType: string) {
|
||||
|
||||
/**
|
||||
* 重置熔断器
|
||||
*
|
||||
* 重置后后端会检查是否应该切回优先级更高的供应商,
|
||||
* 因此需要同时刷新供应商列表和代理状态。
|
||||
*/
|
||||
export function useResetCircuitBreaker() {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -35,6 +38,14 @@ export function useResetCircuitBreaker() {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["providerHealth", variables.providerId, variables.appType],
|
||||
});
|
||||
// 刷新供应商列表(因为可能发生了自动恢复切换)
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["providers", variables.appType],
|
||||
});
|
||||
// 刷新代理状态(更新 active_targets)
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["proxyStatus"],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -120,6 +131,9 @@ export function useAddToFailoverQueue() {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["availableProvidersForFailover", variables.appType],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["providers", variables.appType],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -145,146 +159,79 @@ export function useRemoveFromFailoverQueue() {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["availableProvidersForFailover", variables.appType],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新排序故障转移队列
|
||||
*/
|
||||
export function useReorderFailoverQueue() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
appType,
|
||||
providerIds,
|
||||
}: {
|
||||
appType: string;
|
||||
providerIds: string[];
|
||||
}) => failoverApi.reorderFailoverQueue(appType, providerIds),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["failoverQueue", variables.appType],
|
||||
queryKey: ["providers", variables.appType],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置故障转移队列项的启用状态
|
||||
* 使用乐观更新(Optimistic Update)以提供即时反馈
|
||||
*/
|
||||
export function useSetFailoverItemEnabled() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
appType,
|
||||
providerId,
|
||||
enabled,
|
||||
}: {
|
||||
appType: string;
|
||||
providerId: string;
|
||||
enabled: boolean;
|
||||
}) => failoverApi.setFailoverItemEnabled(appType, providerId, enabled),
|
||||
|
||||
// 乐观更新:立即更新缓存中的数据
|
||||
onMutate: async (variables) => {
|
||||
// 取消正在进行的查询,防止覆盖乐观更新
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: ["failoverQueue", variables.appType],
|
||||
});
|
||||
|
||||
// 保存之前的数据以便回滚
|
||||
const previousQueue = queryClient.getQueryData<
|
||||
import("@/types/proxy").FailoverQueueItem[]
|
||||
>(["failoverQueue", variables.appType]);
|
||||
|
||||
// 乐观地更新缓存
|
||||
if (previousQueue) {
|
||||
queryClient.setQueryData<import("@/types/proxy").FailoverQueueItem[]>(
|
||||
["failoverQueue", variables.appType],
|
||||
previousQueue.map((item) =>
|
||||
item.providerId === variables.providerId
|
||||
? { ...item, enabled: variables.enabled }
|
||||
: item,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 返回上下文供 onError 使用
|
||||
return { previousQueue };
|
||||
},
|
||||
|
||||
// 错误时回滚
|
||||
onError: (_error, variables, context) => {
|
||||
if (context?.previousQueue) {
|
||||
queryClient.setQueryData(
|
||||
["failoverQueue", variables.appType],
|
||||
context.previousQueue,
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
// 无论成功失败,都重新获取最新数据以确保一致性
|
||||
onSettled: (_, __, variables) => {
|
||||
// 清除该供应商的健康状态缓存(退出队列后不再需要健康监控)
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["failoverQueue", variables.appType],
|
||||
queryKey: ["providerHealth", variables.providerId, variables.appType],
|
||||
});
|
||||
// 清除该供应商的熔断器统计缓存
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [
|
||||
"circuitBreakerStats",
|
||||
variables.providerId,
|
||||
variables.appType,
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ========== 自动故障转移总开关 Hooks ==========
|
||||
// ========== 自动故障转移开关 Hooks ==========
|
||||
|
||||
/**
|
||||
* 获取自动故障转移总开关状态
|
||||
* 获取指定应用的自动故障转移开关状态
|
||||
*/
|
||||
export function useAutoFailoverEnabled() {
|
||||
export function useAutoFailoverEnabled(appType: string) {
|
||||
return useQuery({
|
||||
queryKey: ["autoFailoverEnabled"],
|
||||
queryFn: () => failoverApi.getAutoFailoverEnabled(),
|
||||
queryKey: ["autoFailoverEnabled", appType],
|
||||
queryFn: () => failoverApi.getAutoFailoverEnabled(appType),
|
||||
// 默认值为 false(与后端保持一致)
|
||||
placeholderData: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置自动故障转移总开关状态
|
||||
* 设置指定应用的自动故障转移开关状态
|
||||
*/
|
||||
export function useSetAutoFailoverEnabled() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (enabled: boolean) =>
|
||||
failoverApi.setAutoFailoverEnabled(enabled),
|
||||
mutationFn: ({ appType, enabled }: { appType: string; enabled: boolean }) =>
|
||||
failoverApi.setAutoFailoverEnabled(appType, enabled),
|
||||
|
||||
// 乐观更新
|
||||
onMutate: async (enabled) => {
|
||||
await queryClient.cancelQueries({ queryKey: ["autoFailoverEnabled"] });
|
||||
onMutate: async ({ appType, enabled }) => {
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: ["autoFailoverEnabled", appType],
|
||||
});
|
||||
const previousValue = queryClient.getQueryData<boolean>([
|
||||
"autoFailoverEnabled",
|
||||
appType,
|
||||
]);
|
||||
|
||||
queryClient.setQueryData(["autoFailoverEnabled"], enabled);
|
||||
queryClient.setQueryData(["autoFailoverEnabled", appType], enabled);
|
||||
|
||||
return { previousValue };
|
||||
return { previousValue, appType };
|
||||
},
|
||||
|
||||
// 错误时回滚
|
||||
onError: (_error, _enabled, context) => {
|
||||
onError: (_error, _variables, context) => {
|
||||
if (context?.previousValue !== undefined) {
|
||||
queryClient.setQueryData(
|
||||
["autoFailoverEnabled"],
|
||||
["autoFailoverEnabled", context.appType],
|
||||
context.previousValue,
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
// 无论成功失败,都重新获取
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["autoFailoverEnabled"] });
|
||||
onSettled: (_, __, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["autoFailoverEnabled", variables.appType],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ export interface Provider {
|
||||
// 图标配置
|
||||
icon?: string; // 图标名称(如 "openai", "anthropic")
|
||||
iconColor?: string; // 图标颜色(Hex 格式,如 "#00A67E")
|
||||
// 是否加入故障转移队列
|
||||
inFailoverQueue?: boolean;
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
|
||||
@@ -103,7 +103,5 @@ export interface ProxyUsageRecord {
|
||||
export interface FailoverQueueItem {
|
||||
providerId: string;
|
||||
providerName: string;
|
||||
queueOrder: number;
|
||||
enabled: boolean;
|
||||
createdAt: number;
|
||||
sortIndex?: number;
|
||||
}
|
||||
|
||||
@@ -79,6 +79,22 @@ vi.mock("@dnd-kit/sortable", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
// Mock hooks that use QueryClient
|
||||
vi.mock("@/hooks/useStreamCheck", () => ({
|
||||
useStreamCheck: () => ({
|
||||
checkProvider: vi.fn(),
|
||||
isChecking: () => false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/query/failover", () => ({
|
||||
useAutoFailoverEnabled: () => ({ data: false }),
|
||||
useFailoverQueue: () => ({ data: [] }),
|
||||
useAddToFailoverQueue: () => ({ mutate: vi.fn() }),
|
||||
useRemoveFromFailoverQueue: () => ({ mutate: vi.fn() }),
|
||||
useReorderFailoverQueue: () => ({ mutate: vi.fn() }),
|
||||
}));
|
||||
|
||||
function createProvider(overrides: Partial<Provider> = {}): Provider {
|
||||
return {
|
||||
id: overrides.id ?? "provider-1",
|
||||
|
||||
Reference in New Issue
Block a user