fix: 修复测试中缺少 models 字段 + 更新 GitHub Actions 权限

This commit is contained in:
coso
2026-01-04 22:23:42 +08:00
parent 123cee76f4
commit 14fb5aa323
20 changed files with 1000 additions and 1767 deletions
+2
View File
@@ -13,6 +13,8 @@ on:
permissions:
contents: write
packages: write
actions: read
env:
CARGO_INCREMENTAL: 0
+10
View File
@@ -814,6 +814,16 @@ pub fn run() {
commands::native_agent_cmd::native_agent_get_session,
commands::native_agent_cmd::native_agent_delete_session,
commands::native_agent_cmd::native_agent_list_sessions,
// Models config commands
commands::models_cmd::get_models_config,
commands::models_cmd::save_models_config,
commands::models_cmd::get_provider_models,
commands::models_cmd::get_all_provider_models,
commands::models_cmd::add_model_to_provider,
commands::models_cmd::remove_model_from_provider,
commands::models_cmd::toggle_model_enabled,
commands::models_cmd::add_provider,
commands::models_cmd::remove_provider,
// Network commands
commands::network_cmd::get_network_info,
// OAuth Plugin commands
+1
View File
@@ -8,6 +8,7 @@ pub mod injection_cmd;
pub mod kiro_local;
pub mod machine_id_cmd;
pub mod mcp_cmd;
pub mod models_cmd;
pub mod native_agent_cmd;
pub mod network_cmd;
pub mod oauth_cmd;
+200
View File
@@ -0,0 +1,200 @@
//! 模型配置命令模块
//!
//! 提供动态模型配置的 Tauri 命令
use crate::config::{save_config, ModelInfo, ModelsConfig, ProviderModelsConfig};
use crate::AppState;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use tauri::State;
/// 获取模型配置
#[tauri::command]
pub async fn get_models_config(app_state: State<'_, AppState>) -> Result<ModelsConfig, String> {
let state = app_state.read().await;
Ok(state.config.models.clone())
}
/// 保存模型配置
#[tauri::command]
pub async fn save_models_config(
app_state: State<'_, AppState>,
config: ModelsConfig,
) -> Result<(), String> {
let mut state = app_state.write().await;
state.config.models = config;
// 保存配置到文件
save_config(&state.config).map_err(|e| e.to_string())?;
Ok(())
}
/// 获取指定 Provider 的模型列表
#[tauri::command]
pub async fn get_provider_models(
app_state: State<'_, AppState>,
provider: String,
) -> Result<Vec<String>, String> {
let state = app_state.read().await;
let models = state
.config
.models
.providers
.get(&provider)
.map(|p| {
p.models
.iter()
.filter(|m| m.enabled)
.map(|m| m.id.clone())
.collect()
})
.unwrap_or_default();
Ok(models)
}
/// 简化的 Provider 配置(用于前端)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SimpleProviderConfig {
pub label: String,
pub models: Vec<String>,
}
/// 获取所有 Provider 的简化配置(用于前端下拉框)
#[tauri::command]
pub async fn get_all_provider_models(
app_state: State<'_, AppState>,
) -> Result<HashMap<String, SimpleProviderConfig>, String> {
let state = app_state.read().await;
let result: HashMap<String, SimpleProviderConfig> = state
.config
.models
.providers
.iter()
.map(|(key, value)| {
(
key.clone(),
SimpleProviderConfig {
label: value.label.clone(),
models: value
.models
.iter()
.filter(|m| m.enabled)
.map(|m| m.id.clone())
.collect(),
},
)
})
.collect();
Ok(result)
}
/// 添加模型到指定 Provider
#[tauri::command]
pub async fn add_model_to_provider(
app_state: State<'_, AppState>,
provider: String,
model_id: String,
model_name: Option<String>,
) -> Result<(), String> {
let mut state = app_state.write().await;
if let Some(provider_config) = state.config.models.providers.get_mut(&provider) {
// 检查是否已存在
if provider_config.models.iter().any(|m| m.id == model_id) {
return Err(format!("模型 {} 已存在于 {} 中", model_id, provider));
}
provider_config.models.push(ModelInfo {
id: model_id,
name: model_name,
enabled: true,
});
} else {
return Err(format!("Provider {} 不存在", provider));
}
save_config(&state.config).map_err(|e| e.to_string())?;
Ok(())
}
/// 从指定 Provider 移除模型
#[tauri::command]
pub async fn remove_model_from_provider(
app_state: State<'_, AppState>,
provider: String,
model_id: String,
) -> Result<(), String> {
let mut state = app_state.write().await;
if let Some(provider_config) = state.config.models.providers.get_mut(&provider) {
provider_config.models.retain(|m| m.id != model_id);
} else {
return Err(format!("Provider {} 不存在", provider));
}
save_config(&state.config).map_err(|e| e.to_string())?;
Ok(())
}
/// 切换模型启用状态
#[tauri::command]
pub async fn toggle_model_enabled(
app_state: State<'_, AppState>,
provider: String,
model_id: String,
enabled: bool,
) -> Result<(), String> {
let mut state = app_state.write().await;
if let Some(provider_config) = state.config.models.providers.get_mut(&provider) {
if let Some(model) = provider_config.models.iter_mut().find(|m| m.id == model_id) {
model.enabled = enabled;
} else {
return Err(format!("模型 {} 不存在于 {} 中", model_id, provider));
}
} else {
return Err(format!("Provider {} 不存在", provider));
}
save_config(&state.config).map_err(|e| e.to_string())?;
Ok(())
}
/// 添加新的 Provider
#[tauri::command]
pub async fn add_provider(
app_state: State<'_, AppState>,
provider_id: String,
label: String,
) -> Result<(), String> {
let mut state = app_state.write().await;
if state.config.models.providers.contains_key(&provider_id) {
return Err(format!("Provider {} 已存在", provider_id));
}
state.config.models.providers.insert(
provider_id,
ProviderModelsConfig {
label,
models: vec![],
},
);
save_config(&state.config).map_err(|e| e.to_string())?;
Ok(())
}
/// 移除 Provider
#[tauri::command]
pub async fn remove_provider(
app_state: State<'_, AppState>,
provider_id: String,
) -> Result<(), String> {
let mut state = app_state.write().await;
if state.config.models.providers.remove(&provider_id).is_none() {
return Err(format!("Provider {} 不存在", provider_id));
}
save_config(&state.config).map_err(|e| e.to_string())?;
Ok(())
}
+4 -3
View File
@@ -21,9 +21,10 @@ pub use path_utils::{collapse_tilde, contains_tilde, expand_tilde};
pub use types::{
generate_secure_api_key, AmpConfig, AmpModelMapping, ApiKeyEntry, Config, CredentialEntry,
CredentialPoolConfig, CustomProviderConfig, EndpointProvidersConfig, GeminiApiKeyEntry,
IFlowCredentialEntry, InjectionRuleConfig, InjectionSettings, LoggingConfig, ProviderConfig,
ProvidersConfig, QuotaExceededConfig, RemoteManagementConfig, RetrySettings, RoutingConfig,
ServerConfig, TlsConfig, VertexApiKeyEntry, VertexModelAlias, DEFAULT_API_KEY,
IFlowCredentialEntry, InjectionRuleConfig, InjectionSettings, LoggingConfig, ModelInfo,
ModelsConfig, ProviderConfig, ProviderModelsConfig, ProvidersConfig, QuotaExceededConfig,
RemoteManagementConfig, RetrySettings, RoutingConfig, RoutingRuleConfig, ServerConfig,
TlsConfig, VertexApiKeyEntry, VertexModelAlias, DEFAULT_API_KEY,
};
pub use yaml::{load_config, save_config, ConfigError, ConfigManager, YamlService};
+3
View File
@@ -219,6 +219,7 @@ fn arb_config() -> impl Strategy<Value = Config> {
ampcode: crate::config::AmpConfig::default(),
endpoint_providers: crate::config::EndpointProvidersConfig::default(),
minimize_to_tray: true,
models: crate::config::ModelsConfig::default(),
})
}
@@ -492,6 +493,7 @@ fn arb_valid_config() -> impl Strategy<Value = Config> {
ampcode: crate::config::AmpConfig::default(),
endpoint_providers: crate::config::EndpointProvidersConfig::default(),
minimize_to_tray: true,
models: crate::config::ModelsConfig::default(),
})
}
@@ -537,6 +539,7 @@ fn arb_invalid_config() -> impl Strategy<Value = Config> {
ampcode: crate::config::AmpConfig::default(),
endpoint_providers: crate::config::EndpointProvidersConfig::default(),
minimize_to_tray: true,
models: crate::config::ModelsConfig::default(),
};
// 根据类型使配置无效
match invalid_type {
+315
View File
@@ -311,6 +311,9 @@ pub struct Config {
/// 关闭时最小化到托盘(而不是退出应用)
#[serde(default = "default_minimize_to_tray")]
pub minimize_to_tray: bool,
/// 模型配置(动态加载 Provider 和模型列表)
#[serde(default)]
pub models: ModelsConfig,
}
fn default_minimize_to_tray() -> bool {
@@ -712,6 +715,317 @@ impl Default for LoggingConfig {
}
}
// ============ 模型配置类型 ============
/// 模型信息
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ModelInfo {
/// 模型 ID
pub id: String,
/// 模型显示名称(可选)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
/// 是否启用
#[serde(default = "default_model_enabled")]
pub enabled: bool,
}
fn default_model_enabled() -> bool {
true
}
/// Provider 模型配置
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ProviderModelsConfig {
/// Provider 显示标签
pub label: String,
/// 模型列表
#[serde(default)]
pub models: Vec<ModelInfo>,
}
/// 模型配置(顶层)
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ModelsConfig {
/// 是否从 models.dev 获取模型列表(预留功能)
#[serde(default)]
pub fetch_from_models_dev: bool,
/// models.dev 缓存 TTL(秒)
#[serde(default = "default_cache_ttl_secs")]
pub cache_ttl_secs: u64,
/// Provider 模型配置
#[serde(default)]
pub providers: HashMap<String, ProviderModelsConfig>,
}
fn default_cache_ttl_secs() -> u64 {
3600
}
impl Default for ModelsConfig {
fn default() -> Self {
let mut providers = HashMap::new();
// Claude (直连 Anthropic API)
providers.insert(
"claude".to_string(),
ProviderModelsConfig {
label: "Claude".to_string(),
models: vec![
ModelInfo {
id: "claude-opus-4-5-20251101".to_string(),
name: None,
enabled: true,
},
ModelInfo {
id: "claude-sonnet-4-5-20250929".to_string(),
name: None,
enabled: true,
},
ModelInfo {
id: "claude-sonnet-4-20250514".to_string(),
name: None,
enabled: true,
},
],
},
);
// Anthropic (API Key Provider)
providers.insert(
"anthropic".to_string(),
ProviderModelsConfig {
label: "Anthropic".to_string(),
models: vec![
ModelInfo {
id: "claude-opus-4-5-20251101".to_string(),
name: None,
enabled: true,
},
ModelInfo {
id: "claude-sonnet-4-5-20250929".to_string(),
name: None,
enabled: true,
},
ModelInfo {
id: "claude-sonnet-4-20250514".to_string(),
name: None,
enabled: true,
},
],
},
);
// Kiro
providers.insert(
"kiro".to_string(),
ProviderModelsConfig {
label: "Kiro".to_string(),
models: vec![
ModelInfo {
id: "claude-sonnet-4-5-20250929".to_string(),
name: None,
enabled: true,
},
ModelInfo {
id: "claude-sonnet-4-20250514".to_string(),
name: None,
enabled: true,
},
],
},
);
// OpenAI
providers.insert(
"openai".to_string(),
ProviderModelsConfig {
label: "OpenAI".to_string(),
models: vec![
ModelInfo {
id: "gpt-4o".to_string(),
name: None,
enabled: true,
},
ModelInfo {
id: "gpt-4o-mini".to_string(),
name: None,
enabled: true,
},
ModelInfo {
id: "gpt-4-turbo".to_string(),
name: None,
enabled: true,
},
ModelInfo {
id: "o1".to_string(),
name: None,
enabled: true,
},
ModelInfo {
id: "o1-mini".to_string(),
name: None,
enabled: true,
},
ModelInfo {
id: "o3".to_string(),
name: None,
enabled: true,
},
ModelInfo {
id: "o3-mini".to_string(),
name: None,
enabled: true,
},
],
},
);
// Gemini
providers.insert(
"gemini".to_string(),
ProviderModelsConfig {
label: "Gemini".to_string(),
models: vec![
ModelInfo {
id: "gemini-2.0-flash-exp".to_string(),
name: None,
enabled: true,
},
ModelInfo {
id: "gemini-1.5-pro".to_string(),
name: None,
enabled: true,
},
ModelInfo {
id: "gemini-1.5-flash".to_string(),
name: None,
enabled: true,
},
],
},
);
// Qwen
providers.insert(
"qwen".to_string(),
ProviderModelsConfig {
label: "通义千问".to_string(),
models: vec![
ModelInfo {
id: "qwen-max".to_string(),
name: None,
enabled: true,
},
ModelInfo {
id: "qwen-plus".to_string(),
name: None,
enabled: true,
},
ModelInfo {
id: "qwen-turbo".to_string(),
name: None,
enabled: true,
},
],
},
);
// Codex
providers.insert(
"codex".to_string(),
ProviderModelsConfig {
label: "Codex".to_string(),
models: vec![ModelInfo {
id: "codex-mini-latest".to_string(),
name: None,
enabled: true,
}],
},
);
// Claude OAuth
providers.insert(
"claude_oauth".to_string(),
ProviderModelsConfig {
label: "Claude OAuth".to_string(),
models: vec![
ModelInfo {
id: "claude-sonnet-4-5-20250929".to_string(),
name: None,
enabled: true,
},
ModelInfo {
id: "claude-3-5-sonnet-20241022".to_string(),
name: None,
enabled: true,
},
],
},
);
// iFlow
providers.insert(
"iflow".to_string(),
ProviderModelsConfig {
label: "iFlow".to_string(),
models: vec![],
},
);
// Antigravity
providers.insert(
"antigravity".to_string(),
ProviderModelsConfig {
label: "Antigravity".to_string(),
models: vec![
ModelInfo {
id: "gemini-3-pro-preview".to_string(),
name: None,
enabled: true,
},
ModelInfo {
id: "gemini-3-pro-image-preview".to_string(),
name: None,
enabled: true,
},
ModelInfo {
id: "gemini-3-flash-preview".to_string(),
name: None,
enabled: true,
},
ModelInfo {
id: "gemini-2.5-computer-use-preview-10-2025".to_string(),
name: None,
enabled: true,
},
ModelInfo {
id: "gemini-claude-sonnet-4-5".to_string(),
name: None,
enabled: true,
},
ModelInfo {
id: "gemini-claude-sonnet-4-5-thinking".to_string(),
name: None,
enabled: true,
},
ModelInfo {
id: "gemini-claude-opus-4-5-thinking".to_string(),
name: None,
enabled: true,
},
],
},
);
Self {
fetch_from_models_dev: false,
cache_ttl_secs: default_cache_ttl_secs(),
providers,
}
}
}
/// 参数注入配置
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct InjectionSettings {
@@ -801,6 +1115,7 @@ impl Default for Config {
ampcode: AmpConfig::default(),
endpoint_providers: EndpointProvidersConfig::default(),
minimize_to_tray: default_minimize_to_tray(),
models: ModelsConfig::default(),
}
}
}
+92 -260
View File
@@ -780,297 +780,129 @@ pub async fn call_provider_anthropic(
}
// Anthropic API Key - 根据 base_url 决定调用方式
CredentialData::AnthropicKey { api_key, base_url } => {
// 如果有自定义 base_url,假设是 OpenAI 兼容的代理服务器
// 需要将 Anthropic 请求转换为 OpenAI 请求,然后将响应转换回来
if let Some(custom_url) = base_url {
state.logs.write().await.add(
"info",
&format!(
"[ANTHROPIC_COMPAT] 使用 OpenAI 兼容 API: base_url={} credential_uuid={} stream={}",
custom_url,
&credential.uuid[..8],
request.stream
),
);
// 使用 Anthropic 原生格式调用(无论是否有自定义 base_url)
let claude = ClaudeCustomProvider::with_config(api_key.clone(), base_url.clone());
let request_url = claude.get_base_url();
state.logs.write().await.add(
"info",
&format!(
"[ANTHROPIC] 使用 Anthropic API: base_url={} credential_uuid={} stream={}",
request_url,
&credential.uuid[..8],
request.stream
),
);
match claude.call_api(request).await {
Ok(resp) => {
let status = resp.status();
state.logs.write().await.add(
"info",
&format!(
"[ANTHROPIC] 响应状态: status={} model={} stream={}",
status,
request.model,
request.stream
),
);
// 将 Anthropic 请求转换为 OpenAI 请求
let openai_request = crate::converter::anthropic_to_openai::convert_anthropic_to_openai(request);
// 使用 OpenAI 兼容 API 调用
let openai = OpenAICustomProvider::with_config(api_key.clone(), Some(custom_url.clone()));
match openai.call_api(&openai_request).await {
Ok(resp) => {
let status = resp.status();
// 如果是流式请求,直接透传流式响应
if request.stream && status.is_success() {
state.logs.write().await.add(
"info",
&format!(
"[ANTHROPIC_COMPAT] 响应状态: status={} model={} stream={}",
status,
request.model,
request.stream
),
"[ANTHROPIC] 流式请求,透传 SSE 响应",
);
// 流式请求暂不支持格式转换,直接透传 OpenAI SSE 格式
// TODO: 实现 OpenAI SSE -> Anthropic SSE 的流式转换
if request.stream && status.is_success() {
state.logs.write().await.add(
"info",
"[ANTHROPIC_COMPAT] 流式请求,透传 OpenAI SSE 响应(暂不支持格式转换)",
);
if let Some(db) = &state.db {
let _ = state.pool_service.mark_healthy(
db,
&credential.uuid,
Some(&request.model),
);
let _ = state.pool_service.record_usage(db, &credential.uuid);
}
// 直接透传 OpenAI SSE 流
let stream = resp.bytes_stream();
return Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/event-stream")
.header(header::CACHE_CONTROL, "no-cache")
.header("Connection", "keep-alive")
.body(Body::from_stream(stream))
.unwrap_or_else(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": {"message": "Failed to build stream response"}})),
)
.into_response()
});
}
// 非流式响应需要转换格式
if status.is_success() {
if let Some(db) = &state.db {
let _ = state.pool_service.mark_healthy(
db,
&credential.uuid,
Some(&request.model),
);
let _ = state.pool_service.record_usage(db, &credential.uuid);
}
} else {
if let Some(db) = &state.db {
let _ = state.pool_service.mark_unhealthy(
db,
&credential.uuid,
Some(&format!("API error: {}", status)),
);
}
}
match resp.text().await {
Ok(body) => {
if status.is_success() {
// 将 OpenAI 响应转换为 Anthropic 响应
match serde_json::from_str::<crate::models::openai::ChatCompletionResponse>(&body) {
Ok(openai_resp) => {
let anthropic_resp = convert_openai_response_to_anthropic(&openai_resp, &request.model);
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&anthropic_resp).unwrap_or_default()))
.unwrap_or_else(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": {"message": "Failed to build response"}})),
)
.into_response()
})
}
Err(e) => {
state.logs.write().await.add(
"error",
&format!("[ANTHROPIC_COMPAT] 解析 OpenAI 响应失败: {}", e),
);
// 返回原始响应
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(body))
.unwrap_or_else(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": {"message": "Failed to build response"}})),
)
.into_response()
})
}
}
} else {
(
StatusCode::from_u16(status.as_u16())
.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
Json(serde_json::json!({"error": {"message": body}})),
)
.into_response()
}
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": {"message": format!("Failed to read response: {}", e)}})),
)
.into_response(),
}
}
Err(e) => {
if let Some(db) = &state.db {
let _ = state.pool_service.mark_unhealthy(
let _ = state.pool_service.mark_healthy(
db,
&credential.uuid,
Some(&format!("API call failed: {}", e)),
Some(&request.model),
);
let _ = state.pool_service.record_usage(db, &credential.uuid);
}
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": {"message": format!("OpenAI compatible API call failed: {}", e)}})),
)
.into_response()
let stream = resp.bytes_stream();
return Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/event-stream")
.header(header::CACHE_CONTROL, "no-cache")
.header("Connection", "keep-alive")
.body(Body::from_stream(stream))
.unwrap_or_else(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": {"message": "Failed to build stream response"}})),
)
.into_response()
});
}
}
} else {
// 没有自定义 base_url,使用原生 Anthropic API
let claude = ClaudeCustomProvider::with_config(api_key.clone(), None);
let request_url = claude.get_base_url();
state.logs.write().await.add(
"info",
&format!(
"[ANTHROPIC] 使用 Anthropic API: base_url=https://api.anthropic.com -> {}/v1/messages credential_uuid={} stream={}",
request_url,
&credential.uuid[..8],
request.stream
),
);
match claude.call_api(request).await {
Ok(resp) => {
let status = resp.status();
state.logs.write().await.add(
"info",
&format!(
"[ANTHROPIC] 响应状态: status={} model={} stream={}",
status,
request.model,
request.stream
),
);
// 如果是流式请求,直接透传流式响应
if request.stream && status.is_success() {
state.logs.write().await.add(
"info",
"[ANTHROPIC] 流式请求,透传 SSE 响应",
);
if let Some(db) = &state.db {
let _ = state.pool_service.mark_healthy(
db,
&credential.uuid,
Some(&request.model),
);
let _ = state.pool_service.record_usage(db, &credential.uuid);
}
let stream = resp.bytes_stream();
return Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/event-stream")
.header(header::CACHE_CONTROL, "no-cache")
.header("Connection", "keep-alive")
.body(Body::from_stream(stream))
.unwrap_or_else(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": {"message": "Failed to build stream response"}})),
)
.into_response()
});
}
// 非流式请求,读取完整响应
match resp.text().await {
Ok(body) => {
if status.is_success() {
if let Some(db) = &state.db {
let _ = state.pool_service.mark_healthy(
db,
&credential.uuid,
Some(&request.model),
);
let _ = state.pool_service.record_usage(db, &credential.uuid);
}
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(body))
.unwrap_or_else(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": {"message": "Failed to build response"}})),
)
.into_response()
})
} else {
state.logs.write().await.add(
"error",
&format!(
"[ANTHROPIC] 请求失败: status={} body={}",
status,
&body.chars().take(200).collect::<String>()
),
// 非流式请求,读取完整响应
match resp.text().await {
Ok(body) => {
if status.is_success() {
if let Some(db) = &state.db {
let _ = state.pool_service.mark_healthy(
db,
&credential.uuid,
Some(&request.model),
);
if let Some(db) = &state.db {
let _ = state.pool_service.mark_unhealthy(
db,
&credential.uuid,
Some(&body),
);
}
(
StatusCode::from_u16(status.as_u16())
.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
Json(serde_json::json!({"error": {"message": body}})),
)
.into_response()
let _ = state.pool_service.record_usage(db, &credential.uuid);
}
}
Err(e) => {
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(body))
.unwrap_or_else(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": {"message": "Failed to build response"}})),
)
.into_response()
})
} else {
state.logs.write().await.add(
"error",
&format!("[ANTHROPIC] 读取响应失败: {}", e),
&format!(
"[ANTHROPIC] 请求失败: status={} body={}",
status,
&body[..body.len().min(500)]
),
);
if let Some(db) = &state.db {
let _ = state.pool_service.mark_unhealthy(
db,
&credential.uuid,
Some(&e.to_string()),
Some(&format!("API error: {}", status)),
);
}
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": {"message": e.to_string()}})),
StatusCode::from_u16(status.as_u16())
.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
Json(serde_json::json!({"error": {"message": body}})),
)
.into_response()
}
}
}
Err(e) => {
if let Some(db) = &state.db {
let _ = state.pool_service.mark_unhealthy(
db,
&credential.uuid,
Some(&e.to_string()),
);
}
(
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": {"message": e.to_string()}})),
Json(serde_json::json!({"error": {"message": format!("Failed to read response: {}", e)}})),
)
.into_response()
.into_response(),
}
}
Err(e) => {
if let Some(db) = &state.db {
let _ = state.pool_service.mark_unhealthy(
db,
&credential.uuid,
Some(&format!("API call failed: {}", e)),
);
}
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": {"message": format!("Anthropic API call failed: {}", e)}})),
)
.into_response()
}
}
}
}
@@ -5,7 +5,7 @@
* Requirements: 9.3, 9.4
*/
import React, { memo, useMemo } from "react";
import React, { memo, useMemo, useState, useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
import { ChevronDown, Lightbulb } from "lucide-react";
import { MarkdownRenderer } from "./MarkdownRenderer";
@@ -60,6 +60,121 @@ const StreamingCursor: React.FC = () => (
/>
);
// ============ 流式文本组件(逐字符动画) ============
interface StreamingTextProps {
/** 目标文本(完整内容) */
text: string;
/** 是否正在流式输出 */
isStreaming: boolean;
/** 是否显示光标 */
showCursor?: boolean;
/** 每个字符的渲染间隔(毫秒),默认 12ms */
charInterval?: number;
}
/**
* 流式文本组件
*
* 实现逐字符平滑显示效果,类似 ChatGPT/Claude 的打字机效果。
* 当流式结束时,立即显示完整文本。
*/
const StreamingText: React.FC<StreamingTextProps> = memo(
({ text, isStreaming, showCursor = true, charInterval = 12 }) => {
const [displayText, setDisplayText] = useState("");
const displayIndexRef = useRef(0);
const animationRef = useRef<number | null>(null);
const prevTextRef = useRef("");
useEffect(() => {
// 如果不是流式输出,直接显示完整文本
if (!isStreaming) {
setDisplayText(text);
displayIndexRef.current = text.length;
prevTextRef.current = text;
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
animationRef.current = null;
}
return;
}
// 检测文本是否有新增
if (text.length <= prevTextRef.current.length) {
prevTextRef.current = text;
return;
}
prevTextRef.current = text;
// 如果已经有动画在运行,让它继续
if (animationRef.current !== null) {
return;
}
let lastTime = 0;
const animate = (currentTime: number) => {
if (!lastTime) lastTime = currentTime;
const elapsed = currentTime - lastTime;
if (elapsed >= charInterval) {
// 计算这一帧应该显示多少个字符
const charsToAdd = Math.max(1, Math.floor(elapsed / charInterval));
const newIndex = Math.min(
displayIndexRef.current + charsToAdd,
text.length,
);
if (newIndex > displayIndexRef.current) {
displayIndexRef.current = newIndex;
setDisplayText(text.slice(0, newIndex));
}
lastTime = currentTime;
}
// 继续动画直到追上目标
if (displayIndexRef.current < text.length) {
animationRef.current = requestAnimationFrame(animate);
} else {
animationRef.current = null;
}
};
animationRef.current = requestAnimationFrame(animate);
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
animationRef.current = null;
}
};
}, [text, isStreaming, charInterval]);
// 组件卸载时清理
useEffect(() => {
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
};
}, []);
const shouldShowCursor =
isStreaming && showCursor && displayIndexRef.current < text.length;
return (
<div className="relative">
<MarkdownRenderer content={displayText} />
{shouldShowCursor && <StreamingCursor />}
</div>
);
},
);
StreamingText.displayName = "StreamingText";
// ============ 思考内容解析 ============
interface ParsedContent {
@@ -186,11 +301,14 @@ export const StreamingRenderer: React.FC<StreamingRendererProps> = memo(
if (!partVisible) return null;
const isLastPart = index === contentParts.length - 1;
// 使用 StreamingText 组件实现逐字符动画
return (
<div key={`text-${index}`} className="relative">
<MarkdownRenderer content={partVisible} />
{isLastPart && shouldShowCursor && <StreamingCursor />}
</div>
<StreamingText
key={`text-${index}`}
text={partVisible}
isStreaming={isStreaming && isLastPart}
showCursor={shouldShowCursor && isLastPart}
/>
);
} else if (part.type === "tool_use") {
// 渲染单个工具调用
@@ -230,12 +348,13 @@ export const StreamingRenderer: React.FC<StreamingRendererProps> = memo(
{/* 工具调用区域 */}
{hasToolCalls && <ToolCallList toolCalls={toolCalls} />}
{/* 文本内容区域 */}
{/* 文本内容区域 - 使用 StreamingText 组件实现逐字符动画 */}
{visibleText.length > 0 && (
<div className="relative">
<MarkdownRenderer content={visibleText} />
{shouldShowCursor && <StreamingCursor />}
</div>
<StreamingText
text={visibleText}
isStreaming={isStreaming}
showCursor={shouldShowCursor}
/>
)}
{/* 如果没有内容但正在流式输出,显示光标 */}
@@ -14,7 +14,14 @@ import {
type SessionInfo,
type StreamEvent,
} from "@/lib/api/agent";
import { Message, MessageImage, ContentPart, PROVIDER_CONFIG } from "../types";
import {
Message,
MessageImage,
ContentPart,
PROVIDER_CONFIG,
getProviderConfig,
type ProviderConfigMap,
} from "../types";
/** 话题(会话)信息 */
export interface Topic {
@@ -78,6 +85,11 @@ export function useAgentChat() {
running: false,
});
// 动态模型配置(从后端加载)
const [providerConfig, setProviderConfig] =
useState<ProviderConfigMap>(PROVIDER_CONFIG);
const [isConfigLoading, setIsConfigLoading] = useState(true);
// Configuration State (Persistent)
const defaultProvider = "claude";
const defaultModel = PROVIDER_CONFIG["claude"]?.models[0] || "";
@@ -102,6 +114,21 @@ export function useAgentChat() {
const [isSending, setIsSending] = useState(false);
// 加载动态模型配置
useEffect(() => {
const loadConfig = async () => {
try {
const config = await getProviderConfig();
setProviderConfig(config);
} catch (error) {
console.warn("加载模型配置失败,使用默认配置:", error);
} finally {
setIsConfigLoading(false);
}
};
loadConfig();
}, []);
// Persistence Effects
useEffect(() => {
savePersisted("agent_pref_provider", providerType);
@@ -564,6 +591,8 @@ export function useAgentChat() {
setProviderType,
model,
setModel,
providerConfig, // 动态模型配置
isConfigLoading, // 配置加载状态
// Chat
messages,
@@ -0,0 +1,156 @@
import { useState, useEffect, useRef, useCallback } from "react";
interface UseStreamingTextOptions {
/** 每个字符的渲染间隔(毫秒),默认 15ms */
charInterval?: number;
/** 是否启用动画,默认 true */
animated?: boolean;
/** 当文本追赶上目标时的回调 */
onCatchUp?: () => void;
}
interface UseStreamingTextReturn {
/** 当前显示的文本 */
displayText: string;
/** 目标文本(完整内容) */
targetText: string;
/** 是否正在动画中 */
isAnimating: boolean;
/** 设置目标文本 */
setTargetText: (text: string) => void;
/** 追加文本到目标 */
appendText: (text: string) => void;
/** 重置状态 */
reset: () => void;
/** 立即显示完整文本(跳过动画) */
skipToEnd: () => void;
}
/**
* 流式文本渲染 Hook
*
* 实现逐字符平滑显示效果,类似 ChatGPT/Claude 的打字机效果。
*
* @example
* ```tsx
* const { displayText, appendText, reset } = useStreamingText();
*
* // 当收到流式数据时
* appendText(newChunk);
*
* // 渲染
* <div>{displayText}</div>
* ```
*/
export function useStreamingText(
options: UseStreamingTextOptions = {},
): UseStreamingTextReturn {
const { charInterval = 15, animated = true, onCatchUp } = options;
const [displayText, setDisplayText] = useState("");
const [targetText, setTargetText] = useState("");
const [isAnimating, setIsAnimating] = useState(false);
const animationRef = useRef<number | null>(null);
const displayIndexRef = useRef(0);
// 清理动画
const clearAnimation = useCallback(() => {
if (animationRef.current !== null) {
cancelAnimationFrame(animationRef.current);
animationRef.current = null;
}
}, []);
// 动画循环
useEffect(() => {
if (!animated) {
// 禁用动画时直接显示完整文本
setDisplayText(targetText);
displayIndexRef.current = targetText.length;
setIsAnimating(false);
return;
}
// 如果显示文本已经追上目标文本,停止动画
if (displayIndexRef.current >= targetText.length) {
setIsAnimating(false);
onCatchUp?.();
return;
}
setIsAnimating(true);
let lastTime = 0;
const animate = (currentTime: number) => {
if (!lastTime) lastTime = currentTime;
const elapsed = currentTime - lastTime;
if (elapsed >= charInterval) {
// 计算这一帧应该显示多少个字符
const charsToAdd = Math.max(1, Math.floor(elapsed / charInterval));
const newIndex = Math.min(
displayIndexRef.current + charsToAdd,
targetText.length,
);
if (newIndex > displayIndexRef.current) {
displayIndexRef.current = newIndex;
setDisplayText(targetText.slice(0, newIndex));
}
lastTime = currentTime;
}
// 继续动画直到追上目标
if (displayIndexRef.current < targetText.length) {
animationRef.current = requestAnimationFrame(animate);
} else {
setIsAnimating(false);
onCatchUp?.();
}
};
animationRef.current = requestAnimationFrame(animate);
return clearAnimation;
}, [targetText, animated, charInterval, clearAnimation, onCatchUp]);
// 追加文本
const appendText = useCallback((text: string) => {
setTargetText((prev) => prev + text);
}, []);
// 重置
const reset = useCallback(() => {
clearAnimation();
setDisplayText("");
setTargetText("");
displayIndexRef.current = 0;
setIsAnimating(false);
}, [clearAnimation]);
// 跳过动画,立即显示完整文本
const skipToEnd = useCallback(() => {
clearAnimation();
setDisplayText(targetText);
displayIndexRef.current = targetText.length;
setIsAnimating(false);
}, [clearAnimation, targetText]);
// 组件卸载时清理
useEffect(() => {
return clearAnimation;
}, [clearAnimation]);
return {
displayText,
targetText,
isAnimating,
setTargetText,
appendText,
reset,
skipToEnd,
};
}
+47
View File
@@ -1,4 +1,5 @@
import type { ToolCallState, TokenUsage } from "@/lib/api/agent";
import { invoke } from "@tauri-apps/api/core";
export interface MessageImage {
data: string;
@@ -60,6 +61,14 @@ export const PROVIDER_CONFIG: Record<
"claude-sonnet-4-20250514",
],
},
anthropic: {
label: "Anthropic",
models: [
"claude-opus-4-5-20251101",
"claude-sonnet-4-5-20250929",
"claude-sonnet-4-20250514",
],
},
kiro: {
label: "Kiro",
models: ["claude-sonnet-4-5-20250929", "claude-sonnet-4-20250514"],
@@ -109,3 +118,41 @@ export const PROVIDER_CONFIG: Record<
],
},
};
// ============ 动态模型配置 API ============
/** 简化的 Provider 配置(从后端返回) */
export interface SimpleProviderConfig {
label: string;
models: string[];
}
/** Provider 配置映射类型 */
export type ProviderConfigMap = Record<string, SimpleProviderConfig>;
/**
* 从后端获取所有 Provider 的模型配置
* 如果获取失败,返回默认的 PROVIDER_CONFIG
*/
export async function getProviderConfig(): Promise<ProviderConfigMap> {
try {
const config = await invoke<ProviderConfigMap>("get_all_provider_models");
return config;
} catch (error) {
console.warn("获取模型配置失败,使用默认配置:", error);
return PROVIDER_CONFIG;
}
}
/**
* 获取指定 Provider 的模型列表
*/
export async function getProviderModels(provider: string): Promise<string[]> {
try {
const models = await invoke<string[]>("get_provider_models", { provider });
return models;
} catch (error) {
console.warn(`获取 ${provider} 模型列表失败:`, error);
return PROVIDER_CONFIG[provider]?.models ?? [];
}
}
-209
View File
@@ -1,209 +0,0 @@
import { useState, useEffect } from "react";
import {
Folder,
RotateCcw,
Check,
AlertCircle,
FolderOpen,
} from "lucide-react";
import { Config } from "@/lib/api/config";
import { invoke } from "@tauri-apps/api/core";
interface AuthDirSettingsProps {
config: Config | null;
onConfigChange: (config: Config) => void;
}
const DEFAULT_AUTH_DIR = "~/.proxycast/auth";
export function AuthDirSettings({
config,
onConfigChange,
}: AuthDirSettingsProps) {
const [authDir, setAuthDir] = useState(DEFAULT_AUTH_DIR);
const [isSaving, setIsSaving] = useState(false);
const [saveSuccess, setSaveSuccess] = useState(false);
const [error, setError] = useState<string | null>(null);
const [expandedPath, setExpandedPath] = useState<string | null>(null);
// Load auth_dir from config
useEffect(() => {
if (config?.auth_dir) {
setAuthDir(config.auth_dir);
}
}, [config]);
// Validate and expand path
const validatePath = async (path: string) => {
try {
const expanded = await invoke<string>("expand_path", { path });
setExpandedPath(expanded);
setError(null);
return true;
} catch (err) {
setError(`路径无效: ${err}`);
setExpandedPath(null);
return false;
}
};
// Handle path change
const handlePathChange = (newPath: string) => {
setAuthDir(newPath);
setSaveSuccess(false);
// Debounce validation
const timer = setTimeout(() => {
validatePath(newPath);
}, 300);
return () => clearTimeout(timer);
};
// Reset to default
const handleReset = () => {
setAuthDir(DEFAULT_AUTH_DIR);
setSaveSuccess(false);
validatePath(DEFAULT_AUTH_DIR);
};
// Save changes
const handleSave = async () => {
if (!config) return;
setIsSaving(true);
setError(null);
setSaveSuccess(false);
try {
// Validate path first
const isValid = await validatePath(authDir);
if (!isValid) {
setIsSaving(false);
return;
}
// Update config
const newConfig = {
...config,
auth_dir: authDir,
};
onConfigChange(newConfig);
setSaveSuccess(true);
// Clear success message after 3 seconds
setTimeout(() => setSaveSuccess(false), 3000);
} catch (err) {
setError(`保存失败: ${err}`);
} finally {
setIsSaving(false);
}
};
// Open folder in file manager
const handleOpenFolder = async () => {
try {
await invoke("open_auth_dir", { path: authDir });
} catch (err) {
setError(`打开文件夹失败: ${err}`);
}
};
const hasChanges = config?.auth_dir !== authDir;
return (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium flex items-center gap-2">
<Folder className="h-5 w-5" />
认证目录设置
</h3>
<p className="text-sm text-muted-foreground mt-1">
配置 OAuth Token 文件的存储目录。支持使用 ~ 表示用户主目录。
</p>
</div>
<div className="rounded-lg border p-4 space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">认证目录路径 (auth_dir)</label>
<div className="flex gap-2">
<div className="relative flex-1">
<Folder className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
value={authDir}
onChange={(e) => handlePathChange(e.target.value)}
className="w-full pl-9 pr-3 py-2 rounded-lg border bg-background text-sm font-mono focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none"
placeholder={DEFAULT_AUTH_DIR}
/>
</div>
<button
onClick={handleReset}
className="p-2 rounded-lg border hover:bg-muted text-muted-foreground"
title="重置为默认"
>
<RotateCcw className="h-4 w-4" />
</button>
<button
onClick={handleOpenFolder}
className="p-2 rounded-lg border hover:bg-muted text-muted-foreground"
title="打开文件夹"
>
<FolderOpen className="h-4 w-4" />
</button>
</div>
</div>
{/* Expanded path preview */}
{expandedPath && (
<div className="text-sm">
<span className="text-muted-foreground">展开后路径: </span>
<code className="rounded bg-muted px-2 py-0.5 text-xs">
{expandedPath}
</code>
</div>
)}
{/* Error display */}
{error && (
<div className="flex items-start gap-2 rounded-lg border border-red-200 bg-red-50 p-3 text-red-700 dark:border-red-800 dark:bg-red-950 dark:text-red-400">
<AlertCircle className="h-5 w-5 flex-shrink-0" />
<span className="text-sm">{error}</span>
</div>
)}
{/* Success message */}
{saveSuccess && (
<div className="flex items-center gap-2 rounded-lg border border-green-200 bg-green-50 p-3 text-green-700 dark:border-green-800 dark:bg-green-950 dark:text-green-400">
<Check className="h-5 w-5" />
<span className="text-sm">设置已保存</span>
</div>
)}
{/* Save button */}
<div className="flex justify-end">
<button
onClick={handleSave}
disabled={!hasChanges || isSaving}
className="flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{isSaving ? "保存中..." : "保存设置"}
</button>
</div>
</div>
{/* Help text */}
<div className="rounded-lg border bg-muted/50 p-4 space-y-2">
<h4 className="text-sm font-medium">说明</h4>
<ul className="text-sm text-muted-foreground space-y-1 list-disc list-inside">
<li>认证目录用于存储 OAuth Token 文件(Kiro、Gemini、Qwen 等)</li>
<li>
使用 <code className="rounded bg-muted px-1">~</code>{" "}
表示用户主目录,例如{" "}
<code className="rounded bg-muted px-1">~/.proxycast/auth</code>
</li>
<li>修改此设置后,现有的 Token 文件不会自动迁移,需要手动移动</li>
<li>导出配置时,Token 文件会从此目录读取并包含在导出包中</li>
</ul>
</div>
</div>
);
}
-249
View File
@@ -1,249 +0,0 @@
import React, { useState, useEffect, useCallback, useRef } from "react";
import { AlertCircle, Check, Copy, FileCode } from "lucide-react";
import { Config, configApi } from "@/lib/api/config";
interface ConfigEditorProps {
config: Config | null;
onConfigChange: (config: Config) => void;
}
// Simple YAML syntax highlighter
function highlightYaml(yaml: string): string {
return (
yaml
// Comments
.replace(/(#.*$)/gm, '<span class="yaml-comment">$1</span>')
// Keys (before colon)
.replace(
/^(\s*)([a-zA-Z_][a-zA-Z0-9_]*):/gm,
'$1<span class="yaml-key">$2</span>:',
)
// Strings in quotes
.replace(
/"([^"\\]*(\\.[^"\\]*)*)"/g,
'<span class="yaml-string">"$1"</span>',
)
.replace(
/'([^'\\]*(\\.[^'\\]*)*)'/g,
"<span class=\"yaml-string\">'$1'</span>",
)
// Booleans
.replace(
/:\s*(true|false)(\s|$)/gi,
': <span class="yaml-boolean">$1</span>$2',
)
// Numbers
.replace(
/:\s*(\d+\.?\d*)(\s|$)/g,
': <span class="yaml-number">$1</span>$2',
)
// Null
.replace(/:\s*(null|~)(\s|$)/gi, ': <span class="yaml-null">$1</span>$2')
);
}
export function ConfigEditor({ config, onConfigChange }: ConfigEditorProps) {
const [yamlContent, setYamlContent] = useState("");
const [highlightedContent, setHighlightedContent] = useState("");
const [error, setError] = useState<string | null>(null);
const [isValid, setIsValid] = useState(true);
const [copied, setCopied] = useState(false);
const [isValidating, setIsValidating] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const highlightRef = useRef<HTMLPreElement>(null);
const validateTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Load YAML from config
const loadYamlFromConfig = useCallback(async () => {
if (!config) return;
try {
const result = await configApi.exportConfig(config, false);
setYamlContent(result.content);
setHighlightedContent(highlightYaml(result.content));
setError(null);
setIsValid(true);
} catch (err) {
setError(`加载配置失败: ${err}`);
}
}, [config]);
// Load initial YAML from config
useEffect(() => {
if (config) {
loadYamlFromConfig();
}
}, [config, loadYamlFromConfig]);
// Validate YAML with debounce
const validateYaml = useCallback(
async (content: string) => {
if (!content.trim()) {
setError(null);
setIsValid(false);
return;
}
setIsValidating(true);
try {
const validatedConfig = await configApi.validateConfigYaml(content);
setError(null);
setIsValid(true);
onConfigChange(validatedConfig);
} catch (err) {
setError(`${err}`);
setIsValid(false);
} finally {
setIsValidating(false);
}
},
[onConfigChange],
);
// Handle content change with debounced validation
const handleContentChange = (newContent: string) => {
setYamlContent(newContent);
setHighlightedContent(highlightYaml(newContent));
// Clear previous timeout
if (validateTimeoutRef.current) {
clearTimeout(validateTimeoutRef.current);
}
// Debounce validation
validateTimeoutRef.current = setTimeout(() => {
validateYaml(newContent);
}, 500);
};
// Sync scroll between textarea and highlight
const handleScroll = () => {
if (textareaRef.current && highlightRef.current) {
highlightRef.current.scrollTop = textareaRef.current.scrollTop;
highlightRef.current.scrollLeft = textareaRef.current.scrollLeft;
}
};
// Copy to clipboard
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(yamlContent);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
setError("复制失败");
}
};
// Handle tab key for indentation
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Tab") {
e.preventDefault();
const textarea = e.currentTarget;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const newContent =
yamlContent.substring(0, start) + " " + yamlContent.substring(end);
handleContentChange(newContent);
// Restore cursor position
setTimeout(() => {
textarea.selectionStart = textarea.selectionEnd = start + 2;
}, 0);
}
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FileCode className="h-5 w-5" />
<h3 className="text-lg font-medium">YAML 配置编辑器</h3>
</div>
<div className="flex items-center gap-2">
{isValidating && (
<span className="text-sm text-muted-foreground">验证中...</span>
)}
{!isValidating && isValid && yamlContent && (
<span className="flex items-center gap-1 text-sm text-green-600">
<Check className="h-4 w-4" />
有效
</span>
)}
<button
onClick={handleCopy}
className="flex items-center gap-1 rounded-lg border px-3 py-1.5 text-sm hover:bg-muted"
>
{copied ? (
<>
<Check className="h-4 w-4" />
已复制
</>
) : (
<>
<Copy className="h-4 w-4" />
复制
</>
)}
</button>
</div>
</div>
{/* Editor container */}
<div className="relative rounded-lg border bg-[#1e1e1e] overflow-hidden">
{/* Syntax highlighted layer */}
<pre
ref={highlightRef}
className="yaml-highlight absolute inset-0 p-4 m-0 overflow-auto pointer-events-none font-mono text-sm leading-6 whitespace-pre-wrap break-words"
aria-hidden="true"
dangerouslySetInnerHTML={{ __html: highlightedContent + "\n" }}
/>
{/* Editable textarea */}
<textarea
ref={textareaRef}
value={yamlContent}
onChange={(e) => handleContentChange(e.target.value)}
onScroll={handleScroll}
onKeyDown={handleKeyDown}
className="relative w-full h-[500px] p-4 font-mono text-sm leading-6 bg-transparent text-transparent caret-white resize-none outline-none"
spellCheck={false}
placeholder="# 在此输入 YAML 配置..."
/>
</div>
{/* Error display */}
{error && (
<div className="flex items-start gap-2 rounded-lg border border-red-200 bg-red-50 p-3 text-red-700 dark:border-red-800 dark:bg-red-950 dark:text-red-400">
<AlertCircle className="h-5 w-5 flex-shrink-0 mt-0.5" />
<div className="text-sm">
<p className="font-medium">配置错误</p>
<p className="mt-1 whitespace-pre-wrap">{error}</p>
</div>
</div>
)}
{/* Syntax highlighting styles */}
<style>{`
.yaml-highlight {
color: #d4d4d4;
}
.yaml-key {
color: #9cdcfe;
}
.yaml-string {
color: #ce9178;
}
.yaml-number {
color: #b5cea8;
}
.yaml-boolean {
color: #569cd6;
}
.yaml-null {
color: #569cd6;
}
.yaml-comment {
color: #6a9955;
}
`}</style>
</div>
);
}
+7 -78
View File
@@ -1,92 +1,21 @@
import { useState } from "react";
import { Monitor, FileCode } from "lucide-react";
import { cn } from "@/lib/utils";
import { ClientsPage } from "../clients/ClientsPage";
import { ConfigPage } from "./ConfigPage";
type Tab = "switch" | "config";
const tabs = [
{
id: "switch" as Tab,
label: "配置切换",
icon: Monitor,
experimental: false,
},
{
id: "config" as Tab,
label: "配置文件",
icon: FileCode,
experimental: true,
},
];
export function ConfigManagementPage() {
const [activeTab, setActiveTab] = useState<Tab>("switch");
// 根据当前 tab 显示不同的描述
const getDescription = () => {
if (activeTab === "switch") {
return (
<>
return (
<div className="space-y-4">
<div>
<h2 className="text-2xl font-bold">配置管理</h2>
<p className="text-muted-foreground text-sm">
一键切换 API 配置,可独立使用。添加 "ProxyCast" 可将凭证池转为标准
API(
<code className="px-1 py-0.5 rounded bg-muted text-xs">
localhost:8999
</code>
)
</>
);
}
return (
<>
编辑 YAML 配置文件。实验功能,不影响核心使用,
<a
href="https://github.com/aiclientproxy/proxycast/issues"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
问题反馈
</a>
</>
);
};
return (
<div className="space-y-4">
<div>
<h2 className="text-2xl font-bold">配置管理</h2>
<p className="text-muted-foreground text-sm">{getDescription()}</p>
</p>
</div>
{/* Tab 切换 */}
<div className="flex gap-1 border-b">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={cn(
"flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors",
activeTab === tab.id
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground",
)}
>
<tab.icon className="h-4 w-4" />
{tab.label}
{tab.experimental && (
<span className="text-[8px] text-red-500">(实验)</span>
)}
</button>
))}
</div>
{/* Tab 内容 */}
<div className="pt-2">
{activeTab === "switch" && <ClientsPage hideHeader />}
{activeTab === "config" && <ConfigPage hideHeader />}
</div>
<ClientsPage hideHeader />
</div>
);
}
-214
View File
@@ -1,214 +0,0 @@
import React, {
useState,
useEffect,
forwardRef,
useImperativeHandle,
} from "react";
import { FileCode, RefreshCw, FolderOpen, Settings } from "lucide-react";
import { ConfigEditor } from "./ConfigEditor";
import { ImportExport } from "./ImportExport";
import { AuthDirSettings } from "./AuthDirSettings";
import { Config, configApi, ConfigPathInfo } from "@/lib/api/config";
import { invoke } from "@tauri-apps/api/core";
export interface ConfigPageRef {
refresh: () => void;
}
type TabType = "editor" | "import-export" | "settings";
interface ConfigPageProps {
hideHeader?: boolean;
}
export const ConfigPage = forwardRef<ConfigPageRef, ConfigPageProps>(
({ hideHeader = false }, ref) => {
const [activeTab, setActiveTab] = useState<TabType>("editor");
const [config, setConfig] = useState<Config | null>(null);
const [pathInfo, setPathInfo] = useState<ConfigPathInfo | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadConfig = async () => {
setIsLoading(true);
setError(null);
try {
// Load current config from app state
const currentConfig = await invoke<Config>("get_config");
setConfig(currentConfig);
// Load path info
const paths = await configApi.getConfigPaths();
setPathInfo(paths);
} catch (err) {
setError(`加载配置失败: ${err}`);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadConfig();
}, []);
useImperativeHandle(ref, () => ({
refresh: loadConfig,
}));
const handleConfigChange = async (newConfig: Config) => {
setConfig(newConfig);
// Save config to backend
try {
await invoke("save_config", { config: newConfig });
} catch (err) {
setError(`保存配置失败: ${err}`);
}
};
const handleOpenConfigFolder = async () => {
try {
await invoke("open_config_folder", { appType: "ProxyCast" });
} catch (err) {
setError(`打开文件夹失败: ${err}`);
}
};
const tabs: { id: TabType; label: string; icon?: React.ReactNode }[] = [
{ id: "editor", label: "YAML 编辑器" },
{ id: "import-export", label: "导入/导出" },
{ id: "settings", label: "设置", icon: <Settings className="h-4 w-4" /> },
];
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-muted-foreground">加载中...</div>
</div>
);
}
return (
<div className="space-y-4">
{!hideHeader && (
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold flex items-center gap-2">
<FileCode className="h-6 w-6" />
配置管理
</h2>
<p className="text-muted-foreground text-sm">
编辑 YAML 配置文件。实验功能,不影响核心使用,
<a
href="https://github.com/aiclientproxy/proxycast/issues"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
问题反馈
</a>
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleOpenConfigFolder}
className="flex items-center gap-2 rounded-lg border px-3 py-2 text-sm hover:bg-muted"
>
<FolderOpen className="h-4 w-4" />
打开配置目录
</button>
<button
onClick={loadConfig}
className="flex items-center gap-2 rounded-lg border px-3 py-2 text-sm hover:bg-muted"
>
<RefreshCw className="h-4 w-4" />
刷新
</button>
</div>
</div>
)}
{hideHeader && (
<div className="flex items-center justify-end gap-2">
<button
onClick={handleOpenConfigFolder}
className="flex items-center gap-2 rounded-lg border px-3 py-2 text-sm hover:bg-muted"
>
<FolderOpen className="h-4 w-4" />
打开配置目录
</button>
<button
onClick={loadConfig}
className="flex items-center gap-2 rounded-lg border px-3 py-2 text-sm hover:bg-muted"
>
<RefreshCw className="h-4 w-4" />
刷新
</button>
</div>
)}
{/* Config path info */}
{pathInfo && (
<div className="rounded-lg border bg-muted/50 p-3 text-sm">
<div className="flex items-center gap-4">
<span className="text-muted-foreground">配置文件:</span>
<code className="rounded bg-muted px-2 py-0.5">
{pathInfo.yaml_path}
</code>
{pathInfo.yaml_exists ? (
<span className="text-green-600 text-xs">存在</span>
) : (
<span className="text-yellow-600 text-xs">不存在</span>
)}
</div>
</div>
)}
{/* Error display */}
{error && (
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-red-700 dark:border-red-800 dark:bg-red-950 dark:text-red-400">
{error}
</div>
)}
{/* Tabs */}
<div className="flex gap-2 border-b">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium border-b-2 -mb-px ${
activeTab === tab.id
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
{tab.icon}
{tab.label}
</button>
))}
</div>
{/* Tab content */}
<div className="py-4">
{activeTab === "editor" && (
<ConfigEditor config={config} onConfigChange={handleConfigChange} />
)}
{activeTab === "import-export" && (
<ImportExport
config={config}
onConfigImported={handleConfigChange}
/>
)}
{activeTab === "settings" && (
<AuthDirSettings
config={config}
onConfigChange={handleConfigChange}
/>
)}
</div>
</div>
);
},
);
ConfigPage.displayName = "ConfigPage";
-548
View File
@@ -1,548 +0,0 @@
import React, { useState, useRef } from "react";
import {
Download,
Upload,
AlertCircle,
Check,
Shield,
AlertTriangle,
FileJson,
FileText,
Package,
} from "lucide-react";
import { Modal } from "@/components/Modal";
import { Config, configApi, ImportResult } from "@/lib/api/config";
interface ImportExportProps {
config: Config | null;
onConfigImported: (config: Config) => void;
}
// Export scope options
type ExportScope = "config" | "credentials" | "full";
// Validation result from backend
interface ValidationResult {
valid: boolean;
version: string | null;
redacted: boolean;
has_config: boolean;
has_credentials: boolean;
errors: string[];
warnings: string[];
}
export function ImportExport({ config, onConfigImported }: ImportExportProps) {
// Export state
const [isExporting, setIsExporting] = useState(false);
const [exportScope, setExportScope] = useState<ExportScope>("config");
const [redactSecrets, setRedactSecrets] = useState(true);
const [showSecurityWarning, setShowSecurityWarning] = useState(false);
// Import state
const [isImporting, setIsImporting] = useState(false);
const [showImportDialog, setShowImportDialog] = useState(false);
const [importContent, setImportContent] = useState("");
const [importFileName, setImportFileName] = useState("");
const [importResult, setImportResult] = useState<ImportResult | null>(null);
const [validationResult, setValidationResult] =
useState<ValidationResult | null>(null);
const [mergeConfig, setMergeConfig] = useState(true);
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Handle export with security check
const handleExportClick = () => {
if (
!redactSecrets &&
(exportScope === "credentials" || exportScope === "full")
) {
setShowSecurityWarning(true);
} else {
performExport();
}
};
// Perform the actual export
const performExport = async () => {
if (!config) return;
setIsExporting(true);
setError(null);
setShowSecurityWarning(false);
try {
if (exportScope === "config") {
// Export config only as YAML
const result = await configApi.exportConfig(config, redactSecrets);
downloadFile(result.content, result.suggested_filename, "text/yaml");
} else {
// Export bundle (credentials or full)
const result = await configApi.exportBundle(config, {
include_config: exportScope === "full",
include_credentials: true,
redact_secrets: redactSecrets,
});
downloadFile(
result.content,
result.suggested_filename,
"application/json",
);
}
} catch (err) {
setError(`导出失败: ${err}`);
} finally {
setIsExporting(false);
}
};
// Download file helper
const downloadFile = (
content: string,
filename: string,
mimeType: string,
) => {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
// Handle file selection
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async (event) => {
const content = event.target?.result as string;
setImportContent(content);
setImportFileName(file.name);
setShowImportDialog(true);
setImportResult(null);
setError(null);
// Validate the import content
try {
const validation = await configApi.validateImport(content);
setValidationResult(validation);
} catch (err) {
setError(`验证失败: ${err}`);
setValidationResult(null);
}
};
reader.onerror = () => {
setError("读取文件失败");
};
reader.readAsText(file);
// Reset input
e.target.value = "";
};
// Import config/bundle
const handleImport = async () => {
if (!config || !importContent) return;
setIsImporting(true);
setError(null);
try {
const result = await configApi.importBundle(
config,
importContent,
mergeConfig,
);
setImportResult(result);
if (result.success) {
onConfigImported(result.config);
}
} catch (err) {
setError(`导入失败: ${err}`);
} finally {
setIsImporting(false);
}
};
// Close import dialog
const closeImportDialog = () => {
setShowImportDialog(false);
setImportContent("");
setImportFileName("");
setImportResult(null);
setValidationResult(null);
setError(null);
};
// Get file type icon
const getFileTypeIcon = () => {
if (importFileName.endsWith(".json")) {
return <FileJson className="h-4 w-4" />;
}
return <FileText className="h-4 w-4" />;
};
return (
<div className="space-y-6">
{/* Export Section */}
<div className="rounded-lg border p-4">
<h3 className="text-lg font-medium mb-4 flex items-center gap-2">
<Download className="h-5 w-5" />
导出配置
</h3>
<div className="space-y-4">
{/* Export Scope Selection */}
<div className="space-y-2">
<label className="text-sm font-medium">导出范围</label>
<div className="flex flex-col gap-2">
<label className="flex items-center gap-2">
<input
type="radio"
name="exportScope"
checked={exportScope === "config"}
onChange={() => setExportScope("config")}
className="rounded-full border-gray-300"
/>
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">仅配置 (YAML)</span>
</label>
<label className="flex items-center gap-2">
<input
type="radio"
name="exportScope"
checked={exportScope === "credentials"}
onChange={() => setExportScope("credentials")}
className="rounded-full border-gray-300"
/>
<Shield className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">仅凭证 (JSON)</span>
</label>
<label className="flex items-center gap-2">
<input
type="radio"
name="exportScope"
checked={exportScope === "full"}
onChange={() => setExportScope("full")}
className="rounded-full border-gray-300"
/>
<Package className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">完整导出 (配置 + 凭证)</span>
</label>
</div>
</div>
{/* Redaction Option */}
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={redactSecrets}
onChange={(e) => setRedactSecrets(e.target.checked)}
className="rounded border-gray-300"
/>
<Shield className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">脱敏敏感信息(API 密钥、Token 等)</span>
</label>
{/* Security hint */}
{!redactSecrets &&
(exportScope === "credentials" || exportScope === "full") && (
<div className="flex items-start gap-2 rounded-lg border border-yellow-200 bg-yellow-50 p-3 text-yellow-700 dark:border-yellow-800 dark:bg-yellow-950 dark:text-yellow-400">
<AlertTriangle className="h-5 w-5 flex-shrink-0" />
<span className="text-sm">
未脱敏的导出文件将包含明文 API 密钥和 Token,请妥善保管。
</span>
</div>
)}
<div className="flex gap-2">
<button
onClick={handleExportClick}
disabled={!config || isExporting}
className="flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
<Download className="h-4 w-4" />
{isExporting ? "导出中..." : "导出"}
</button>
</div>
<p className="text-sm text-muted-foreground">
{exportScope === "config" &&
"导出当前配置为 YAML 文件,可用于备份或迁移。"}
{exportScope === "credentials" &&
"导出凭证池中的所有凭证,包括 OAuth Token 文件。"}
{exportScope === "full" &&
"导出完整的配置和凭证包,可用于完整迁移到其他设备。"}
</p>
</div>
</div>
{/* Import Section */}
<div className="rounded-lg border p-4">
<h3 className="text-lg font-medium mb-4 flex items-center gap-2">
<Upload className="h-5 w-5" />
导入配置
</h3>
<div className="space-y-4">
<input
ref={fileInputRef}
type="file"
accept=".yaml,.yml,.json"
onChange={handleFileSelect}
className="hidden"
/>
<button
onClick={() => fileInputRef.current?.click()}
className="flex items-center gap-2 rounded-lg border px-4 py-2 text-sm hover:bg-muted"
>
<Upload className="h-4 w-4" />
选择文件
</button>
<p className="text-sm text-muted-foreground">
支持导入 YAML 配置文件或 JSON 导出包,支持合并或替换现有配置。
</p>
</div>
</div>
{/* Security Warning Dialog */}
<Modal
isOpen={showSecurityWarning}
onClose={() => setShowSecurityWarning(false)}
maxWidth="max-w-md"
showCloseButton={false}
>
<div className="p-6">
<div className="flex items-center gap-3 mb-4">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-yellow-100 dark:bg-yellow-900">
<AlertTriangle className="h-5 w-5 text-yellow-600 dark:text-yellow-400" />
</div>
<h3 className="text-lg font-medium">安全警告</h3>
</div>
<p className="text-sm text-muted-foreground mb-4">
您即将导出未脱敏的凭证数据,导出文件将包含明文 API 密钥和 OAuth
Token。 请确保:
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground mb-4 space-y-1">
<li>不要将此文件分享给他人</li>
<li>不要上传到公共代码仓库</li>
<li>妥善保管导出文件</li>
</ul>
<div className="flex justify-end gap-2">
<button
onClick={() => setShowSecurityWarning(false)}
className="rounded-lg border px-4 py-2 text-sm hover:bg-muted"
>
取消
</button>
<button
onClick={performExport}
className="rounded-lg bg-yellow-600 px-4 py-2 text-sm text-white hover:bg-yellow-700"
>
我已了解,继续导出
</button>
</div>
</div>
</Modal>
{/* Import Dialog */}
<Modal
isOpen={showImportDialog}
onClose={closeImportDialog}
maxWidth="max-w-2xl"
className="max-h-[90vh] overflow-y-auto"
>
<div className="p-6">
<h3 className="text-lg font-medium mb-4 flex items-center gap-2">
{getFileTypeIcon()}
导入配置 - {importFileName}
</h3>
{/* Validation Result */}
{validationResult && (
<div className="mb-4 space-y-2">
{validationResult.valid ? (
<div className="flex items-center gap-2 rounded-lg border border-green-200 bg-green-50 p-3 text-green-700 dark:border-green-800 dark:bg-green-950 dark:text-green-400">
<Check className="h-5 w-5" />
<span className="text-sm">文件格式有效</span>
</div>
) : (
<div className="flex items-start gap-2 rounded-lg border border-red-200 bg-red-50 p-3 text-red-700 dark:border-red-800 dark:bg-red-950 dark:text-red-400">
<AlertCircle className="h-5 w-5 flex-shrink-0" />
<div className="text-sm">
<p className="font-medium">文件格式无效</p>
<ul className="mt-1 list-disc list-inside">
{validationResult.errors.map((err, i) => (
<li key={i}>{err}</li>
))}
</ul>
</div>
</div>
)}
{/* Content info */}
{validationResult.valid && (
<div className="flex flex-wrap gap-2 text-sm">
{validationResult.version && (
<span className="rounded bg-muted px-2 py-1">
版本: {validationResult.version}
</span>
)}
{validationResult.has_config && (
<span className="rounded bg-blue-100 px-2 py-1 text-blue-700 dark:bg-blue-900 dark:text-blue-300">
包含配置
</span>
)}
{validationResult.has_credentials && (
<span className="rounded bg-purple-100 px-2 py-1 text-purple-700 dark:bg-purple-900 dark:text-purple-300">
包含凭证
</span>
)}
{validationResult.redacted && (
<span className="rounded bg-yellow-100 px-2 py-1 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300">
已脱敏
</span>
)}
</div>
)}
{/* Redaction warning */}
{validationResult.redacted && (
<div className="flex items-start gap-2 rounded-lg border border-yellow-200 bg-yellow-50 p-3 text-yellow-700 dark:border-yellow-800 dark:bg-yellow-950 dark:text-yellow-400">
<AlertTriangle className="h-5 w-5 flex-shrink-0" />
<span className="text-sm">
此导出包已脱敏,凭证数据(API 密钥、Token)无法恢复。
</span>
</div>
)}
{/* Validation warnings */}
{validationResult.warnings.length > 0 && (
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-3 dark:border-yellow-800 dark:bg-yellow-950">
<p className="text-sm font-medium text-yellow-700 dark:text-yellow-400">
警告
</p>
<ul className="mt-1 list-disc list-inside text-sm text-yellow-600 dark:text-yellow-500">
{validationResult.warnings.map((warning, i) => (
<li key={i}>{warning}</li>
))}
</ul>
</div>
)}
</div>
)}
{/* Preview */}
<div className="mb-4">
<label className="text-sm font-medium">内容预览</label>
<pre className="mt-2 max-h-48 overflow-auto rounded-lg bg-muted p-4 text-xs font-mono">
{importContent.slice(0, 2000)}
{importContent.length > 2000 && "\n..."}
</pre>
</div>
{/* Import Options */}
<div className="mb-4 space-y-2">
<label className="text-sm font-medium">导入模式</label>
<div className="flex flex-col gap-2">
<label className="flex items-center gap-2">
<input
type="radio"
name="importMode"
checked={mergeConfig}
onChange={() => setMergeConfig(true)}
className="rounded-full border-gray-300"
/>
<span className="text-sm">合并到现有配置</span>
<span className="text-xs text-muted-foreground">
(保留现有数据,添加新数据)
</span>
</label>
<label className="flex items-center gap-2">
<input
type="radio"
name="importMode"
checked={!mergeConfig}
onChange={() => setMergeConfig(false)}
className="rounded-full border-gray-300"
/>
<span className="text-sm">替换现有配置</span>
<span className="text-xs text-muted-foreground">
(完全覆盖现有数据)
</span>
</label>
</div>
</div>
{/* Import Result Warnings */}
{importResult?.warnings && importResult.warnings.length > 0 && (
<div className="mb-4 rounded-lg border border-yellow-200 bg-yellow-50 p-3 dark:border-yellow-800 dark:bg-yellow-950">
<p className="text-sm font-medium text-yellow-700 dark:text-yellow-400">
导入警告
</p>
<ul className="mt-1 list-disc list-inside text-sm text-yellow-600 dark:text-yellow-500">
{importResult.warnings.map((warning, i) => (
<li key={i}>{warning}</li>
))}
</ul>
</div>
)}
{/* Success */}
{importResult?.success && (
<div className="mb-4 flex items-center gap-2 rounded-lg border border-green-200 bg-green-50 p-3 text-green-700 dark:border-green-800 dark:bg-green-950 dark:text-green-400">
<Check className="h-5 w-5" />
<span className="text-sm">配置导入成功</span>
</div>
)}
{/* Error */}
{error && (
<div className="mb-4 flex items-start gap-2 rounded-lg border border-red-200 bg-red-50 p-3 text-red-700 dark:border-red-800 dark:bg-red-950 dark:text-red-400">
<AlertCircle className="h-5 w-5 flex-shrink-0" />
<span className="text-sm">{error}</span>
</div>
)}
{/* Actions */}
<div className="flex justify-end gap-2">
<button
onClick={closeImportDialog}
className="rounded-lg border px-4 py-2 text-sm hover:bg-muted"
>
{importResult?.success ? "关闭" : "取消"}
</button>
{!importResult?.success && validationResult?.valid && (
<button
onClick={handleImport}
disabled={isImporting}
className="rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{isImporting ? "导入中..." : "导入"}
</button>
)}
</div>
</div>
</Modal>
{/* Global Error */}
{error && !showImportDialog && (
<div className="flex items-start gap-2 rounded-lg border border-red-200 bg-red-50 p-3 text-red-700 dark:border-red-800 dark:bg-red-950 dark:text-red-400">
<AlertCircle className="h-5 w-5 flex-shrink-0" />
<span className="text-sm">{error}</span>
</div>
)}
</div>
);
}
-4
View File
@@ -1,5 +1 @@
export { ConfigPage } from "./ConfigPage";
export { ConfigEditor } from "./ConfigEditor";
export { ImportExport } from "./ImportExport";
export { AuthDirSettings } from "./AuthDirSettings";
export { ConfigManagementPage } from "./ConfigManagementPage";
@@ -186,10 +186,10 @@ export const ImportExportDialog: React.FC<ImportExportDialogProps> = ({
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
<DialogContent
className="sm:max-w-[600px]"
className="sm:max-w-[600px] p-6"
data-testid="import-export-dialog"
>
<DialogHeader>
<DialogHeader className="mb-4">
<DialogTitle>导入/导出 Provider 配置</DialogTitle>
<DialogDescription>
导出当前 Provider 配置或从文件导入配置
@@ -199,6 +199,7 @@ export const ImportExportDialog: React.FC<ImportExportDialogProps> = ({
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as TabValue)}
className="w-full"
>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="export" data-testid="export-tab">
@@ -361,7 +362,7 @@ export const ImportExportDialog: React.FC<ImportExportDialogProps> = ({
</div>
)}
<DialogFooter>
<DialogFooter className="mt-6 pt-4 border-t">
<Button
variant="outline"
onClick={handleClose}
-188
View File
@@ -1,188 +0,0 @@
import { invoke } from "@tauri-apps/api/core";
// Config types matching Rust backend
export interface ServerConfig {
host: string;
port: number;
api_key: string;
}
export interface ProviderConfig {
enabled: boolean;
credentials_path?: string;
region?: string;
project_id?: string;
}
export interface CustomProviderConfig {
enabled: boolean;
api_key?: string;
base_url?: string;
}
export interface ProvidersConfig {
kiro: ProviderConfig;
gemini: ProviderConfig;
qwen: ProviderConfig;
openai: CustomProviderConfig;
claude: CustomProviderConfig;
}
export interface RoutingRuleConfig {
pattern: string;
provider: string;
priority: number;
}
export interface RoutingConfig {
default_provider: string;
rules: RoutingRuleConfig[];
model_aliases: Record<string, string>;
exclusions: Record<string, string[]>;
}
export interface RetrySettings {
max_retries: number;
base_delay_ms: number;
max_delay_ms: number;
auto_switch_provider: boolean;
}
export interface LoggingConfig {
enabled: boolean;
level: string;
retention_days: number;
include_request_body: boolean;
}
// Credential pool types
export interface CredentialEntry {
id: string;
token_file: string;
disabled: boolean;
}
export interface ApiKeyEntry {
id: string;
api_key: string;
base_url?: string;
disabled: boolean;
}
export interface CredentialPoolConfig {
kiro: CredentialEntry[];
gemini: CredentialEntry[];
qwen: CredentialEntry[];
openai: ApiKeyEntry[];
claude: ApiKeyEntry[];
}
export interface Config {
server: ServerConfig;
providers: ProvidersConfig;
default_provider: string;
routing: RoutingConfig;
retry: RetrySettings;
logging: LoggingConfig;
auth_dir: string;
credential_pool: CredentialPoolConfig;
}
// Export result
export interface ExportResult {
content: string;
suggested_filename: string;
}
// Unified export options
export interface UnifiedExportOptions {
include_config: boolean;
include_credentials: boolean;
redact_secrets: boolean;
}
// Unified export result
export interface UnifiedExportResult {
content: string;
suggested_filename: string;
redacted: boolean;
has_config: boolean;
has_credentials: boolean;
}
// Validation result
export interface ValidationResult {
valid: boolean;
version: string | null;
redacted: boolean;
has_config: boolean;
has_credentials: boolean;
errors: string[];
warnings: string[];
}
// Import result
export interface ImportResult {
success: boolean;
config: Config;
warnings: string[];
}
// Config path info
export interface ConfigPathInfo {
yaml_path: string;
json_path: string;
yaml_exists: boolean;
json_exists: boolean;
}
export const configApi = {
// Export config to YAML
async exportConfig(
config: Config,
redactSecrets: boolean,
): Promise<ExportResult> {
return invoke("export_config", { config, redactSecrets });
},
// Export bundle (config + credentials)
async exportBundle(
config: Config,
options: UnifiedExportOptions,
): Promise<UnifiedExportResult> {
return invoke("export_bundle", { config, options });
},
// Validate import content (JSON bundle or YAML config)
async validateImport(content: string): Promise<ValidationResult> {
return invoke("validate_import", { content });
},
// Validate YAML config
async validateConfigYaml(yamlContent: string): Promise<Config> {
return invoke("validate_config_yaml", { yamlContent });
},
// Import config from YAML
async importConfig(
currentConfig: Config,
yamlContent: string,
merge: boolean,
): Promise<ImportResult> {
return invoke("import_config", { currentConfig, yamlContent, merge });
},
// Import bundle (JSON bundle or YAML config)
async importBundle(
currentConfig: Config,
content: string,
merge: boolean,
): Promise<ImportResult> {
return invoke("import_bundle", { currentConfig, content, merge });
},
// Get config file paths
async getConfigPaths(): Promise<ConfigPathInfo> {
return invoke("get_config_paths");
},
};