Compare commits
36 Commits
v3.2.0
...
feature/vs
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31cdc2a5cf | ||
|
|
7522ba3e03 | ||
|
|
3ac3f122eb | ||
|
|
67db492330 | ||
|
|
358d6e001e | ||
|
|
8a26cb51d8 | ||
|
|
9f8c745f8c | ||
|
|
3a9a8036d2 | ||
|
|
04e81ebbe3 | ||
|
|
c6e4f3599e | ||
|
|
60eb9ce2a4 | ||
|
|
50244f0055 | ||
|
|
eca14db58c | ||
|
|
463e430a3d | ||
|
|
32e66e054b | ||
|
|
2a9f093210 | ||
|
|
9bf216b102 | ||
|
|
b69d7f7979 | ||
|
|
efff780eea | ||
|
|
19dcc84c83 | ||
|
|
4e9e63f524 | ||
|
|
1d1440f52f | ||
|
|
36b78d1b4b | ||
|
|
2b59a5d51b | ||
|
|
15c12c8e65 | ||
|
|
3256b2f842 | ||
|
|
7374b934c7 | ||
|
|
d9d7c5c342 | ||
|
|
f4f7e10953 | ||
|
|
6ad7e04a95 | ||
|
|
7122e10646 | ||
|
|
bb685be43d | ||
|
|
c5b3b4027f | ||
|
|
daba6b094b | ||
|
|
711ad843ce | ||
|
|
189a70280f |
@@ -196,6 +196,10 @@ cargo test
|
|||||||
|
|
||||||
欢迎提交 Issue 和 Pull Request!
|
欢迎提交 Issue 和 Pull Request!
|
||||||
|
|
||||||
|
## Star History
|
||||||
|
|
||||||
|
[](https://www.star-history.com/#farion1231/cc-switch&Date)
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT © Jason Young
|
MIT © Jason Young
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
"@tauri-apps/api": "^2.8.0",
|
"@tauri-apps/api": "^2.8.0",
|
||||||
"@tauri-apps/plugin-process": "^2.0.0",
|
"@tauri-apps/plugin-process": "^2.0.0",
|
||||||
"@tauri-apps/plugin-updater": "^2.0.0",
|
"@tauri-apps/plugin-updater": "^2.0.0",
|
||||||
|
"jsonc-parser": "^3.2.1",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
"lucide-react": "^0.542.0",
|
"lucide-react": "^0.542.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
|
|||||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -35,6 +35,9 @@ importers:
|
|||||||
codemirror:
|
codemirror:
|
||||||
specifier: ^6.0.2
|
specifier: ^6.0.2
|
||||||
version: 6.0.2
|
version: 6.0.2
|
||||||
|
jsonc-parser:
|
||||||
|
specifier: ^3.2.1
|
||||||
|
version: 3.3.1
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.542.0
|
specifier: ^0.542.0
|
||||||
version: 0.542.0(react@18.3.1)
|
version: 0.542.0(react@18.3.1)
|
||||||
@@ -755,6 +758,9 @@ packages:
|
|||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
jsonc-parser@3.3.1:
|
||||||
|
resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==}
|
||||||
|
|
||||||
lightningcss-darwin-arm64@1.30.1:
|
lightningcss-darwin-arm64@1.30.1:
|
||||||
resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==}
|
resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
@@ -1580,6 +1586,8 @@ snapshots:
|
|||||||
|
|
||||||
json5@2.2.3: {}
|
json5@2.2.3: {}
|
||||||
|
|
||||||
|
jsonc-parser@3.3.1: {}
|
||||||
|
|
||||||
lightningcss-darwin-arm64@1.30.1:
|
lightningcss-darwin-arm64@1.30.1:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|||||||
BIN
src-tauri/icons/tray/macos/statusTemplate.png
Normal file
BIN
src-tauri/icons/tray/macos/statusTemplate.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 564 KiB |
BIN
src-tauri/icons/tray/macos/statusTemplate@2x.png
Normal file
BIN
src-tauri/icons/tray/macos/statusTemplate@2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 572 KiB |
@@ -7,9 +7,42 @@ use tauri_plugin_opener::OpenerExt;
|
|||||||
use crate::app_config::AppType;
|
use crate::app_config::AppType;
|
||||||
use crate::codex_config;
|
use crate::codex_config;
|
||||||
use crate::config::{get_claude_settings_path, ConfigStatus};
|
use crate::config::{get_claude_settings_path, ConfigStatus};
|
||||||
|
use crate::vscode;
|
||||||
|
use crate::config;
|
||||||
use crate::provider::Provider;
|
use crate::provider::Provider;
|
||||||
use crate::store::AppState;
|
use crate::store::AppState;
|
||||||
|
|
||||||
|
fn validate_provider_settings(app_type: &AppType, provider: &Provider) -> Result<(), String> {
|
||||||
|
match app_type {
|
||||||
|
AppType::Claude => {
|
||||||
|
if !provider.settings_config.is_object() {
|
||||||
|
return Err("Claude 配置必须是 JSON 对象".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AppType::Codex => {
|
||||||
|
let settings = provider
|
||||||
|
.settings_config
|
||||||
|
.as_object()
|
||||||
|
.ok_or_else(|| "Codex 配置必须是 JSON 对象".to_string())?;
|
||||||
|
let auth = settings
|
||||||
|
.get("auth")
|
||||||
|
.ok_or_else(|| "Codex 配置缺少 auth 字段".to_string())?;
|
||||||
|
if !auth.is_object() {
|
||||||
|
return Err("Codex auth 配置必须是 JSON 对象".to_string());
|
||||||
|
}
|
||||||
|
if let Some(config_value) = settings.get("config") {
|
||||||
|
if !(config_value.is_string() || config_value.is_null()) {
|
||||||
|
return Err("Codex config 字段必须是字符串".to_string());
|
||||||
|
}
|
||||||
|
if let Some(cfg_text) = config_value.as_str() {
|
||||||
|
codex_config::validate_config_toml(cfg_text)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// 获取所有供应商
|
/// 获取所有供应商
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_providers(
|
pub async fn get_providers(
|
||||||
@@ -74,6 +107,8 @@ pub async fn add_provider(
|
|||||||
.or_else(|| appType.as_deref().map(|s| s.into()))
|
.or_else(|| appType.as_deref().map(|s| s.into()))
|
||||||
.unwrap_or(AppType::Claude);
|
.unwrap_or(AppType::Claude);
|
||||||
|
|
||||||
|
validate_provider_settings(&app_type, &provider)?;
|
||||||
|
|
||||||
// 读取当前是否是激活供应商(短锁)
|
// 读取当前是否是激活供应商(短锁)
|
||||||
let is_current = {
|
let is_current = {
|
||||||
let config = state
|
let config = state
|
||||||
@@ -139,6 +174,8 @@ pub async fn update_provider(
|
|||||||
.or_else(|| appType.as_deref().map(|s| s.into()))
|
.or_else(|| appType.as_deref().map(|s| s.into()))
|
||||||
.unwrap_or(AppType::Claude);
|
.unwrap_or(AppType::Claude);
|
||||||
|
|
||||||
|
validate_provider_settings(&app_type, &provider)?;
|
||||||
|
|
||||||
// 读取校验 & 是否当前(短锁)
|
// 读取校验 & 是否当前(短锁)
|
||||||
let (exists, is_current) = {
|
let (exists, is_current) = {
|
||||||
let config = state
|
let config = state
|
||||||
@@ -598,3 +635,36 @@ pub async fn check_for_updates(handle: tauri::AppHandle) -> Result<bool, String>
|
|||||||
|
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// VS Code: 获取用户 settings.json 状态
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_vscode_settings_status() -> Result<ConfigStatus, String> {
|
||||||
|
if let Some(p) = vscode::find_existing_settings() {
|
||||||
|
Ok(ConfigStatus { exists: true, path: p.to_string_lossy().to_string() })
|
||||||
|
} else {
|
||||||
|
// 默认返回 macOS 稳定版路径(或其他平台首选项的第一个候选),但标记不存在
|
||||||
|
let preferred = vscode::candidate_settings_paths().into_iter().next();
|
||||||
|
Ok(ConfigStatus { exists: false, path: preferred.unwrap_or_default().to_string_lossy().to_string() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// VS Code: 读取 settings.json 文本(仅当文件存在)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn read_vscode_settings() -> Result<String, String> {
|
||||||
|
if let Some(p) = vscode::find_existing_settings() {
|
||||||
|
std::fs::read_to_string(&p).map_err(|e| format!("读取 VS Code 设置失败: {}", e))
|
||||||
|
} else {
|
||||||
|
Err("未找到 VS Code 用户设置文件".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// VS Code: 写入 settings.json 文本(仅当文件存在;不自动创建)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn write_vscode_settings(content: String) -> Result<bool, String> {
|
||||||
|
if let Some(p) = vscode::find_existing_settings() {
|
||||||
|
config::write_text_file(&p, &content)?;
|
||||||
|
Ok(true)
|
||||||
|
} else {
|
||||||
|
Err("未找到 VS Code 用户设置文件".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -175,7 +175,19 @@ pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fs::rename(&tmp, path).map_err(|e| format!("原子替换失败: {}", e))?;
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
// Windows 上 rename 目标存在会失败,先移除再重命名(尽量接近原子性)
|
||||||
|
if path.exists() {
|
||||||
|
let _ = fs::remove_file(path);
|
||||||
|
}
|
||||||
|
fs::rename(&tmp, path).map_err(|e| format!("原子替换失败: {}", e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
fs::rename(&tmp, path).map_err(|e| format!("原子替换失败: {}", e))?;
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,17 @@ mod app_config;
|
|||||||
mod codex_config;
|
mod codex_config;
|
||||||
mod commands;
|
mod commands;
|
||||||
mod config;
|
mod config;
|
||||||
|
mod vscode;
|
||||||
mod migration;
|
mod migration;
|
||||||
mod provider;
|
mod provider;
|
||||||
mod store;
|
mod store;
|
||||||
|
|
||||||
use store::AppState;
|
use store::AppState;
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
use tauri::RunEvent;
|
||||||
use tauri::{
|
use tauri::{
|
||||||
menu::{CheckMenuItem, Menu, MenuBuilder, MenuItem},
|
menu::{CheckMenuItem, Menu, MenuBuilder, MenuItem},
|
||||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
tray::{TrayIconBuilder, TrayIconEvent},
|
||||||
};
|
};
|
||||||
use tauri::{Emitter, Manager};
|
use tauri::{Emitter, Manager};
|
||||||
|
|
||||||
@@ -25,6 +28,11 @@ fn create_tray_menu(
|
|||||||
|
|
||||||
let mut menu_builder = MenuBuilder::new(app);
|
let mut menu_builder = MenuBuilder::new(app);
|
||||||
|
|
||||||
|
// 顶部:打开主界面
|
||||||
|
let show_main_item = MenuItem::with_id(app, "show_main", "打开主界面", true, None::<&str>)
|
||||||
|
.map_err(|e| format!("创建打开主界面菜单失败: {}", e))?;
|
||||||
|
menu_builder = menu_builder.item(&show_main_item).separator();
|
||||||
|
|
||||||
// 直接添加所有供应商到主菜单(扁平化结构,更简单可靠)
|
// 直接添加所有供应商到主菜单(扁平化结构,更简单可靠)
|
||||||
if let Some(claude_manager) = config.get_manager(&crate::app_config::AppType::Claude) {
|
if let Some(claude_manager) = config.get_manager(&crate::app_config::AppType::Claude) {
|
||||||
// 添加Claude标题(禁用状态,仅作为分组标识)
|
// 添加Claude标题(禁用状态,仅作为分组标识)
|
||||||
@@ -112,6 +120,13 @@ fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
|
|||||||
log::info!("处理托盘菜单事件: {}", event_id);
|
log::info!("处理托盘菜单事件: {}", event_id);
|
||||||
|
|
||||||
match event_id {
|
match event_id {
|
||||||
|
"show_main" => {
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
let _ = window.unminimize();
|
||||||
|
let _ = window.show();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
"quit" => {
|
"quit" => {
|
||||||
log::info!("退出应用");
|
log::info!("退出应用");
|
||||||
app.exit(0);
|
app.exit(0);
|
||||||
@@ -130,7 +145,9 @@ fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
|
|||||||
provider_id,
|
provider_id,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{ log::error!("切换Claude供应商失败: {}", e); }
|
{
|
||||||
|
log::error!("切换Claude供应商失败: {}", e);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
id if id.starts_with("codex_") => {
|
id if id.starts_with("codex_") => {
|
||||||
@@ -147,7 +164,9 @@ fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
|
|||||||
provider_id,
|
provider_id,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{ log::error!("切换Codex供应商失败: {}", e); }
|
{
|
||||||
|
log::error!("切换Codex供应商失败: {}", e);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
@@ -156,6 +175,8 @@ fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
/// 内部切换供应商函数
|
/// 内部切换供应商函数
|
||||||
async fn switch_provider_internal(
|
async fn switch_provider_internal(
|
||||||
app: &tauri::AppHandle,
|
app: &tauri::AppHandle,
|
||||||
@@ -215,7 +236,15 @@ async fn update_tray_menu(
|
|||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
let builder = tauri::Builder::default()
|
||||||
|
// 拦截窗口关闭:仅隐藏窗口,保持进程与托盘常驻
|
||||||
|
.on_window_event(|window, event| match event {
|
||||||
|
tauri::WindowEvent::CloseRequested { api, .. } => {
|
||||||
|
api.prevent_close();
|
||||||
|
let _ = window.hide();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
})
|
||||||
.plugin(tauri_plugin_process::init())
|
.plugin(tauri_plugin_process::init())
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
@@ -290,33 +319,23 @@ pub fn run() {
|
|||||||
// 创建动态托盘菜单
|
// 创建动态托盘菜单
|
||||||
let menu = create_tray_menu(&app.handle(), &app_state)?;
|
let menu = create_tray_menu(&app.handle(), &app_state)?;
|
||||||
|
|
||||||
let _tray = TrayIconBuilder::with_id("main")
|
// 构建托盘
|
||||||
.on_tray_icon_event(|tray, event| match event {
|
let mut tray_builder = TrayIconBuilder::with_id("main")
|
||||||
TrayIconEvent::Click {
|
.on_tray_icon_event(|_tray, event| match event {
|
||||||
button: MouseButton::Left,
|
// 左键点击已通过 show_menu_on_left_click(true) 打开菜单,这里不再额外处理
|
||||||
button_state: MouseButtonState::Up,
|
TrayIconEvent::Click { .. } => {}
|
||||||
..
|
_ => log::debug!("unhandled event {event:?}"),
|
||||||
} => {
|
|
||||||
log::info!("left click pressed and released");
|
|
||||||
// 在这个例子中,当点击托盘图标时,将展示并聚焦于主窗口
|
|
||||||
let app = tray.app_handle();
|
|
||||||
if let Some(window) = app.get_webview_window("main") {
|
|
||||||
let _ = window.unminimize();
|
|
||||||
let _ = window.show();
|
|
||||||
let _ = window.set_focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
log::debug!("unhandled event {event:?}");
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.menu(&menu)
|
.menu(&menu)
|
||||||
.on_menu_event(|app, event| {
|
.on_menu_event(|app, event| {
|
||||||
handle_tray_menu_event(app, &event.id.0);
|
handle_tray_menu_event(app, &event.id.0);
|
||||||
})
|
})
|
||||||
.icon(app.default_window_icon().unwrap().clone())
|
.show_menu_on_left_click(true);
|
||||||
.show_menu_on_left_click(true)
|
|
||||||
.build(app)?;
|
// 统一使用应用默认图标;待托盘模板图标就绪后再启用
|
||||||
|
tray_builder = tray_builder.icon(app.default_window_icon().unwrap().clone());
|
||||||
|
|
||||||
|
let _tray = tray_builder.build(app)?;
|
||||||
// 将同一个实例注入到全局状态,避免重复创建导致的不一致
|
// 将同一个实例注入到全局状态,避免重复创建导致的不一致
|
||||||
app.manage(app_state);
|
app.manage(app_state);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -339,8 +358,33 @@ pub fn run() {
|
|||||||
commands::get_settings,
|
commands::get_settings,
|
||||||
commands::save_settings,
|
commands::save_settings,
|
||||||
commands::check_for_updates,
|
commands::check_for_updates,
|
||||||
|
commands::get_vscode_settings_status,
|
||||||
|
commands::read_vscode_settings,
|
||||||
|
commands::write_vscode_settings,
|
||||||
update_tray_menu,
|
update_tray_menu,
|
||||||
])
|
]);
|
||||||
.run(tauri::generate_context!())
|
|
||||||
|
let app = builder
|
||||||
|
.build(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|
||||||
|
app.run(|app_handle, event| {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
// macOS 在 Dock 图标被点击并重新激活应用时会触发 Reopen 事件,这里手动恢复主窗口
|
||||||
|
match event {
|
||||||
|
RunEvent::Reopen { .. } => {
|
||||||
|
if let Some(window) = app_handle.get_webview_window("main") {
|
||||||
|
let _ = window.unminimize();
|
||||||
|
let _ = window.show();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
{
|
||||||
|
let _ = (app_handle, event);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,8 +148,7 @@ pub fn migrate_copies_into_config(config: &mut MultiAppConfig) -> Result<bool, S
|
|||||||
// 如果已迁移过则跳过;若目录不存在则先创建,避免新装用户写入标记时失败
|
// 如果已迁移过则跳过;若目录不存在则先创建,避免新装用户写入标记时失败
|
||||||
let marker = get_marker_path();
|
let marker = get_marker_path();
|
||||||
if let Some(parent) = marker.parent() {
|
if let Some(parent) = marker.parent() {
|
||||||
std::fs::create_dir_all(parent)
|
std::fs::create_dir_all(parent).map_err(|e| format!("创建迁移标记目录失败: {}", e))?;
|
||||||
.map_err(|e| format!("创建迁移标记目录失败: {}", e))?;
|
|
||||||
}
|
}
|
||||||
if marker.exists() {
|
if marker.exists() {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
|
|||||||
61
src-tauri/src/vscode.rs
Normal file
61
src-tauri/src/vscode.rs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
use std::path::{PathBuf};
|
||||||
|
|
||||||
|
/// 枚举可能的 VS Code 发行版配置目录名称
|
||||||
|
fn vscode_product_dirs() -> Vec<&'static str> {
|
||||||
|
vec![
|
||||||
|
"Code", // VS Code Stable
|
||||||
|
"Code - Insiders", // VS Code Insiders
|
||||||
|
"VSCodium", // VSCodium
|
||||||
|
"Code - OSS", // OSS 发行版
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取 VS Code 用户 settings.json 的候选路径列表(按优先级排序)
|
||||||
|
pub fn candidate_settings_paths() -> Vec<PathBuf> {
|
||||||
|
let mut paths = Vec::new();
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
if let Some(home) = dirs::home_dir() {
|
||||||
|
for prod in vscode_product_dirs() {
|
||||||
|
paths.push(
|
||||||
|
home.join("Library").join("Application Support").join(prod).join("User").join("settings.json")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
// Windows: %APPDATA%\Code\User\settings.json
|
||||||
|
if let Some(roaming) = dirs::config_dir() {
|
||||||
|
for prod in vscode_product_dirs() {
|
||||||
|
paths.push(roaming.join(prod).join("User").join("settings.json"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(unix, not(target_os = "macos")))]
|
||||||
|
{
|
||||||
|
// Linux: ~/.config/Code/User/settings.json
|
||||||
|
if let Some(config) = dirs::config_dir() {
|
||||||
|
for prod in vscode_product_dirs() {
|
||||||
|
paths.push(config.join(prod).join("User").join("settings.json"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
paths
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 返回第一个存在的 settings.json 路径
|
||||||
|
pub fn find_existing_settings() -> Option<PathBuf> {
|
||||||
|
for p in candidate_settings_paths() {
|
||||||
|
if let Ok(meta) = std::fs::metadata(&p) {
|
||||||
|
if meta.is_file() {
|
||||||
|
return Some(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
147
src/App.tsx
147
src/App.tsx
@@ -11,9 +11,14 @@ import { UpdateBadge } from "./components/UpdateBadge";
|
|||||||
import { Plus, Settings, Moon, Sun } from "lucide-react";
|
import { Plus, Settings, Moon, Sun } from "lucide-react";
|
||||||
import { buttonStyles } from "./lib/styles";
|
import { buttonStyles } from "./lib/styles";
|
||||||
import { useDarkMode } from "./hooks/useDarkMode";
|
import { useDarkMode } from "./hooks/useDarkMode";
|
||||||
|
import { extractErrorMessage } from "./utils/errorUtils";
|
||||||
|
import { applyProviderToVSCode } from "./utils/vscodeSettings";
|
||||||
|
import { getCodexBaseUrl } from "./utils/providerConfigUtils";
|
||||||
|
import { useVSCodeAutoSync } from "./hooks/useVSCodeAutoSync";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { isDarkMode, toggleDarkMode } = useDarkMode();
|
const { isDarkMode, toggleDarkMode } = useDarkMode();
|
||||||
|
const { isAutoSyncEnabled } = useVSCodeAutoSync();
|
||||||
const [activeApp, setActiveApp] = useState<AppType>("claude");
|
const [activeApp, setActiveApp] = useState<AppType>("claude");
|
||||||
const [providers, setProviders] = useState<Record<string, Provider>>({});
|
const [providers, setProviders] = useState<Record<string, Provider>>({});
|
||||||
const [currentProviderId, setCurrentProviderId] = useState<string>("");
|
const [currentProviderId, setCurrentProviderId] = useState<string>("");
|
||||||
@@ -75,7 +80,7 @@ function App() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 监听托盘切换事件
|
// 监听托盘切换事件(包括菜单切换)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let unlisten: (() => void) | null = null;
|
let unlisten: (() => void) | null = null;
|
||||||
|
|
||||||
@@ -90,6 +95,11 @@ function App() {
|
|||||||
if (data.appType === activeApp) {
|
if (data.appType === activeApp) {
|
||||||
await loadProviders();
|
await loadProviders();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 若为 Codex 且开启自动同步,则静默同步到 VS Code(覆盖)
|
||||||
|
if (data.appType === "codex" && isAutoSyncEnabled) {
|
||||||
|
await syncCodexToVSCode(data.providerId, true);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("设置供应商切换监听器失败:", error);
|
console.error("设置供应商切换监听器失败:", error);
|
||||||
@@ -104,7 +114,7 @@ function App() {
|
|||||||
unlisten();
|
unlisten();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [activeApp]); // 依赖activeApp,切换应用时重新设置监听器
|
}, [activeApp, isAutoSyncEnabled]);
|
||||||
|
|
||||||
const loadProviders = async () => {
|
const loadProviders = async () => {
|
||||||
const loadedProviders = await window.api.getProviders(activeApp);
|
const loadedProviders = await window.api.getProviders(activeApp);
|
||||||
@@ -148,7 +158,11 @@ function App() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("更新供应商失败:", error);
|
console.error("更新供应商失败:", error);
|
||||||
setEditingProviderId(null);
|
setEditingProviderId(null);
|
||||||
showNotification("保存失败,请重试", "error");
|
const errorMessage = extractErrorMessage(error);
|
||||||
|
const message = errorMessage
|
||||||
|
? `保存失败:${errorMessage}`
|
||||||
|
: "保存失败,请重试";
|
||||||
|
showNotification(message, "error", errorMessage ? 6000 : 3000);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -169,6 +183,64 @@ function App() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 同步Codex供应商到VS Code设置(静默覆盖)
|
||||||
|
const syncCodexToVSCode = async (providerId: string, silent = false) => {
|
||||||
|
try {
|
||||||
|
const status = await window.api.getVSCodeSettingsStatus();
|
||||||
|
if (!status.exists) {
|
||||||
|
if (!silent) {
|
||||||
|
showNotification(
|
||||||
|
"未找到 VS Code 用户设置文件 (settings.json)",
|
||||||
|
"error",
|
||||||
|
3000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = await window.api.readVSCodeSettings();
|
||||||
|
const provider = providers[providerId];
|
||||||
|
const isOfficial = provider?.category === "official";
|
||||||
|
|
||||||
|
// 非官方供应商需要解析 base_url(使用公共工具函数)
|
||||||
|
let baseUrl: string | undefined = undefined;
|
||||||
|
if (!isOfficial) {
|
||||||
|
const parsed = getCodexBaseUrl(provider);
|
||||||
|
if (!parsed) {
|
||||||
|
if (!silent) {
|
||||||
|
showNotification(
|
||||||
|
"当前配置缺少 base_url,无法写入 VS Code",
|
||||||
|
"error",
|
||||||
|
4000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
baseUrl = parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedSettings = applyProviderToVSCode(raw, {
|
||||||
|
baseUrl,
|
||||||
|
isOfficial,
|
||||||
|
});
|
||||||
|
if (updatedSettings !== raw) {
|
||||||
|
await window.api.writeVSCodeSettings(updatedSettings);
|
||||||
|
if (!silent) {
|
||||||
|
showNotification("已同步到 VS Code", "success", 1500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 触发providers重新加载,以更新VS Code按钮状态
|
||||||
|
await loadProviders();
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("同步到VS Code失败:", error);
|
||||||
|
if (!silent) {
|
||||||
|
const errorMessage = error?.message || "同步 VS Code 失败";
|
||||||
|
showNotification(errorMessage, "error", 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSwitchProvider = async (id: string) => {
|
const handleSwitchProvider = async (id: string) => {
|
||||||
const success = await window.api.switchProvider(id, activeApp);
|
const success = await window.api.switchProvider(id, activeApp);
|
||||||
if (success) {
|
if (success) {
|
||||||
@@ -182,6 +254,11 @@ function App() {
|
|||||||
);
|
);
|
||||||
// 更新托盘菜单
|
// 更新托盘菜单
|
||||||
await window.api.updateTrayMenu();
|
await window.api.updateTrayMenu();
|
||||||
|
|
||||||
|
// Codex: 切换供应商后,只在自动同步启用时同步到 VS Code
|
||||||
|
if (activeApp === "codex" && isAutoSyncEnabled) {
|
||||||
|
await syncCodexToVSCode(id, true); // silent模式,不显示通知
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
showNotification("切换失败,请检查配置", "error");
|
showNotification("切换失败,请检查配置", "error");
|
||||||
}
|
}
|
||||||
@@ -206,14 +283,20 @@ function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col bg-gray-50 dark:bg-gray-950">
|
<div className="h-screen flex flex-col bg-gray-50 dark:bg-gray-950">
|
||||||
{/* Linear 风格的顶部导航 */}
|
{/* 顶部导航区域 - 固定高度 */}
|
||||||
<header className="bg-white border-b border-gray-200 dark:bg-gray-900 dark:border-gray-800 px-6 py-4">
|
<header className="flex-shrink-0 bg-white border-b border-gray-200 dark:bg-gray-900 dark:border-gray-800 px-6 py-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h1 className="text-xl font-semibold text-blue-500 dark:text-blue-400">
|
<a
|
||||||
|
href="https://github.com/farion1231/cc-switch"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xl font-semibold text-blue-500 dark:text-blue-400 hover:text-blue-600 dark:hover:text-blue-300 transition-colors"
|
||||||
|
title="在 GitHub 上查看"
|
||||||
|
>
|
||||||
CC Switch
|
CC Switch
|
||||||
</h1>
|
</a>
|
||||||
<button
|
<button
|
||||||
onClick={toggleDarkMode}
|
onClick={toggleDarkMode}
|
||||||
className={buttonStyles.icon}
|
className={buttonStyles.icon}
|
||||||
@@ -247,29 +330,33 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* 主内容区域 */}
|
{/* 主内容区域 - 独立滚动 */}
|
||||||
<main className="flex-1 p-6">
|
<main className="flex-1 overflow-y-scroll">
|
||||||
<div className="max-w-4xl mx-auto">
|
<div className="pt-3 px-6 pb-6">
|
||||||
{/* 通知组件 */}
|
<div className="max-w-4xl mx-auto">
|
||||||
{notification && (
|
{/* 通知组件 - 相对于视窗定位 */}
|
||||||
<div
|
{notification && (
|
||||||
className={`fixed top-6 left-1/2 transform -translate-x-1/2 z-50 px-4 py-3 rounded-lg shadow-lg transition-all duration-300 ${
|
<div
|
||||||
notification.type === "error"
|
className={`fixed top-20 left-1/2 transform -translate-x-1/2 z-50 px-4 py-3 rounded-lg shadow-lg transition-all duration-300 ${
|
||||||
? "bg-red-500 text-white"
|
notification.type === "error"
|
||||||
: "bg-green-500 text-white"
|
? "bg-red-500 text-white"
|
||||||
} ${isNotificationVisible ? "opacity-100 translate-y-0" : "opacity-0 -translate-y-2"}`}
|
: "bg-green-500 text-white"
|
||||||
>
|
} ${isNotificationVisible ? "opacity-100 translate-y-0" : "opacity-0 -translate-y-2"}`}
|
||||||
{notification.message}
|
>
|
||||||
</div>
|
{notification.message}
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<ProviderList
|
<ProviderList
|
||||||
providers={providers}
|
providers={providers}
|
||||||
currentProviderId={currentProviderId}
|
currentProviderId={currentProviderId}
|
||||||
onSwitch={handleSwitchProvider}
|
onSwitch={handleSwitchProvider}
|
||||||
onDelete={handleDeleteProvider}
|
onDelete={handleDeleteProvider}
|
||||||
onEdit={setEditingProviderId}
|
onEdit={setEditingProviderId}
|
||||||
/>
|
appType={activeApp}
|
||||||
|
onNotify={showNotification}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleSwitch("claude")}
|
onClick={() => handleSwitch("claude")}
|
||||||
className={`inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
|
className={`group inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
|
||||||
activeApp === "claude"
|
activeApp === "claude"
|
||||||
? "bg-white text-gray-900 shadow-sm dark:bg-gray-900 dark:text-gray-100 dark:shadow-none"
|
? "bg-white text-gray-900 shadow-sm dark:bg-gray-900 dark:text-gray-100 dark:shadow-none"
|
||||||
: "text-gray-500 hover:text-gray-900 hover:bg-white/50 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800/60"
|
: "text-gray-500 hover:text-gray-900 hover:bg-white/50 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800/60"
|
||||||
@@ -26,7 +26,9 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
|
|||||||
<ClaudeIcon
|
<ClaudeIcon
|
||||||
size={16}
|
size={16}
|
||||||
className={
|
className={
|
||||||
activeApp === "claude" ? "text-[#D97757] dark:text-[#D97757]" : ""
|
activeApp === "claude"
|
||||||
|
? "text-[#D97757] dark:text-[#D97757] transition-colors duration-200"
|
||||||
|
: "text-gray-500 dark:text-gray-400 group-hover:text-[#D97757] dark:group-hover:text-[#D97757] transition-colors duration-200"
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<span>Claude</span>
|
<span>Claude</span>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import React, { useRef, useEffect } from "react";
|
import React, { useRef, useEffect, useMemo } from "react";
|
||||||
import { EditorView, basicSetup } from "codemirror";
|
import { EditorView, basicSetup } from "codemirror";
|
||||||
import { json } from "@codemirror/lang-json";
|
import { json } from "@codemirror/lang-json";
|
||||||
import { oneDark } from "@codemirror/theme-one-dark";
|
import { oneDark } from "@codemirror/theme-one-dark";
|
||||||
import { EditorState } from "@codemirror/state";
|
import { EditorState } from "@codemirror/state";
|
||||||
import { placeholder } from "@codemirror/view";
|
import { placeholder } from "@codemirror/view";
|
||||||
|
import { linter, Diagnostic } from "@codemirror/lint";
|
||||||
|
|
||||||
interface JsonEditorProps {
|
interface JsonEditorProps {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -11,6 +12,7 @@ interface JsonEditorProps {
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
darkMode?: boolean;
|
darkMode?: boolean;
|
||||||
rows?: number;
|
rows?: number;
|
||||||
|
showValidation?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const JsonEditor: React.FC<JsonEditorProps> = ({
|
const JsonEditor: React.FC<JsonEditorProps> = ({
|
||||||
@@ -19,10 +21,50 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
|
|||||||
placeholder: placeholderText = "",
|
placeholder: placeholderText = "",
|
||||||
darkMode = false,
|
darkMode = false,
|
||||||
rows = 12,
|
rows = 12,
|
||||||
|
showValidation = true,
|
||||||
}) => {
|
}) => {
|
||||||
const editorRef = useRef<HTMLDivElement>(null);
|
const editorRef = useRef<HTMLDivElement>(null);
|
||||||
const viewRef = useRef<EditorView | null>(null);
|
const viewRef = useRef<EditorView | null>(null);
|
||||||
|
|
||||||
|
// JSON linter 函数
|
||||||
|
const jsonLinter = useMemo(
|
||||||
|
() =>
|
||||||
|
linter((view) => {
|
||||||
|
const diagnostics: Diagnostic[] = [];
|
||||||
|
if (!showValidation) return diagnostics;
|
||||||
|
|
||||||
|
const doc = view.state.doc.toString();
|
||||||
|
if (!doc.trim()) return diagnostics;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(doc);
|
||||||
|
// 检查是否是JSON对象
|
||||||
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||||
|
// 格式正确
|
||||||
|
} else {
|
||||||
|
diagnostics.push({
|
||||||
|
from: 0,
|
||||||
|
to: doc.length,
|
||||||
|
severity: "error",
|
||||||
|
message: "配置必须是JSON对象,不能是数组或其他类型",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 简单处理JSON解析错误
|
||||||
|
const message = e instanceof SyntaxError ? e.message : "JSON格式错误";
|
||||||
|
diagnostics.push({
|
||||||
|
from: 0,
|
||||||
|
to: doc.length,
|
||||||
|
severity: "error",
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return diagnostics;
|
||||||
|
}),
|
||||||
|
[showValidation],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editorRef.current) return;
|
if (!editorRef.current) return;
|
||||||
|
|
||||||
@@ -43,6 +85,7 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
|
|||||||
json(),
|
json(),
|
||||||
placeholder(placeholderText || ""),
|
placeholder(placeholderText || ""),
|
||||||
sizingTheme,
|
sizingTheme,
|
||||||
|
jsonLinter,
|
||||||
EditorView.updateListener.of((update) => {
|
EditorView.updateListener.of((update) => {
|
||||||
if (update.docChanged) {
|
if (update.docChanged) {
|
||||||
const newValue = update.state.doc.toString();
|
const newValue = update.state.doc.toString();
|
||||||
@@ -75,7 +118,7 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
|
|||||||
view.destroy();
|
view.destroy();
|
||||||
viewRef.current = null;
|
viewRef.current = null;
|
||||||
};
|
};
|
||||||
}, [darkMode, rows]); // 依赖项中不包含 onChange 和 placeholder,避免不必要的重建
|
}, [darkMode, rows, jsonLinter]); // 依赖项中不包含 onChange 和 placeholder,避免不必要的重建
|
||||||
|
|
||||||
// 当 value 从外部改变时更新编辑器内容
|
// 当 value 从外部改变时更新编辑器内容
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
import { Provider, ProviderCategory } from "../types";
|
import { Provider, ProviderCategory } from "../types";
|
||||||
import { AppType } from "../lib/tauri-api";
|
import { AppType } from "../lib/tauri-api";
|
||||||
import {
|
import {
|
||||||
updateCoAuthoredSetting,
|
updateCommonConfigSnippet,
|
||||||
checkCoAuthoredSetting,
|
hasCommonConfigSnippet,
|
||||||
getApiKeyFromConfig,
|
getApiKeyFromConfig,
|
||||||
hasApiKeyField,
|
hasApiKeyField,
|
||||||
setApiKeyInConfig,
|
setApiKeyInConfig,
|
||||||
|
updateTomlCommonConfigSnippet,
|
||||||
|
hasTomlCommonConfigSnippet,
|
||||||
|
validateJsonConfig,
|
||||||
} from "../utils/providerConfigUtils";
|
} from "../utils/providerConfigUtils";
|
||||||
import { providerPresets } from "../config/providerPresets";
|
import { providerPresets } from "../config/providerPresets";
|
||||||
import { codexProviderPresets } from "../config/codexProviderPresets";
|
import { codexProviderPresets } from "../config/codexProviderPresets";
|
||||||
@@ -18,6 +21,14 @@ import KimiModelSelector from "./ProviderForm/KimiModelSelector";
|
|||||||
import { X, AlertCircle, Save } from "lucide-react";
|
import { X, AlertCircle, Save } from "lucide-react";
|
||||||
// 分类仅用于控制少量交互(如官方禁用 API Key),不显示介绍组件
|
// 分类仅用于控制少量交互(如官方禁用 API Key),不显示介绍组件
|
||||||
|
|
||||||
|
const COMMON_CONFIG_STORAGE_KEY = "cc-switch:common-config-snippet";
|
||||||
|
const CODEX_COMMON_CONFIG_STORAGE_KEY = "cc-switch:codex-common-config-snippet";
|
||||||
|
const DEFAULT_COMMON_CONFIG_SNIPPET = `{
|
||||||
|
"includeCoAuthoredBy": false
|
||||||
|
}`;
|
||||||
|
const DEFAULT_CODEX_COMMON_CONFIG_SNIPPET = `# Common Codex config
|
||||||
|
# Add your common TOML configuration here`;
|
||||||
|
|
||||||
interface ProviderFormProps {
|
interface ProviderFormProps {
|
||||||
appType?: AppType;
|
appType?: AppType;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -57,14 +68,27 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
const [baseUrl, setBaseUrl] = useState(""); // 新增:基础 URL 状态
|
const [baseUrl, setBaseUrl] = useState(""); // 新增:基础 URL 状态
|
||||||
|
|
||||||
// Codex 特有的状态
|
// Codex 特有的状态
|
||||||
const [codexAuth, setCodexAuth] = useState("");
|
const [codexAuth, setCodexAuthState] = useState("");
|
||||||
const [codexConfig, setCodexConfig] = useState("");
|
const [codexConfig, setCodexConfigState] = useState("");
|
||||||
const [codexApiKey, setCodexApiKey] = useState("");
|
const [codexApiKey, setCodexApiKey] = useState("");
|
||||||
// -1 表示自定义,null 表示未选择,>= 0 表示预设索引
|
// -1 表示自定义,null 表示未选择,>= 0 表示预设索引
|
||||||
const [selectedCodexPreset, setSelectedCodexPreset] = useState<number | null>(
|
const [selectedCodexPreset, setSelectedCodexPreset] = useState<number | null>(
|
||||||
showPresets && isCodex ? -1 : null,
|
showPresets && isCodex ? -1 : null,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const setCodexAuth = (value: string) => {
|
||||||
|
setCodexAuthState(value);
|
||||||
|
setCodexAuthError(validateCodexAuth(value));
|
||||||
|
};
|
||||||
|
|
||||||
|
const setCodexConfig = (value: string) => {
|
||||||
|
setCodexConfigState(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setCodexCommonConfigSnippet = (value: string) => {
|
||||||
|
setCodexCommonConfigSnippetState(value);
|
||||||
|
};
|
||||||
|
|
||||||
// 初始化 Codex 配置
|
// 初始化 Codex 配置
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isCodex && initialData) {
|
if (isCodex && initialData) {
|
||||||
@@ -85,18 +109,86 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
}, [isCodex, initialData]);
|
}, [isCodex, initialData]);
|
||||||
|
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [disableCoAuthored, setDisableCoAuthored] = useState(false);
|
const [useCommonConfig, setUseCommonConfig] = useState(false);
|
||||||
|
const [commonConfigSnippet, setCommonConfigSnippet] = useState<string>(() => {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return DEFAULT_COMMON_CONFIG_SNIPPET;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const stored = window.localStorage.getItem(COMMON_CONFIG_STORAGE_KEY);
|
||||||
|
if (stored && stored.trim()) {
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore localStorage 读取失败
|
||||||
|
}
|
||||||
|
return DEFAULT_COMMON_CONFIG_SNIPPET;
|
||||||
|
});
|
||||||
|
const [commonConfigError, setCommonConfigError] = useState("");
|
||||||
|
const [settingsConfigError, setSettingsConfigError] = useState("");
|
||||||
|
// 用于跟踪是否正在通过通用配置更新
|
||||||
|
const isUpdatingFromCommonConfig = useRef(false);
|
||||||
|
|
||||||
|
// Codex 通用配置状态
|
||||||
|
const [useCodexCommonConfig, setUseCodexCommonConfig] = useState(false);
|
||||||
|
const [codexCommonConfigSnippet, setCodexCommonConfigSnippetState] =
|
||||||
|
useState<string>(() => {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return DEFAULT_CODEX_COMMON_CONFIG_SNIPPET;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const stored = window.localStorage.getItem(
|
||||||
|
CODEX_COMMON_CONFIG_STORAGE_KEY,
|
||||||
|
);
|
||||||
|
if (stored && stored.trim()) {
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore localStorage 读取失败
|
||||||
|
}
|
||||||
|
return DEFAULT_CODEX_COMMON_CONFIG_SNIPPET;
|
||||||
|
});
|
||||||
|
const [codexCommonConfigError, setCodexCommonConfigError] = useState("");
|
||||||
|
const isUpdatingFromCodexCommonConfig = useRef(false);
|
||||||
// -1 表示自定义,null 表示未选择,>= 0 表示预设索引
|
// -1 表示自定义,null 表示未选择,>= 0 表示预设索引
|
||||||
const [selectedPreset, setSelectedPreset] = useState<number | null>(
|
const [selectedPreset, setSelectedPreset] = useState<number | null>(
|
||||||
showPresets ? -1 : null,
|
showPresets ? -1 : null,
|
||||||
);
|
);
|
||||||
const [apiKey, setApiKey] = useState("");
|
const [apiKey, setApiKey] = useState("");
|
||||||
|
const [codexAuthError, setCodexAuthError] = useState("");
|
||||||
|
|
||||||
// Kimi 模型选择状态
|
// Kimi 模型选择状态
|
||||||
const [kimiAnthropicModel, setKimiAnthropicModel] = useState("");
|
const [kimiAnthropicModel, setKimiAnthropicModel] = useState("");
|
||||||
const [kimiAnthropicSmallFastModel, setKimiAnthropicSmallFastModel] =
|
const [kimiAnthropicSmallFastModel, setKimiAnthropicSmallFastModel] =
|
||||||
useState("");
|
useState("");
|
||||||
|
|
||||||
|
const validateSettingsConfig = (value: string): string => {
|
||||||
|
return validateJsonConfig(value, "配置内容");
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateCodexAuth = (value: string): string => {
|
||||||
|
if (!value.trim()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||||
|
return "auth.json 必须是 JSON 对象";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
} catch {
|
||||||
|
return "auth.json 格式错误,请检查JSON语法";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSettingsConfigValue = (value: string) => {
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
settingsConfig: value,
|
||||||
|
}));
|
||||||
|
setSettingsConfigError(validateSettingsConfig(value));
|
||||||
|
};
|
||||||
|
|
||||||
// 初始化自定义模式的默认配置
|
// 初始化自定义模式的默认配置
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
@@ -115,44 +207,67 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
// ANTHROPIC_SMALL_FAST_MODEL: "your-fast-model-name"
|
// ANTHROPIC_SMALL_FAST_MODEL: "your-fast-model-name"
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
const templateString = JSON.stringify(customTemplate, null, 2);
|
||||||
|
|
||||||
setFormData((prev) => ({
|
updateSettingsConfigValue(templateString);
|
||||||
...prev,
|
|
||||||
settingsConfig: JSON.stringify(customTemplate, null, 2),
|
|
||||||
}));
|
|
||||||
setApiKey("");
|
setApiKey("");
|
||||||
}
|
}
|
||||||
}, []); // 只在组件挂载时执行一次
|
}, []); // 只在组件挂载时执行一次
|
||||||
|
|
||||||
// 初始化时检查禁用签名状态
|
// 初始化时检查通用配置片段
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialData) {
|
if (initialData) {
|
||||||
const configString = JSON.stringify(initialData.settingsConfig, null, 2);
|
if (!isCodex) {
|
||||||
const hasCoAuthoredDisabled = checkCoAuthoredSetting(configString);
|
const configString = JSON.stringify(
|
||||||
setDisableCoAuthored(hasCoAuthoredDisabled);
|
initialData.settingsConfig,
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
const hasCommon = hasCommonConfigSnippet(
|
||||||
|
configString,
|
||||||
|
commonConfigSnippet,
|
||||||
|
);
|
||||||
|
setUseCommonConfig(hasCommon);
|
||||||
|
setSettingsConfigError(validateSettingsConfig(configString));
|
||||||
|
|
||||||
// 初始化模型配置(编辑模式)
|
// 初始化模型配置(编辑模式)
|
||||||
if (
|
if (
|
||||||
initialData.settingsConfig &&
|
initialData.settingsConfig &&
|
||||||
typeof initialData.settingsConfig === "object"
|
typeof initialData.settingsConfig === "object"
|
||||||
) {
|
) {
|
||||||
const config = initialData.settingsConfig as {
|
const config = initialData.settingsConfig as {
|
||||||
env?: Record<string, any>;
|
env?: Record<string, any>;
|
||||||
};
|
};
|
||||||
if (config.env) {
|
if (config.env) {
|
||||||
setClaudeModel(config.env.ANTHROPIC_MODEL || "");
|
setClaudeModel(config.env.ANTHROPIC_MODEL || "");
|
||||||
setClaudeSmallFastModel(config.env.ANTHROPIC_SMALL_FAST_MODEL || "");
|
setClaudeSmallFastModel(
|
||||||
setBaseUrl(config.env.ANTHROPIC_BASE_URL || ""); // 初始化基础 URL
|
config.env.ANTHROPIC_SMALL_FAST_MODEL || "",
|
||||||
|
);
|
||||||
|
setBaseUrl(config.env.ANTHROPIC_BASE_URL || ""); // 初始化基础 URL
|
||||||
|
|
||||||
// 初始化 Kimi 模型选择
|
// 初始化 Kimi 模型选择
|
||||||
setKimiAnthropicModel(config.env.ANTHROPIC_MODEL || "");
|
setKimiAnthropicModel(config.env.ANTHROPIC_MODEL || "");
|
||||||
setKimiAnthropicSmallFastModel(
|
setKimiAnthropicSmallFastModel(
|
||||||
config.env.ANTHROPIC_SMALL_FAST_MODEL || "",
|
config.env.ANTHROPIC_SMALL_FAST_MODEL || "",
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Codex 初始化时检查 TOML 通用配置
|
||||||
|
const hasCommon = hasTomlCommonConfigSnippet(
|
||||||
|
codexConfig,
|
||||||
|
codexCommonConfigSnippet,
|
||||||
|
);
|
||||||
|
setUseCodexCommonConfig(hasCommon);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [initialData]);
|
}, [
|
||||||
|
initialData,
|
||||||
|
commonConfigSnippet,
|
||||||
|
codexCommonConfigSnippet,
|
||||||
|
isCodex,
|
||||||
|
codexConfig,
|
||||||
|
]);
|
||||||
|
|
||||||
// 当选择预设变化时,同步类别
|
// 当选择预设变化时,同步类别
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -178,6 +293,23 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
}
|
}
|
||||||
}, [showPresets, isCodex, selectedPreset, selectedCodexPreset]);
|
}, [showPresets, isCodex, selectedPreset, selectedCodexPreset]);
|
||||||
|
|
||||||
|
// 同步本地存储的通用配置片段
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
try {
|
||||||
|
if (commonConfigSnippet.trim()) {
|
||||||
|
window.localStorage.setItem(
|
||||||
|
COMMON_CONFIG_STORAGE_KEY,
|
||||||
|
commonConfigSnippet,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
window.localStorage.removeItem(COMMON_CONFIG_STORAGE_KEY);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, [commonConfigSnippet]);
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError("");
|
setError("");
|
||||||
@@ -190,6 +322,12 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
let settingsConfig: Record<string, any>;
|
let settingsConfig: Record<string, any>;
|
||||||
|
|
||||||
if (isCodex) {
|
if (isCodex) {
|
||||||
|
const currentAuthError = validateCodexAuth(codexAuth);
|
||||||
|
setCodexAuthError(currentAuthError);
|
||||||
|
if (currentAuthError) {
|
||||||
|
setError(currentAuthError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Codex: 仅要求 auth.json 必填;config.toml 可为空
|
// Codex: 仅要求 auth.json 必填;config.toml 可为空
|
||||||
if (!codexAuth.trim()) {
|
if (!codexAuth.trim()) {
|
||||||
setError("请填写 auth.json 配置");
|
setError("请填写 auth.json 配置");
|
||||||
@@ -224,6 +362,14 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
const currentSettingsError = validateSettingsConfig(
|
||||||
|
formData.settingsConfig,
|
||||||
|
);
|
||||||
|
setSettingsConfigError(currentSettingsError);
|
||||||
|
if (currentSettingsError) {
|
||||||
|
setError(currentSettingsError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Claude: 原有逻辑
|
// Claude: 原有逻辑
|
||||||
if (!formData.settingsConfig.trim()) {
|
if (!formData.settingsConfig.trim()) {
|
||||||
setError("请填写配置内容");
|
setError("请填写配置内容");
|
||||||
@@ -253,19 +399,18 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
const { name, value } = e.target;
|
const { name, value } = e.target;
|
||||||
|
|
||||||
if (name === "settingsConfig") {
|
if (name === "settingsConfig") {
|
||||||
// 同时检查并同步选择框状态
|
// 只有在不是通过通用配置更新时,才检查并同步选择框状态
|
||||||
const hasCoAuthoredDisabled = checkCoAuthoredSetting(value);
|
if (!isUpdatingFromCommonConfig.current) {
|
||||||
setDisableCoAuthored(hasCoAuthoredDisabled);
|
const hasCommon = hasCommonConfigSnippet(value, commonConfigSnippet);
|
||||||
|
setUseCommonConfig(hasCommon);
|
||||||
|
}
|
||||||
|
|
||||||
// 同步 API Key 输入框显示与值
|
// 同步 API Key 输入框显示与值
|
||||||
const parsedKey = getApiKeyFromConfig(value);
|
const parsedKey = getApiKeyFromConfig(value);
|
||||||
setApiKey(parsedKey);
|
setApiKey(parsedKey);
|
||||||
|
|
||||||
// 不再从 JSON 自动提取或覆盖官网地址,只更新配置内容
|
// 不再从 JSON 自动提取或覆盖官网地址,只更新配置内容
|
||||||
setFormData((prev) => ({
|
updateSettingsConfigValue(value);
|
||||||
...prev,
|
|
||||||
[name]: value,
|
|
||||||
}));
|
|
||||||
} else {
|
} else {
|
||||||
setFormData({
|
setFormData({
|
||||||
...formData,
|
...formData,
|
||||||
@@ -274,19 +419,106 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 处理选择框变化
|
// 处理通用配置开关
|
||||||
const handleCoAuthoredToggle = (checked: boolean) => {
|
const handleCommonConfigToggle = (checked: boolean) => {
|
||||||
setDisableCoAuthored(checked);
|
const { updatedConfig, error: snippetError } = updateCommonConfigSnippet(
|
||||||
|
|
||||||
// 更新JSON配置
|
|
||||||
const updatedConfig = updateCoAuthoredSetting(
|
|
||||||
formData.settingsConfig,
|
formData.settingsConfig,
|
||||||
|
commonConfigSnippet,
|
||||||
checked,
|
checked,
|
||||||
);
|
);
|
||||||
setFormData({
|
|
||||||
...formData,
|
if (snippetError) {
|
||||||
settingsConfig: updatedConfig,
|
setCommonConfigError(snippetError);
|
||||||
});
|
if (snippetError.includes("配置 JSON 解析失败")) {
|
||||||
|
setSettingsConfigError("配置JSON格式错误,请检查语法");
|
||||||
|
}
|
||||||
|
setUseCommonConfig(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCommonConfigError("");
|
||||||
|
setUseCommonConfig(checked);
|
||||||
|
// 标记正在通过通用配置更新
|
||||||
|
isUpdatingFromCommonConfig.current = true;
|
||||||
|
updateSettingsConfigValue(updatedConfig);
|
||||||
|
// 在下一个事件循环中重置标记
|
||||||
|
setTimeout(() => {
|
||||||
|
isUpdatingFromCommonConfig.current = false;
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCommonConfigSnippetChange = (value: string) => {
|
||||||
|
const previousSnippet = commonConfigSnippet;
|
||||||
|
setCommonConfigSnippet(value);
|
||||||
|
|
||||||
|
if (!value.trim()) {
|
||||||
|
setCommonConfigError("");
|
||||||
|
if (useCommonConfig) {
|
||||||
|
const { updatedConfig } = updateCommonConfigSnippet(
|
||||||
|
formData.settingsConfig,
|
||||||
|
previousSnippet,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
// 直接更新 formData,不通过 handleChange
|
||||||
|
updateSettingsConfigValue(updatedConfig);
|
||||||
|
setUseCommonConfig(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证JSON格式
|
||||||
|
const validationError = validateJsonConfig(value, "通用配置片段");
|
||||||
|
if (validationError) {
|
||||||
|
setCommonConfigError(validationError);
|
||||||
|
} else {
|
||||||
|
setCommonConfigError("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 若当前启用通用配置且格式正确,需要替换为最新片段
|
||||||
|
if (useCommonConfig && !validationError) {
|
||||||
|
const removeResult = updateCommonConfigSnippet(
|
||||||
|
formData.settingsConfig,
|
||||||
|
previousSnippet,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
if (removeResult.error) {
|
||||||
|
setCommonConfigError(removeResult.error);
|
||||||
|
if (removeResult.error.includes("配置 JSON 解析失败")) {
|
||||||
|
setSettingsConfigError("配置JSON格式错误,请检查语法");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const addResult = updateCommonConfigSnippet(
|
||||||
|
removeResult.updatedConfig,
|
||||||
|
value,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (addResult.error) {
|
||||||
|
setCommonConfigError(addResult.error);
|
||||||
|
if (addResult.error.includes("配置 JSON 解析失败")) {
|
||||||
|
setSettingsConfigError("配置JSON格式错误,请检查语法");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标记正在通过通用配置更新,避免触发状态检查
|
||||||
|
isUpdatingFromCommonConfig.current = true;
|
||||||
|
updateSettingsConfigValue(addResult.updatedConfig);
|
||||||
|
// 在下一个事件循环中重置标记
|
||||||
|
setTimeout(() => {
|
||||||
|
isUpdatingFromCommonConfig.current = false;
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存通用配置到 localStorage
|
||||||
|
if (!validationError && typeof window !== "undefined") {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(COMMON_CONFIG_STORAGE_KEY, value);
|
||||||
|
} catch {
|
||||||
|
// ignore localStorage 写入失败
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyPreset = (preset: (typeof providerPresets)[0], index: number) => {
|
const applyPreset = (preset: (typeof providerPresets)[0], index: number) => {
|
||||||
@@ -297,6 +529,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
websiteUrl: preset.websiteUrl,
|
websiteUrl: preset.websiteUrl,
|
||||||
settingsConfig: configString,
|
settingsConfig: configString,
|
||||||
});
|
});
|
||||||
|
setSettingsConfigError(validateSettingsConfig(configString));
|
||||||
setCategory(
|
setCategory(
|
||||||
preset.category || (preset.isOfficial ? "official" : undefined),
|
preset.category || (preset.isOfficial ? "official" : undefined),
|
||||||
);
|
);
|
||||||
@@ -308,9 +541,10 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
setApiKey("");
|
setApiKey("");
|
||||||
setBaseUrl(""); // 清空基础 URL
|
setBaseUrl(""); // 清空基础 URL
|
||||||
|
|
||||||
// 同步选择框状态
|
// 同步通用配置状态
|
||||||
const hasCoAuthoredDisabled = checkCoAuthoredSetting(configString);
|
const hasCommon = hasCommonConfigSnippet(configString, commonConfigSnippet);
|
||||||
setDisableCoAuthored(hasCoAuthoredDisabled);
|
setUseCommonConfig(hasCommon);
|
||||||
|
setCommonConfigError("");
|
||||||
|
|
||||||
// 如果预设包含模型配置,初始化模型输入框
|
// 如果预设包含模型配置,初始化模型输入框
|
||||||
if (preset.settingsConfig && typeof preset.settingsConfig === "object") {
|
if (preset.settingsConfig && typeof preset.settingsConfig === "object") {
|
||||||
@@ -347,15 +581,18 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
// ANTHROPIC_SMALL_FAST_MODEL: "your-fast-model-name"
|
// ANTHROPIC_SMALL_FAST_MODEL: "your-fast-model-name"
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
const templateString = JSON.stringify(customTemplate, null, 2);
|
||||||
|
|
||||||
setFormData({
|
setFormData({
|
||||||
name: "",
|
name: "",
|
||||||
websiteUrl: "",
|
websiteUrl: "",
|
||||||
settingsConfig: JSON.stringify(customTemplate, null, 2),
|
settingsConfig: templateString,
|
||||||
});
|
});
|
||||||
|
setSettingsConfigError(validateSettingsConfig(templateString));
|
||||||
setApiKey("");
|
setApiKey("");
|
||||||
setBaseUrl("https://your-api-endpoint.com"); // 设置默认的基础 URL
|
setBaseUrl("https://your-api-endpoint.com"); // 设置默认的基础 URL
|
||||||
setDisableCoAuthored(false);
|
setUseCommonConfig(false);
|
||||||
|
setCommonConfigError("");
|
||||||
setClaudeModel("");
|
setClaudeModel("");
|
||||||
setClaudeSmallFastModel("");
|
setClaudeSmallFastModel("");
|
||||||
setKimiAnthropicModel("");
|
setKimiAnthropicModel("");
|
||||||
@@ -395,6 +632,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
websiteUrl: "",
|
websiteUrl: "",
|
||||||
settingsConfig: "",
|
settingsConfig: "",
|
||||||
});
|
});
|
||||||
|
setSettingsConfigError(validateSettingsConfig(""));
|
||||||
setCodexAuth("");
|
setCodexAuth("");
|
||||||
setCodexConfig("");
|
setCodexConfig("");
|
||||||
setCodexApiKey("");
|
setCodexApiKey("");
|
||||||
@@ -412,14 +650,11 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 更新表单配置
|
// 更新表单配置
|
||||||
setFormData((prev) => ({
|
updateSettingsConfigValue(configString);
|
||||||
...prev,
|
|
||||||
settingsConfig: configString,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 同步选择框状态
|
// 同步通用配置开关
|
||||||
const hasCoAuthoredDisabled = checkCoAuthoredSetting(configString);
|
const hasCommon = hasCommonConfigSnippet(configString, commonConfigSnippet);
|
||||||
setDisableCoAuthored(hasCoAuthoredDisabled);
|
setUseCommonConfig(hasCommon);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 处理基础 URL 变化
|
// 处理基础 URL 变化
|
||||||
@@ -433,10 +668,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
}
|
}
|
||||||
config.env.ANTHROPIC_BASE_URL = url.trim();
|
config.env.ANTHROPIC_BASE_URL = url.trim();
|
||||||
|
|
||||||
setFormData((prev) => ({
|
updateSettingsConfigValue(JSON.stringify(config, null, 2));
|
||||||
...prev,
|
|
||||||
settingsConfig: JSON.stringify(config, null, 2),
|
|
||||||
}));
|
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -454,6 +686,100 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Codex: 处理通用配置开关
|
||||||
|
const handleCodexCommonConfigToggle = (checked: boolean) => {
|
||||||
|
const { updatedConfig, error: snippetError } =
|
||||||
|
updateTomlCommonConfigSnippet(
|
||||||
|
codexConfig,
|
||||||
|
codexCommonConfigSnippet,
|
||||||
|
checked,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (snippetError) {
|
||||||
|
setCodexCommonConfigError(snippetError);
|
||||||
|
setUseCodexCommonConfig(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCodexCommonConfigError("");
|
||||||
|
setUseCodexCommonConfig(checked);
|
||||||
|
// 标记正在通过通用配置更新
|
||||||
|
isUpdatingFromCodexCommonConfig.current = true;
|
||||||
|
setCodexConfig(updatedConfig);
|
||||||
|
// 在下一个事件循环中重置标记
|
||||||
|
setTimeout(() => {
|
||||||
|
isUpdatingFromCodexCommonConfig.current = false;
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Codex: 处理通用配置片段变化
|
||||||
|
const handleCodexCommonConfigSnippetChange = (value: string) => {
|
||||||
|
const previousSnippet = codexCommonConfigSnippet;
|
||||||
|
setCodexCommonConfigSnippet(value);
|
||||||
|
|
||||||
|
if (!value.trim()) {
|
||||||
|
setCodexCommonConfigError("");
|
||||||
|
if (useCodexCommonConfig) {
|
||||||
|
const { updatedConfig } = updateTomlCommonConfigSnippet(
|
||||||
|
codexConfig,
|
||||||
|
previousSnippet,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
setCodexConfig(updatedConfig);
|
||||||
|
setUseCodexCommonConfig(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TOML 不需要验证 JSON 格式,直接更新
|
||||||
|
if (useCodexCommonConfig) {
|
||||||
|
const removeResult = updateTomlCommonConfigSnippet(
|
||||||
|
codexConfig,
|
||||||
|
previousSnippet,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
const addResult = updateTomlCommonConfigSnippet(
|
||||||
|
removeResult.updatedConfig,
|
||||||
|
value,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (addResult.error) {
|
||||||
|
setCodexCommonConfigError(addResult.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标记正在通过通用配置更新
|
||||||
|
isUpdatingFromCodexCommonConfig.current = true;
|
||||||
|
setCodexConfig(addResult.updatedConfig);
|
||||||
|
// 在下一个事件循环中重置标记
|
||||||
|
setTimeout(() => {
|
||||||
|
isUpdatingFromCodexCommonConfig.current = false;
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存 Codex 通用配置到 localStorage
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(CODEX_COMMON_CONFIG_STORAGE_KEY, value);
|
||||||
|
} catch {
|
||||||
|
// ignore localStorage 写入失败
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Codex: 处理 config 变化
|
||||||
|
const handleCodexConfigChange = (value: string) => {
|
||||||
|
if (!isUpdatingFromCodexCommonConfig.current) {
|
||||||
|
const hasCommon = hasTomlCommonConfigSnippet(
|
||||||
|
value,
|
||||||
|
codexCommonConfigSnippet,
|
||||||
|
);
|
||||||
|
setUseCodexCommonConfig(hasCommon);
|
||||||
|
}
|
||||||
|
setCodexConfig(value);
|
||||||
|
};
|
||||||
|
|
||||||
// 根据当前配置决定是否展示 API Key 输入框
|
// 根据当前配置决定是否展示 API Key 输入框
|
||||||
// 自定义模式(-1)也需要显示 API Key 输入框
|
// 自定义模式(-1)也需要显示 API Key 输入框
|
||||||
const showApiKey =
|
const showApiKey =
|
||||||
@@ -581,10 +907,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
delete currentConfig.env[field];
|
delete currentConfig.env[field];
|
||||||
}
|
}
|
||||||
|
|
||||||
setFormData((prev) => ({
|
updateSettingsConfigValue(JSON.stringify(currentConfig, null, 2));
|
||||||
...prev,
|
|
||||||
settingsConfig: JSON.stringify(currentConfig, null, 2),
|
|
||||||
}));
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// 如果 JSON 解析失败,不做处理
|
// 如果 JSON 解析失败,不做处理
|
||||||
}
|
}
|
||||||
@@ -608,10 +931,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
currentConfig.env[field] = value;
|
currentConfig.env[field] = value;
|
||||||
|
|
||||||
const updatedConfigString = JSON.stringify(currentConfig, null, 2);
|
const updatedConfigString = JSON.stringify(currentConfig, null, 2);
|
||||||
setFormData((prev) => ({
|
updateSettingsConfigValue(updatedConfigString);
|
||||||
...prev,
|
|
||||||
settingsConfig: updatedConfigString,
|
|
||||||
}));
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("更新 Kimi 模型配置失败:", err);
|
console.error("更新 Kimi 模型配置失败:", err);
|
||||||
}
|
}
|
||||||
@@ -846,7 +1166,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
authValue={codexAuth}
|
authValue={codexAuth}
|
||||||
configValue={codexConfig}
|
configValue={codexConfig}
|
||||||
onAuthChange={setCodexAuth}
|
onAuthChange={setCodexAuth}
|
||||||
onConfigChange={setCodexConfig}
|
onConfigChange={handleCodexConfigChange}
|
||||||
onAuthBlur={() => {
|
onAuthBlur={() => {
|
||||||
try {
|
try {
|
||||||
const auth = JSON.parse(codexAuth || "{}");
|
const auth = JSON.parse(codexAuth || "{}");
|
||||||
@@ -859,6 +1179,14 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
useCommonConfig={useCodexCommonConfig}
|
||||||
|
onCommonConfigToggle={handleCodexCommonConfigToggle}
|
||||||
|
commonConfigSnippet={codexCommonConfigSnippet}
|
||||||
|
onCommonConfigSnippetChange={
|
||||||
|
handleCodexCommonConfigSnippetChange
|
||||||
|
}
|
||||||
|
commonConfigError={codexCommonConfigError}
|
||||||
|
authError={codexAuthError}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -925,8 +1253,12 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
target: { name: "settingsConfig", value },
|
target: { name: "settingsConfig", value },
|
||||||
} as React.ChangeEvent<HTMLTextAreaElement>)
|
} as React.ChangeEvent<HTMLTextAreaElement>)
|
||||||
}
|
}
|
||||||
disableCoAuthored={disableCoAuthored}
|
useCommonConfig={useCommonConfig}
|
||||||
onCoAuthoredToggle={handleCoAuthoredToggle}
|
onCommonConfigToggle={handleCommonConfigToggle}
|
||||||
|
commonConfigSnippet={commonConfigSnippet}
|
||||||
|
onCommonConfigSnippetChange={handleCommonConfigSnippetChange}
|
||||||
|
commonConfigError={commonConfigError}
|
||||||
|
configError={settingsConfigError}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,20 +1,30 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import JsonEditor from "../JsonEditor";
|
import JsonEditor from "../JsonEditor";
|
||||||
|
import { X, Save } from "lucide-react";
|
||||||
|
|
||||||
interface ClaudeConfigEditorProps {
|
interface ClaudeConfigEditorProps {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
disableCoAuthored: boolean;
|
useCommonConfig: boolean;
|
||||||
onCoAuthoredToggle: (checked: boolean) => void;
|
onCommonConfigToggle: (checked: boolean) => void;
|
||||||
|
commonConfigSnippet: string;
|
||||||
|
onCommonConfigSnippetChange: (value: string) => void;
|
||||||
|
commonConfigError: string;
|
||||||
|
configError: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ClaudeConfigEditor: React.FC<ClaudeConfigEditorProps> = ({
|
const ClaudeConfigEditor: React.FC<ClaudeConfigEditorProps> = ({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
disableCoAuthored,
|
useCommonConfig,
|
||||||
onCoAuthoredToggle,
|
onCommonConfigToggle,
|
||||||
|
commonConfigSnippet,
|
||||||
|
onCommonConfigSnippetChange,
|
||||||
|
commonConfigError,
|
||||||
|
configError,
|
||||||
}) => {
|
}) => {
|
||||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||||
|
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 检测暗色模式
|
// 检测暗色模式
|
||||||
@@ -40,6 +50,30 @@ const ClaudeConfigEditor: React.FC<ClaudeConfigEditorProps> = ({
|
|||||||
|
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (commonConfigError && !isCommonConfigModalOpen) {
|
||||||
|
setIsCommonConfigModalOpen(true);
|
||||||
|
}
|
||||||
|
}, [commonConfigError, isCommonConfigModalOpen]);
|
||||||
|
|
||||||
|
// 支持按下 ESC 关闭弹窗
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isCommonConfigModalOpen) return;
|
||||||
|
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", onKeyDown);
|
||||||
|
}, [isCommonConfigModalOpen]);
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
setIsCommonConfigModalOpen(false);
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -52,13 +86,27 @@ const ClaudeConfigEditor: React.FC<ClaudeConfigEditorProps> = ({
|
|||||||
<label className="inline-flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 cursor-pointer">
|
<label className="inline-flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={disableCoAuthored}
|
checked={useCommonConfig}
|
||||||
onChange={(e) => onCoAuthoredToggle(e.target.checked)}
|
onChange={(e) => onCommonConfigToggle(e.target.checked)}
|
||||||
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
|
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
|
||||||
/>
|
/>
|
||||||
禁止 Claude Code 签名
|
写入通用配置
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsCommonConfigModalOpen(true)}
|
||||||
|
className="text-xs text-blue-500 dark:text-blue-400 hover:underline"
|
||||||
|
>
|
||||||
|
编辑通用配置
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{commonConfigError && !isCommonConfigModalOpen && (
|
||||||
|
<p className="text-xs text-red-500 dark:text-red-400 text-right">
|
||||||
|
{commonConfigError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<JsonEditor
|
<JsonEditor
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
@@ -71,9 +119,78 @@ const ClaudeConfigEditor: React.FC<ClaudeConfigEditorProps> = ({
|
|||||||
}`}
|
}`}
|
||||||
rows={12}
|
rows={12}
|
||||||
/>
|
/>
|
||||||
|
{configError && (
|
||||||
|
<p className="text-xs text-red-500 dark:text-red-400">{configError}</p>
|
||||||
|
)}
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
完整的 Claude Code settings.json 配置内容
|
完整的 Claude Code settings.json 配置内容
|
||||||
</p>
|
</p>
|
||||||
|
{isCommonConfigModalOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
if (e.target === e.currentTarget) closeModal();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Backdrop - 统一背景样式 */}
|
||||||
|
<div className="absolute inset-0 bg-black/50 dark:bg-black/70 backdrop-blur-sm" />
|
||||||
|
|
||||||
|
{/* Modal - 统一窗口样式 */}
|
||||||
|
<div className="relative bg-white dark:bg-gray-900 rounded-xl shadow-lg max-w-2xl w-full mx-4 max-h-[90vh] overflow-hidden flex flex-col">
|
||||||
|
{/* Header - 统一标题栏样式 */}
|
||||||
|
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
编辑通用配置片段
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={closeModal}
|
||||||
|
className="p-1 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
|
||||||
|
aria-label="关闭"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content - 统一内容区域样式 */}
|
||||||
|
<div className="flex-1 overflow-auto p-6 space-y-4">
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
该片段会在勾选"写入通用配置"时合并到 settings.json 中
|
||||||
|
</p>
|
||||||
|
<JsonEditor
|
||||||
|
value={commonConfigSnippet}
|
||||||
|
onChange={onCommonConfigSnippetChange}
|
||||||
|
darkMode={isDarkMode}
|
||||||
|
rows={12}
|
||||||
|
/>
|
||||||
|
{commonConfigError && (
|
||||||
|
<p className="text-sm text-red-500 dark:text-red-400">
|
||||||
|
{commonConfigError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer - 统一底部按钮样式 */}
|
||||||
|
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200 dark:border-gray-800 bg-gray-100 dark:bg-gray-800">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={closeModal}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-white dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={closeModal}
|
||||||
|
className="px-4 py-2 bg-blue-500 dark:bg-blue-600 text-white rounded-lg hover:bg-blue-600 dark:hover:bg-blue-700 transition-colors text-sm font-medium flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Save className="w-4 h-4" />
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { X, Save } from "lucide-react";
|
||||||
|
|
||||||
interface CodexConfigEditorProps {
|
interface CodexConfigEditorProps {
|
||||||
authValue: string;
|
authValue: string;
|
||||||
@@ -6,6 +7,12 @@ interface CodexConfigEditorProps {
|
|||||||
onAuthChange: (value: string) => void;
|
onAuthChange: (value: string) => void;
|
||||||
onConfigChange: (value: string) => void;
|
onConfigChange: (value: string) => void;
|
||||||
onAuthBlur?: () => void;
|
onAuthBlur?: () => void;
|
||||||
|
useCommonConfig: boolean;
|
||||||
|
onCommonConfigToggle: (checked: boolean) => void;
|
||||||
|
commonConfigSnippet: string;
|
||||||
|
onCommonConfigSnippetChange: (value: string) => void;
|
||||||
|
commonConfigError: string;
|
||||||
|
authError: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
||||||
@@ -14,7 +21,51 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
|||||||
onAuthChange,
|
onAuthChange,
|
||||||
onConfigChange,
|
onConfigChange,
|
||||||
onAuthBlur,
|
onAuthBlur,
|
||||||
|
useCommonConfig,
|
||||||
|
onCommonConfigToggle,
|
||||||
|
commonConfigSnippet,
|
||||||
|
onCommonConfigSnippetChange,
|
||||||
|
commonConfigError,
|
||||||
|
authError,
|
||||||
}) => {
|
}) => {
|
||||||
|
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (commonConfigError && !isCommonConfigModalOpen) {
|
||||||
|
setIsCommonConfigModalOpen(true);
|
||||||
|
}
|
||||||
|
}, [commonConfigError, isCommonConfigModalOpen]);
|
||||||
|
|
||||||
|
// 支持按下 ESC 关闭弹窗
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isCommonConfigModalOpen) return;
|
||||||
|
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", onKeyDown);
|
||||||
|
}, [isCommonConfigModalOpen]);
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
setIsCommonConfigModalOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAuthChange = (value: string) => {
|
||||||
|
onAuthChange(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfigChange = (value: string) => {
|
||||||
|
onConfigChange(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCommonConfigSnippetChange = (value: string) => {
|
||||||
|
onCommonConfigSnippetChange(value);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -27,7 +78,7 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
|||||||
<textarea
|
<textarea
|
||||||
id="codexAuth"
|
id="codexAuth"
|
||||||
value={authValue}
|
value={authValue}
|
||||||
onChange={(e) => onAuthChange(e.target.value)}
|
onChange={(e) => handleAuthChange(e.target.value)}
|
||||||
onBlur={onAuthBlur}
|
onBlur={onAuthBlur}
|
||||||
placeholder={`{
|
placeholder={`{
|
||||||
"OPENAI_API_KEY": "sk-your-api-key-here"
|
"OPENAI_API_KEY": "sk-your-api-key-here"
|
||||||
@@ -35,31 +86,157 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
|||||||
rows={6}
|
rows={6}
|
||||||
required
|
required
|
||||||
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 focus:border-blue-500 dark:focus:border-blue-400 transition-colors resize-y min-h-[8rem]"
|
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 focus:border-blue-500 dark:focus:border-blue-400 transition-colors resize-y min-h-[8rem]"
|
||||||
|
autoComplete="off"
|
||||||
|
autoCorrect="off"
|
||||||
|
autoCapitalize="none"
|
||||||
|
spellCheck={false}
|
||||||
|
lang="en"
|
||||||
|
inputMode="text"
|
||||||
|
data-gramm="false"
|
||||||
|
data-gramm_editor="false"
|
||||||
|
data-enable-grammarly="false"
|
||||||
/>
|
/>
|
||||||
|
{authError && (
|
||||||
|
<p className="text-xs text-red-500 dark:text-red-400">{authError}</p>
|
||||||
|
)}
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
Codex auth.json 配置内容
|
Codex auth.json 配置内容
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label
|
<div className="flex items-center justify-between">
|
||||||
htmlFor="codexConfig"
|
<label
|
||||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
htmlFor="codexConfig"
|
||||||
>
|
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||||
config.toml (TOML)
|
>
|
||||||
</label>
|
config.toml (TOML)
|
||||||
|
</label>
|
||||||
|
<label className="inline-flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={useCommonConfig}
|
||||||
|
onChange={(e) => onCommonConfigToggle(e.target.checked)}
|
||||||
|
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
|
||||||
|
/>
|
||||||
|
写入通用配置
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsCommonConfigModalOpen(true)}
|
||||||
|
className="text-xs text-blue-500 dark:text-blue-400 hover:underline"
|
||||||
|
>
|
||||||
|
编辑通用配置
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{commonConfigError && !isCommonConfigModalOpen && (
|
||||||
|
<p className="text-xs text-red-500 dark:text-red-400 text-right">
|
||||||
|
{commonConfigError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<textarea
|
<textarea
|
||||||
id="codexConfig"
|
id="codexConfig"
|
||||||
value={configValue}
|
value={configValue}
|
||||||
onChange={(e) => onConfigChange(e.target.value)}
|
onChange={(e) => handleConfigChange(e.target.value)}
|
||||||
placeholder=""
|
placeholder=""
|
||||||
rows={8}
|
rows={8}
|
||||||
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 focus:border-blue-500 dark:focus:border-blue-400 transition-colors resize-y min-h-[10rem]"
|
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 focus:border-blue-500 dark:focus:border-blue-400 transition-colors resize-y min-h-[10rem]"
|
||||||
|
autoComplete="off"
|
||||||
|
autoCorrect="off"
|
||||||
|
autoCapitalize="none"
|
||||||
|
spellCheck={false}
|
||||||
|
lang="en"
|
||||||
|
inputMode="text"
|
||||||
|
data-gramm="false"
|
||||||
|
data-gramm_editor="false"
|
||||||
|
data-enable-grammarly="false"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
Codex config.toml 配置内容
|
Codex config.toml 配置内容
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isCommonConfigModalOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
if (e.target === e.currentTarget) closeModal();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Backdrop - 统一背景样式 */}
|
||||||
|
<div className="absolute inset-0 bg-black/50 dark:bg-black/70 backdrop-blur-sm" />
|
||||||
|
|
||||||
|
{/* Modal - 统一窗口样式 */}
|
||||||
|
<div className="relative bg-white dark:bg-gray-900 rounded-xl shadow-lg max-w-2xl w-full mx-4 max-h-[90vh] overflow-hidden flex flex-col">
|
||||||
|
{/* Header - 统一标题栏样式 */}
|
||||||
|
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
编辑 Codex 通用配置片段
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={closeModal}
|
||||||
|
className="p-1 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
|
||||||
|
aria-label="关闭"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content - 统一内容区域样式 */}
|
||||||
|
<div className="flex-1 overflow-auto p-6 space-y-4">
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
该片段会在勾选"写入通用配置"时追加到 config.toml 末尾
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
value={commonConfigSnippet}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleCommonConfigSnippetChange(e.target.value)
|
||||||
|
}
|
||||||
|
placeholder={`# Common Codex config
|
||||||
|
# Add your common TOML configuration here`}
|
||||||
|
rows={12}
|
||||||
|
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 focus:border-blue-500 dark:focus:border-blue-400 transition-colors resize-y"
|
||||||
|
autoComplete="off"
|
||||||
|
autoCorrect="off"
|
||||||
|
autoCapitalize="none"
|
||||||
|
spellCheck={false}
|
||||||
|
lang="en"
|
||||||
|
inputMode="text"
|
||||||
|
data-gramm="false"
|
||||||
|
data-gramm_editor="false"
|
||||||
|
data-enable-grammarly="false"
|
||||||
|
/>
|
||||||
|
{commonConfigError && (
|
||||||
|
<p className="text-sm text-red-500 dark:text-red-400">
|
||||||
|
{commonConfigError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer - 统一底部按钮样式 */}
|
||||||
|
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200 dark:border-gray-800 bg-gray-100 dark:bg-gray-800">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={closeModal}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-white dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={closeModal}
|
||||||
|
className="px-4 py-2 bg-blue-500 dark:bg-blue-600 text-white rounded-lg hover:bg-blue-600 dark:hover:bg-blue-700 transition-colors text-sm font-medium flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Save className="w-4 h-4" />
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import React from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { Provider } from "../types";
|
import { Provider } from "../types";
|
||||||
import { Play, Edit3, Trash2, CheckCircle2, Users } from "lucide-react";
|
import { Play, Edit3, Trash2, CheckCircle2, Users } from "lucide-react";
|
||||||
import { buttonStyles, cardStyles, badgeStyles, cn } from "../lib/styles";
|
import { buttonStyles, cardStyles, badgeStyles, cn } from "../lib/styles";
|
||||||
|
import { AppType } from "../lib/tauri-api";
|
||||||
|
import {
|
||||||
|
applyProviderToVSCode,
|
||||||
|
detectApplied,
|
||||||
|
normalizeBaseUrl,
|
||||||
|
} from "../utils/vscodeSettings";
|
||||||
|
import { getCodexBaseUrl } from "../utils/providerConfigUtils";
|
||||||
|
import { useVSCodeAutoSync } from "../hooks/useVSCodeAutoSync";
|
||||||
// 不再在列表中显示分类徽章,避免造成困惑
|
// 不再在列表中显示分类徽章,避免造成困惑
|
||||||
|
|
||||||
interface ProviderListProps {
|
interface ProviderListProps {
|
||||||
@@ -10,6 +18,12 @@ interface ProviderListProps {
|
|||||||
onSwitch: (id: string) => void;
|
onSwitch: (id: string) => void;
|
||||||
onDelete: (id: string) => void;
|
onDelete: (id: string) => void;
|
||||||
onEdit: (id: string) => void;
|
onEdit: (id: string) => void;
|
||||||
|
appType?: AppType;
|
||||||
|
onNotify?: (
|
||||||
|
message: string,
|
||||||
|
type: "success" | "error",
|
||||||
|
duration?: number,
|
||||||
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ProviderList: React.FC<ProviderListProps> = ({
|
const ProviderList: React.FC<ProviderListProps> = ({
|
||||||
@@ -18,6 +32,8 @@ const ProviderList: React.FC<ProviderListProps> = ({
|
|||||||
onSwitch,
|
onSwitch,
|
||||||
onDelete,
|
onDelete,
|
||||||
onEdit,
|
onEdit,
|
||||||
|
appType,
|
||||||
|
onNotify,
|
||||||
}) => {
|
}) => {
|
||||||
// 提取API地址(兼容不同供应商配置:Claude env / Codex TOML)
|
// 提取API地址(兼容不同供应商配置:Claude env / Codex TOML)
|
||||||
const getApiUrl = (provider: Provider): string => {
|
const getApiUrl = (provider: Provider): string => {
|
||||||
@@ -29,8 +45,9 @@ const ProviderList: React.FC<ProviderListProps> = ({
|
|||||||
}
|
}
|
||||||
// Codex: 从 TOML 配置中解析 base_url
|
// Codex: 从 TOML 配置中解析 base_url
|
||||||
if (typeof cfg?.config === "string" && cfg.config.includes("base_url")) {
|
if (typeof cfg?.config === "string" && cfg.config.includes("base_url")) {
|
||||||
const match = cfg.config.match(/base_url\s*=\s*"([^"]+)"/);
|
// 支持单/双引号
|
||||||
if (match && match[1]) return match[1];
|
const match = cfg.config.match(/base_url\s*=\s*(['"])([^'\"]+)\1/);
|
||||||
|
if (match && match[2]) return match[2];
|
||||||
}
|
}
|
||||||
return "未配置官网地址";
|
return "未配置官网地址";
|
||||||
} catch {
|
} catch {
|
||||||
@@ -46,6 +63,128 @@ const ProviderList: React.FC<ProviderListProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 解析 Codex 配置中的 base_url(已提取到公共工具)
|
||||||
|
|
||||||
|
// VS Code 按钮:仅在 Codex + 当前供应商显示;按钮文案根据是否"已应用"变化
|
||||||
|
const [vscodeAppliedFor, setVscodeAppliedFor] = useState<string | null>(null);
|
||||||
|
const { enableAutoSync, disableAutoSync } = useVSCodeAutoSync();
|
||||||
|
|
||||||
|
// 当当前供应商或 appType 变化时,尝试读取 VS Code settings 并检测状态
|
||||||
|
useEffect(() => {
|
||||||
|
const check = async () => {
|
||||||
|
if (appType !== "codex" || !currentProviderId) {
|
||||||
|
setVscodeAppliedFor(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const status = await window.api.getVSCodeSettingsStatus();
|
||||||
|
if (!status.exists) {
|
||||||
|
setVscodeAppliedFor(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const content = await window.api.readVSCodeSettings();
|
||||||
|
const detected = detectApplied(content);
|
||||||
|
// 认为“已应用”的条件(非官方供应商):VS Code 中的 apiBase 与当前供应商的 base_url 完全一致
|
||||||
|
const current = providers[currentProviderId];
|
||||||
|
let applied = false;
|
||||||
|
if (current && current.category !== "official") {
|
||||||
|
const base = getCodexBaseUrl(current);
|
||||||
|
if (detected.apiBase && base) {
|
||||||
|
applied =
|
||||||
|
normalizeBaseUrl(detected.apiBase) === normalizeBaseUrl(base);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setVscodeAppliedFor(applied ? currentProviderId : null);
|
||||||
|
} catch {
|
||||||
|
setVscodeAppliedFor(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
check();
|
||||||
|
}, [appType, currentProviderId, providers]);
|
||||||
|
|
||||||
|
const handleApplyToVSCode = async (provider: Provider) => {
|
||||||
|
try {
|
||||||
|
const status = await window.api.getVSCodeSettingsStatus();
|
||||||
|
if (!status.exists) {
|
||||||
|
onNotify?.(
|
||||||
|
"未找到 VS Code 用户设置文件 (settings.json)",
|
||||||
|
"error",
|
||||||
|
3000,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = await window.api.readVSCodeSettings();
|
||||||
|
|
||||||
|
const isOfficial = provider.category === "official";
|
||||||
|
// 非官方且缺少 base_url 时直接报错并返回,避免“空写入”假成功
|
||||||
|
if (!isOfficial) {
|
||||||
|
const parsed = getCodexBaseUrl(provider);
|
||||||
|
if (!parsed) {
|
||||||
|
onNotify?.("当前配置缺少 base_url,无法写入 VS Code", "error", 4000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = isOfficial ? undefined : getCodexBaseUrl(provider);
|
||||||
|
const next = applyProviderToVSCode(raw, { baseUrl, isOfficial });
|
||||||
|
|
||||||
|
if (next === raw) {
|
||||||
|
// 幂等:没有变化也提示成功
|
||||||
|
onNotify?.("已应用到 VS Code,重启 Codex 插件以生效", "success", 3000);
|
||||||
|
setVscodeAppliedFor(provider.id);
|
||||||
|
// 用户手动应用时,启用自动同步
|
||||||
|
enableAutoSync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await window.api.writeVSCodeSettings(next);
|
||||||
|
onNotify?.("已应用到 VS Code,重启 Codex 插件以生效", "success", 3000);
|
||||||
|
setVscodeAppliedFor(provider.id);
|
||||||
|
// 用户手动应用时,启用自动同步
|
||||||
|
enableAutoSync();
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
const msg = e && e.message ? e.message : "应用到 VS Code 失败";
|
||||||
|
onNotify?.(msg, "error", 5000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveFromVSCode = async () => {
|
||||||
|
try {
|
||||||
|
const status = await window.api.getVSCodeSettingsStatus();
|
||||||
|
if (!status.exists) {
|
||||||
|
onNotify?.(
|
||||||
|
"未找到 VS Code 用户设置文件 (settings.json)",
|
||||||
|
"error",
|
||||||
|
3000,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const raw = await window.api.readVSCodeSettings();
|
||||||
|
const next = applyProviderToVSCode(raw, {
|
||||||
|
baseUrl: undefined,
|
||||||
|
isOfficial: true,
|
||||||
|
});
|
||||||
|
if (next === raw) {
|
||||||
|
onNotify?.("已从 VS Code 移除,重启 Codex 插件以生效", "success", 3000);
|
||||||
|
setVscodeAppliedFor(null);
|
||||||
|
// 用户手动移除时,禁用自动同步
|
||||||
|
disableAutoSync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await window.api.writeVSCodeSettings(next);
|
||||||
|
onNotify?.("已从 VS Code 移除,重启 Codex 插件以生效", "success", 3000);
|
||||||
|
setVscodeAppliedFor(null);
|
||||||
|
// 用户手动移除时,禁用自动同步
|
||||||
|
disableAutoSync();
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
const msg = e && e.message ? e.message : "移除失败";
|
||||||
|
onNotify?.(msg, "error", 5000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 对供应商列表进行排序
|
// 对供应商列表进行排序
|
||||||
const sortedProviders = Object.values(providers).sort((a, b) => {
|
const sortedProviders = Object.values(providers).sort((a, b) => {
|
||||||
// 按添加时间排序
|
// 按添加时间排序
|
||||||
@@ -101,12 +240,15 @@ const ProviderList: React.FC<ProviderListProps> = ({
|
|||||||
{provider.name}
|
{provider.name}
|
||||||
</h3>
|
</h3>
|
||||||
{/* 分类徽章已移除 */}
|
{/* 分类徽章已移除 */}
|
||||||
{isCurrent && (
|
<div
|
||||||
<div className={badgeStyles.success}>
|
className={cn(
|
||||||
<CheckCircle2 size={12} />
|
badgeStyles.success,
|
||||||
当前使用
|
!isCurrent && "invisible",
|
||||||
</div>
|
)}
|
||||||
)}
|
>
|
||||||
|
<CheckCircle2 size={12} />
|
||||||
|
当前使用
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
@@ -133,6 +275,32 @@ const ProviderList: React.FC<ProviderListProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 ml-4">
|
<div className="flex items-center gap-2 ml-4">
|
||||||
|
{appType === "codex" &&
|
||||||
|
provider.category !== "official" && (
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
vscodeAppliedFor === provider.id
|
||||||
|
? handleRemoveFromVSCode()
|
||||||
|
: handleApplyToVSCode(provider)
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors w-[130px] justify-center",
|
||||||
|
!isCurrent && "invisible",
|
||||||
|
vscodeAppliedFor === provider.id
|
||||||
|
? "bg-gray-100 text-gray-800 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||||
|
: "bg-emerald-500 text-white hover:bg-emerald-600 dark:bg-emerald-600 dark:hover:bg-emerald-700",
|
||||||
|
)}
|
||||||
|
title={
|
||||||
|
vscodeAppliedFor === provider.id
|
||||||
|
? "从 VS Code 移除我们写入的配置"
|
||||||
|
: "将当前供应商应用到 VS Code"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{vscodeAppliedFor === provider.id
|
||||||
|
? "从 VS Code 移除"
|
||||||
|
: "应用到 VS Code"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => onSwitch(provider.id)}
|
onClick={() => onSwitch(provider.id)}
|
||||||
disabled={isCurrent}
|
disabled={isCurrent}
|
||||||
|
|||||||
@@ -157,8 +157,14 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-50">
|
<div
|
||||||
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-[500px] overflow-hidden">
|
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
if (e.target === e.currentTarget) onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 bg-black/50 dark:bg-black/70 backdrop-blur-sm" />
|
||||||
|
<div className="relative bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-[500px] overflow-hidden">
|
||||||
{/* 标题栏 */}
|
{/* 标题栏 */}
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-800">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-800">
|
||||||
<h2 className="text-lg font-semibold text-blue-500 dark:text-blue-400">
|
<h2 className="text-lg font-semibold text-blue-500 dark:text-blue-400">
|
||||||
@@ -195,6 +201,8 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
|
|||||||
</label>
|
</label>
|
||||||
</div> */}
|
</div> */}
|
||||||
|
|
||||||
|
{/* VS Code 自动同步设置已移除 */}
|
||||||
|
|
||||||
{/* 配置文件位置 */}
|
{/* 配置文件位置 */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
|
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
|
||||||
@@ -252,11 +260,11 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
|
|||||||
<button
|
<button
|
||||||
onClick={handleCheckUpdate}
|
onClick={handleCheckUpdate}
|
||||||
disabled={isCheckingUpdate || isDownloading}
|
disabled={isCheckingUpdate || isDownloading}
|
||||||
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-all ${
|
className={`min-w-[88px] px-3 py-1.5 text-xs font-medium rounded-lg transition-all ${
|
||||||
isCheckingUpdate || isDownloading
|
isCheckingUpdate || isDownloading
|
||||||
? "bg-gray-100 dark:bg-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed"
|
? "bg-gray-100 dark:bg-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed border border-transparent"
|
||||||
: hasUpdate
|
: hasUpdate
|
||||||
? "bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700 text-white"
|
? "bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700 text-white border border-transparent"
|
||||||
: showUpToDate
|
: showUpToDate
|
||||||
? "bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400 border border-green-200 dark:border-green-800"
|
? "bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400 border border-green-200 dark:border-green-800"
|
||||||
: "bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 text-blue-500 dark:text-blue-400 border border-gray-200 dark:border-gray-600"
|
: "bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 text-blue-500 dark:text-blue-400 border border-gray-200 dark:border-gray-600"
|
||||||
|
|||||||
99
src/hooks/useVSCodeAutoSync.ts
Normal file
99
src/hooks/useVSCodeAutoSync.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
|
||||||
|
const VSCODE_AUTO_SYNC_KEY = "vscode-auto-sync-enabled";
|
||||||
|
const VSCODE_AUTO_SYNC_EVENT = "vscode-auto-sync-changed";
|
||||||
|
|
||||||
|
export function useVSCodeAutoSync() {
|
||||||
|
// 默认开启自动同步;若本地存储存在记录,则以记录为准
|
||||||
|
const [isAutoSyncEnabled, setIsAutoSyncEnabled] = useState<boolean>(true);
|
||||||
|
|
||||||
|
// 从 localStorage 读取初始状态
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(VSCODE_AUTO_SYNC_KEY);
|
||||||
|
if (saved !== null) {
|
||||||
|
setIsAutoSyncEnabled(saved === "true");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("读取自动同步状态失败:", error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 订阅同窗口的自定义事件,以及跨窗口的 storage 事件,实现全局同步
|
||||||
|
useEffect(() => {
|
||||||
|
const onCustom = (e: Event) => {
|
||||||
|
try {
|
||||||
|
const detail = (e as CustomEvent).detail as
|
||||||
|
| { enabled?: boolean }
|
||||||
|
| undefined;
|
||||||
|
if (detail && typeof detail.enabled === "boolean") {
|
||||||
|
setIsAutoSyncEnabled(detail.enabled);
|
||||||
|
} else {
|
||||||
|
// 兜底:从 localStorage 读取
|
||||||
|
const saved = localStorage.getItem(VSCODE_AUTO_SYNC_KEY);
|
||||||
|
if (saved !== null) setIsAutoSyncEnabled(saved === "true");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 忽略
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onStorage = (e: StorageEvent) => {
|
||||||
|
if (e.key === VSCODE_AUTO_SYNC_KEY) {
|
||||||
|
setIsAutoSyncEnabled(e.newValue === "true");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener(VSCODE_AUTO_SYNC_EVENT, onCustom as EventListener);
|
||||||
|
window.addEventListener("storage", onStorage);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener(
|
||||||
|
VSCODE_AUTO_SYNC_EVENT,
|
||||||
|
onCustom as EventListener,
|
||||||
|
);
|
||||||
|
window.removeEventListener("storage", onStorage);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 启用自动同步
|
||||||
|
const enableAutoSync = useCallback(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(VSCODE_AUTO_SYNC_KEY, "true");
|
||||||
|
setIsAutoSyncEnabled(true);
|
||||||
|
// 通知同窗口其他订阅者
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent(VSCODE_AUTO_SYNC_EVENT, { detail: { enabled: true } }),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("保存自动同步状态失败:", error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 禁用自动同步
|
||||||
|
const disableAutoSync = useCallback(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(VSCODE_AUTO_SYNC_KEY, "false");
|
||||||
|
setIsAutoSyncEnabled(false);
|
||||||
|
// 通知同窗口其他订阅者
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent(VSCODE_AUTO_SYNC_EVENT, { detail: { enabled: false } }),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("保存自动同步状态失败:", error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 切换自动同步状态
|
||||||
|
const toggleAutoSync = useCallback(() => {
|
||||||
|
if (isAutoSyncEnabled) {
|
||||||
|
disableAutoSync();
|
||||||
|
} else {
|
||||||
|
enableAutoSync();
|
||||||
|
}
|
||||||
|
}, [isAutoSyncEnabled, enableAutoSync, disableAutoSync]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isAutoSyncEnabled,
|
||||||
|
enableAutoSync,
|
||||||
|
disableAutoSync,
|
||||||
|
toggleAutoSync,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -38,7 +38,7 @@ export const cardStyles = {
|
|||||||
|
|
||||||
// 选中/激活态卡片
|
// 选中/激活态卡片
|
||||||
selected:
|
selected:
|
||||||
"bg-white rounded-lg border border-blue-500 ring-1 ring-blue-500/20 bg-blue-500/5 p-4 dark:bg-gray-900 dark:border-blue-400 dark:ring-blue-400/20 dark:bg-blue-400/10",
|
"bg-white rounded-lg border border-blue-500 shadow-sm bg-blue-50 p-4 dark:bg-gray-900 dark:border-blue-400 dark:bg-blue-400/10",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// 输入控件样式
|
// 输入控件样式
|
||||||
|
|||||||
@@ -242,6 +242,38 @@ export const tauriAPI = {
|
|||||||
console.error("打开应用配置文件夹失败:", error);
|
console.error("打开应用配置文件夹失败:", error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// VS Code: 获取 settings.json 状态
|
||||||
|
getVSCodeSettingsStatus: async (): Promise<{
|
||||||
|
exists: boolean;
|
||||||
|
path: string;
|
||||||
|
error?: string;
|
||||||
|
}> => {
|
||||||
|
try {
|
||||||
|
return await invoke("get_vscode_settings_status");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("获取 VS Code 设置状态失败:", error);
|
||||||
|
return { exists: false, path: "", error: String(error) };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// VS Code: 读取 settings.json 文本
|
||||||
|
readVSCodeSettings: async (): Promise<string> => {
|
||||||
|
try {
|
||||||
|
return await invoke("read_vscode_settings");
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`读取 VS Code 设置失败: ${String(error)}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// VS Code: 写回 settings.json 文本(不自动创建)
|
||||||
|
writeVSCodeSettings: async (content: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
return await invoke("write_vscode_settings", { content });
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`写入 VS Code 设置失败: ${String(error)}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// 创建全局 API 对象,兼容现有代码
|
// 创建全局 API 对象,兼容现有代码
|
||||||
|
|||||||
38
src/utils/errorUtils.ts
Normal file
38
src/utils/errorUtils.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* 从各种错误对象中提取错误信息
|
||||||
|
* @param error 错误对象
|
||||||
|
* @returns 提取的错误信息字符串
|
||||||
|
*/
|
||||||
|
export const extractErrorMessage = (error: unknown): string => {
|
||||||
|
if (!error) return "";
|
||||||
|
if (typeof error === "string") {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
if (error instanceof Error && error.message.trim()) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof error === "object") {
|
||||||
|
const errObject = error as Record<string, unknown>;
|
||||||
|
|
||||||
|
const candidate = errObject.message ?? errObject.error ?? errObject.detail;
|
||||||
|
if (typeof candidate === "string" && candidate.trim()) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = errObject.payload;
|
||||||
|
if (typeof payload === "string" && payload.trim()) {
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
if (payload && typeof payload === "object") {
|
||||||
|
const payloadObj = payload as Record<string, unknown>;
|
||||||
|
const payloadCandidate =
|
||||||
|
payloadObj.message ?? payloadObj.error ?? payloadObj.detail;
|
||||||
|
if (typeof payloadCandidate === "string" && payloadCandidate.trim()) {
|
||||||
|
return payloadCandidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
};
|
||||||
@@ -1,33 +1,162 @@
|
|||||||
// 供应商配置处理工具函数
|
// 供应商配置处理工具函数
|
||||||
|
|
||||||
// 处理includeCoAuthoredBy字段的添加/删除
|
const isPlainObject = (value: unknown): value is Record<string, any> => {
|
||||||
export const updateCoAuthoredSetting = (
|
return Object.prototype.toString.call(value) === "[object Object]";
|
||||||
jsonString: string,
|
};
|
||||||
disable: boolean,
|
|
||||||
): string => {
|
|
||||||
try {
|
|
||||||
const config = JSON.parse(jsonString);
|
|
||||||
|
|
||||||
if (disable) {
|
const deepMerge = (
|
||||||
// 添加或更新includeCoAuthoredBy字段
|
target: Record<string, any>,
|
||||||
config.includeCoAuthoredBy = false;
|
source: Record<string, any>,
|
||||||
|
): Record<string, any> => {
|
||||||
|
Object.entries(source).forEach(([key, value]) => {
|
||||||
|
if (isPlainObject(value)) {
|
||||||
|
if (!isPlainObject(target[key])) {
|
||||||
|
target[key] = {};
|
||||||
|
}
|
||||||
|
deepMerge(target[key], value);
|
||||||
} else {
|
} else {
|
||||||
// 删除includeCoAuthoredBy字段
|
// 直接覆盖非对象字段(数组/基础类型)
|
||||||
delete config.includeCoAuthoredBy;
|
target[key] = value;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
return target;
|
||||||
|
};
|
||||||
|
|
||||||
return JSON.stringify(config, null, 2);
|
const deepRemove = (
|
||||||
} catch (err) {
|
target: Record<string, any>,
|
||||||
// 如果JSON解析失败,返回原始字符串
|
source: Record<string, any>,
|
||||||
return jsonString;
|
) => {
|
||||||
|
Object.entries(source).forEach(([key, value]) => {
|
||||||
|
if (!(key in target)) return;
|
||||||
|
|
||||||
|
if (isPlainObject(value) && isPlainObject(target[key])) {
|
||||||
|
// 只移除完全匹配的嵌套属性
|
||||||
|
deepRemove(target[key], value);
|
||||||
|
if (Object.keys(target[key]).length === 0) {
|
||||||
|
delete target[key];
|
||||||
|
}
|
||||||
|
} else if (isSubset(target[key], value)) {
|
||||||
|
// 只有当值完全匹配时才删除
|
||||||
|
delete target[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSubset = (target: any, source: any): boolean => {
|
||||||
|
if (isPlainObject(source)) {
|
||||||
|
if (!isPlainObject(target)) return false;
|
||||||
|
return Object.entries(source).every(([key, value]) =>
|
||||||
|
isSubset(target[key], value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(source)) {
|
||||||
|
if (!Array.isArray(target) || target.length !== source.length) return false;
|
||||||
|
return source.every((item, index) => isSubset(target[index], item));
|
||||||
|
}
|
||||||
|
|
||||||
|
return target === source;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 深拷贝函数
|
||||||
|
const deepClone = <T>(obj: T): T => {
|
||||||
|
if (obj === null || typeof obj !== "object") return obj;
|
||||||
|
if (obj instanceof Date) return new Date(obj.getTime()) as T;
|
||||||
|
if (obj instanceof Array) return obj.map((item) => deepClone(item)) as T;
|
||||||
|
if (obj instanceof Object) {
|
||||||
|
const clonedObj = {} as T;
|
||||||
|
for (const key in obj) {
|
||||||
|
if (obj.hasOwnProperty(key)) {
|
||||||
|
clonedObj[key] = deepClone(obj[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return clonedObj;
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface UpdateCommonConfigResult {
|
||||||
|
updatedConfig: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证JSON配置格式
|
||||||
|
export const validateJsonConfig = (
|
||||||
|
value: string,
|
||||||
|
fieldName: string = "配置",
|
||||||
|
): string => {
|
||||||
|
if (!value.trim()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||||
|
return `${fieldName}必须是 JSON 对象`;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
} catch {
|
||||||
|
return `${fieldName}JSON格式错误,请检查语法`;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 从JSON配置中检查是否包含includeCoAuthoredBy设置
|
// 将通用配置片段写入/移除 settingsConfig
|
||||||
export const checkCoAuthoredSetting = (jsonString: string): boolean => {
|
export const updateCommonConfigSnippet = (
|
||||||
|
jsonString: string,
|
||||||
|
snippetString: string,
|
||||||
|
enabled: boolean,
|
||||||
|
): UpdateCommonConfigResult => {
|
||||||
|
let config: Record<string, any>;
|
||||||
try {
|
try {
|
||||||
const config = JSON.parse(jsonString);
|
config = jsonString ? JSON.parse(jsonString) : {};
|
||||||
return config.includeCoAuthoredBy === false;
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
updatedConfig: jsonString,
|
||||||
|
error: "配置 JSON 解析失败,无法写入通用配置",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!snippetString.trim()) {
|
||||||
|
return {
|
||||||
|
updatedConfig: JSON.stringify(config, null, 2),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用统一的验证函数
|
||||||
|
const snippetError = validateJsonConfig(snippetString, "通用配置片段");
|
||||||
|
if (snippetError) {
|
||||||
|
return {
|
||||||
|
updatedConfig: JSON.stringify(config, null, 2),
|
||||||
|
error: snippetError,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const snippet = JSON.parse(snippetString) as Record<string, any>;
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
const merged = deepMerge(deepClone(config), snippet);
|
||||||
|
return {
|
||||||
|
updatedConfig: JSON.stringify(merged, null, 2),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const cloned = deepClone(config);
|
||||||
|
deepRemove(cloned, snippet);
|
||||||
|
return {
|
||||||
|
updatedConfig: JSON.stringify(cloned, null, 2),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 检查当前配置是否已包含通用配置片段
|
||||||
|
export const hasCommonConfigSnippet = (
|
||||||
|
jsonString: string,
|
||||||
|
snippetString: string,
|
||||||
|
): boolean => {
|
||||||
|
try {
|
||||||
|
if (!snippetString.trim()) return false;
|
||||||
|
const config = jsonString ? JSON.parse(jsonString) : {};
|
||||||
|
const snippet = JSON.parse(snippetString);
|
||||||
|
if (!isPlainObject(snippet)) return false;
|
||||||
|
return isSubset(config, snippet);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -79,3 +208,113 @@ export const setApiKeyInConfig = (
|
|||||||
return jsonString;
|
return jsonString;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ========== TOML Config Utilities ==========
|
||||||
|
|
||||||
|
export interface UpdateTomlCommonConfigResult {
|
||||||
|
updatedConfig: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存之前的通用配置片段,用于替换操作
|
||||||
|
let previousCommonSnippet = "";
|
||||||
|
|
||||||
|
// 将通用配置片段写入/移除 TOML 配置
|
||||||
|
export const updateTomlCommonConfigSnippet = (
|
||||||
|
tomlString: string,
|
||||||
|
snippetString: string,
|
||||||
|
enabled: boolean,
|
||||||
|
): UpdateTomlCommonConfigResult => {
|
||||||
|
if (!snippetString.trim()) {
|
||||||
|
// 如果片段为空,直接返回原始配置
|
||||||
|
return {
|
||||||
|
updatedConfig: tomlString,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
// 添加通用配置
|
||||||
|
// 先移除旧的通用配置(如果有)
|
||||||
|
let updatedConfig = tomlString;
|
||||||
|
if (previousCommonSnippet && tomlString.includes(previousCommonSnippet)) {
|
||||||
|
updatedConfig = tomlString.replace(previousCommonSnippet, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在文件末尾添加新的通用配置
|
||||||
|
// 确保有适当的换行
|
||||||
|
const needsNewline = updatedConfig && !updatedConfig.endsWith("\n");
|
||||||
|
updatedConfig =
|
||||||
|
updatedConfig + (needsNewline ? "\n\n" : "\n") + snippetString;
|
||||||
|
|
||||||
|
// 保存当前通用配置片段
|
||||||
|
previousCommonSnippet = snippetString;
|
||||||
|
|
||||||
|
return {
|
||||||
|
updatedConfig: updatedConfig.trim() + "\n",
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// 移除通用配置
|
||||||
|
if (tomlString.includes(snippetString)) {
|
||||||
|
const updatedConfig = tomlString.replace(snippetString, "");
|
||||||
|
// 清理多余的空行
|
||||||
|
const cleaned = updatedConfig.replace(/\n{3,}/g, "\n\n").trim();
|
||||||
|
|
||||||
|
// 清空保存的状态
|
||||||
|
previousCommonSnippet = "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
updatedConfig: cleaned ? cleaned + "\n" : "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
updatedConfig: tomlString,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 检查 TOML 配置是否已包含通用配置片段
|
||||||
|
export const hasTomlCommonConfigSnippet = (
|
||||||
|
tomlString: string,
|
||||||
|
snippetString: string,
|
||||||
|
): boolean => {
|
||||||
|
if (!snippetString.trim()) return false;
|
||||||
|
|
||||||
|
// 简单检查配置是否包含片段内容
|
||||||
|
// 去除空白字符后比较,避免格式差异影响
|
||||||
|
const normalizeWhitespace = (str: string) => str.replace(/\s+/g, " ").trim();
|
||||||
|
|
||||||
|
return normalizeWhitespace(tomlString).includes(
|
||||||
|
normalizeWhitespace(snippetString),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========== Codex base_url utils ==========
|
||||||
|
|
||||||
|
// 从 Codex 的 TOML 配置文本中提取 base_url(支持单/双引号)
|
||||||
|
export const extractCodexBaseUrl = (
|
||||||
|
configText: string | undefined | null,
|
||||||
|
): string | undefined => {
|
||||||
|
try {
|
||||||
|
const text = typeof configText === "string" ? configText : "";
|
||||||
|
if (!text) return undefined;
|
||||||
|
const m = text.match(/base_url\s*=\s*(['"])([^'\"]+)\1/);
|
||||||
|
return m && m[2] ? m[2] : undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 从 Provider 对象中提取 Codex base_url(当 settingsConfig.config 为 TOML 字符串时)
|
||||||
|
export const getCodexBaseUrl = (
|
||||||
|
provider: { settingsConfig?: Record<string, any> } | undefined | null,
|
||||||
|
): string | undefined => {
|
||||||
|
try {
|
||||||
|
const text =
|
||||||
|
typeof provider?.settingsConfig?.config === "string"
|
||||||
|
? (provider as any).settingsConfig.config
|
||||||
|
: "";
|
||||||
|
return extractCodexBaseUrl(text);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
124
src/utils/vscodeSettings.ts
Normal file
124
src/utils/vscodeSettings.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { applyEdits, modify, parse } from "jsonc-parser";
|
||||||
|
|
||||||
|
const fmt = { insertSpaces: true, tabSize: 2, eol: "\n" } as const;
|
||||||
|
|
||||||
|
export interface AppliedCheck {
|
||||||
|
hasApiBase: boolean;
|
||||||
|
apiBase?: string;
|
||||||
|
hasPreferredAuthMethod: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeBaseUrl(url: string): string {
|
||||||
|
return url.replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDocEmpty = (s: string) => s.trim().length === 0;
|
||||||
|
|
||||||
|
// 检查 settings.json(JSONC 文本)中是否已经应用了我们的键
|
||||||
|
export function detectApplied(content: string): AppliedCheck {
|
||||||
|
try {
|
||||||
|
// 允许 JSONC 的宽松解析:jsonc-parser 的 parse 可以直接处理注释
|
||||||
|
const data = parse(content) as any;
|
||||||
|
const apiBase = data?.["chatgpt.apiBase"];
|
||||||
|
const method = data?.["chatgpt.config"]?.preferred_auth_method;
|
||||||
|
return {
|
||||||
|
hasApiBase: typeof apiBase === "string",
|
||||||
|
apiBase,
|
||||||
|
hasPreferredAuthMethod: typeof method === "string",
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { hasApiBase: false, hasPreferredAuthMethod: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成“清理我们管理的键”后的文本(仅删除我们写入的两个键)
|
||||||
|
export function removeManagedKeys(content: string): string {
|
||||||
|
if (isDocEmpty(content)) return content; // 空文档无需删除
|
||||||
|
let out = content;
|
||||||
|
// 删除 chatgpt.apiBase
|
||||||
|
try {
|
||||||
|
out = applyEdits(
|
||||||
|
out,
|
||||||
|
modify(out, ["chatgpt.apiBase"], undefined, { formattingOptions: fmt }),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// 忽略删除失败
|
||||||
|
}
|
||||||
|
// 删除 chatgpt.config.preferred_auth_method(注意 chatgpt.config 是顶层带点的键)
|
||||||
|
try {
|
||||||
|
out = applyEdits(
|
||||||
|
out,
|
||||||
|
modify(out, ["chatgpt.config", "preferred_auth_method"], undefined, {
|
||||||
|
formattingOptions: fmt,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// 忽略删除失败
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兼容早期错误写入:若曾写成嵌套 chatgpt.config.preferred_auth_method,也一并清理
|
||||||
|
try {
|
||||||
|
out = applyEdits(
|
||||||
|
out,
|
||||||
|
modify(out, ["chatgpt", "config", "preferred_auth_method"], undefined, {
|
||||||
|
formattingOptions: fmt,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// 忽略删除失败
|
||||||
|
}
|
||||||
|
|
||||||
|
// 若 chatgpt.config 变为空对象,顺便移除(不影响其他 chatgpt* 键)
|
||||||
|
try {
|
||||||
|
const data = parse(out) as any;
|
||||||
|
const cfg = data?.["chatgpt.config"];
|
||||||
|
if (
|
||||||
|
cfg &&
|
||||||
|
typeof cfg === "object" &&
|
||||||
|
!Array.isArray(cfg) &&
|
||||||
|
Object.keys(cfg).length === 0
|
||||||
|
) {
|
||||||
|
out = applyEdits(
|
||||||
|
out,
|
||||||
|
modify(out, ["chatgpt.config"], undefined, { formattingOptions: fmt }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 忽略解析失败,保持已删除的键
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成“应用供应商到 VS Code”后的文本:
|
||||||
|
// - 先清理我们管理的键
|
||||||
|
// - 再根据是否官方决定写入(官方:不写入;非官方:写入两个键)
|
||||||
|
export function applyProviderToVSCode(
|
||||||
|
content: string,
|
||||||
|
opts: { baseUrl?: string | null; isOfficial?: boolean },
|
||||||
|
): string {
|
||||||
|
let out = removeManagedKeys(content);
|
||||||
|
if (!opts.isOfficial && opts.baseUrl) {
|
||||||
|
const apiBase = normalizeBaseUrl(opts.baseUrl);
|
||||||
|
if (isDocEmpty(out)) {
|
||||||
|
// 简化:空文档直接写入新对象
|
||||||
|
const obj: any = {
|
||||||
|
"chatgpt.apiBase": apiBase,
|
||||||
|
"chatgpt.config": { preferred_auth_method: "apikey" },
|
||||||
|
};
|
||||||
|
out = JSON.stringify(obj, null, 2) + "\n";
|
||||||
|
} else {
|
||||||
|
out = applyEdits(
|
||||||
|
out,
|
||||||
|
modify(out, ["chatgpt.apiBase"], apiBase, { formattingOptions: fmt }),
|
||||||
|
);
|
||||||
|
out = applyEdits(
|
||||||
|
out,
|
||||||
|
modify(out, ["chatgpt.config", "preferred_auth_method"], "apikey", {
|
||||||
|
formattingOptions: fmt,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
4
src/vite-env.d.ts
vendored
4
src/vite-env.d.ts
vendored
@@ -40,6 +40,10 @@ declare global {
|
|||||||
checkForUpdates: () => Promise<void>;
|
checkForUpdates: () => Promise<void>;
|
||||||
getAppConfigPath: () => Promise<string>;
|
getAppConfigPath: () => Promise<string>;
|
||||||
openAppConfigFolder: () => Promise<void>;
|
openAppConfigFolder: () => Promise<void>;
|
||||||
|
// VS Code settings.json 能力
|
||||||
|
getVSCodeSettingsStatus: () => Promise<ConfigStatus>;
|
||||||
|
readVSCodeSettings: () => Promise<string>;
|
||||||
|
writeVSCodeSettings: (content: string) => Promise<boolean>;
|
||||||
};
|
};
|
||||||
platform: {
|
platform: {
|
||||||
isMac: boolean;
|
isMac: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user