mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
fix: 修复测试中缺少 models 字段 + 更新 GitHub Actions 权限
This commit is contained in:
@@ -13,6 +13,8 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
actions: read
|
||||
|
||||
env:
|
||||
CARGO_INCREMENTAL: 0
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user