mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-26 14:35:22 +08:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a433b0ff7 | |||
| 1555dbc55e | |||
| 3551e3c496 | |||
| 08014f99e6 | |||
| 3006c6a23d | |||
| 9b14721d4c | |||
| 65c96db0d1 | |||
| e5867ca2d1 | |||
| 854f19d0e1 | |||
| 905f7ccbfe | |||
| 3e4c87278f | |||
| f4e960253e | |||
| 324a1da8e6 | |||
| 49f66bcc9a | |||
| 4084b53834 | |||
| 2c2c72271a | |||
| 6a1ba46f2a |
@@ -100,11 +100,6 @@ Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original pric
|
|||||||
<td>Thanks to CTok.ai for sponsoring this project! CTok.ai is dedicated to building a one-stop AI programming tool service platform. We offer professional Claude Code packages and technical community services, with support for Google Gemini and OpenAI Codex. Through carefully designed plans and a professional tech community, we provide developers with reliable service guarantees and continuous technical support, making AI-assisted programming a true productivity tool. Click <a href="https://ctok.ai">here</a> to register!</td>
|
<td>Thanks to CTok.ai for sponsoring this project! CTok.ai is dedicated to building a one-stop AI programming tool service platform. We offer professional Claude Code packages and technical community services, with support for Google Gemini and OpenAI Codex. Through carefully designed plans and a professional tech community, we provide developers with reliable service guarantees and continuous technical support, making AI-assisted programming a true productivity tool. Click <a href="https://ctok.ai">here</a> to register!</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
|
||||||
<td width="180"><a href="https://chefshop.ai"><img src="assets/partners/logos/chefshop.png" alt="ChefShop" width="150"></a></td>
|
|
||||||
<td>Thanks to ChefShop AI for sponsoring this project! ChefShop AI is a premium account service provider tailored for heavy AI subscription users. The platform offers official top-up and stable account services for mainstream large models including ChatGPT Plus/Pro, Claude Max, Grok Super/Heavy, and Gemini. Click <a href="https://chefshop.ai">here</a> to purchase!</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@@ -100,11 +100,6 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
|
|||||||
<td>CTok.ai のご支援に感謝します!CTok.ai はワンストップ AI プログラミングツールサービスプラットフォームの構築に取り組んでいます。Claude Code のプロフェッショナルプランと技術コミュニティサービスを提供し、Google Gemini や OpenAI Codex にも対応しています。丁寧に設計されたプランと専門的な技術コミュニティを通じて、開発者に安定したサービス保証と継続的な技術サポートを提供し、AI アシストプログラミングを真の生産性ツールにします。<a href="https://ctok.ai">こちら</a>から登録してください!</td>
|
<td>CTok.ai のご支援に感謝します!CTok.ai はワンストップ AI プログラミングツールサービスプラットフォームの構築に取り組んでいます。Claude Code のプロフェッショナルプランと技術コミュニティサービスを提供し、Google Gemini や OpenAI Codex にも対応しています。丁寧に設計されたプランと専門的な技術コミュニティを通じて、開発者に安定したサービス保証と継続的な技術サポートを提供し、AI アシストプログラミングを真の生産性ツールにします。<a href="https://ctok.ai">こちら</a>から登録してください!</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
|
||||||
<td width="180"><a href="https://chefshop.ai"><img src="assets/partners/logos/chefshop.png" alt="ChefShop" width="150"></a></td>
|
|
||||||
<td>ChefShop AI のご支援に感謝します!ChefShop AI は、AI ヘビーユーザー向けにカスタマイズされたプレミアムアカウントサービスプロバイダーです。ChatGPT Plus/Pro、Claude Max、Grok Super/Heavy、Gemini など主流の大規模モデルの公式チャージと安定したアカウントサービスを提供しています。<a href="https://chefshop.ai">こちら</a>から購入してください!</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@@ -101,11 +101,6 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
|
|||||||
<td>感谢 CTok.ai 赞助了本项目!CTok.ai 致力于打造一站式 AI 编程工具服务平台。我们提供 Claude Code 专业套餐及技术社群服务,同时支持 Google Gemini 和 OpenAI Codex。通过精心设计的套餐方案和专业的技术社群,为开发者提供稳定的服务保障和持续的技术支持,让 AI 辅助编程真正成为开发者的生产力工具。点击<a href="https://ctok.ai">这里</a>注册!</td>
|
<td>感谢 CTok.ai 赞助了本项目!CTok.ai 致力于打造一站式 AI 编程工具服务平台。我们提供 Claude Code 专业套餐及技术社群服务,同时支持 Google Gemini 和 OpenAI Codex。通过精心设计的套餐方案和专业的技术社群,为开发者提供稳定的服务保障和持续的技术支持,让 AI 辅助编程真正成为开发者的生产力工具。点击<a href="https://ctok.ai">这里</a>注册!</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
|
||||||
<td width="180"><a href="https://chefshop.ai"><img src="assets/partners/logos/chefshop.png" alt="ChefShop" width="150"></a></td>
|
|
||||||
<td>感谢 厨师长AI小铺 赞助了本项目!厨师长AI小铺 是一家专为 AI 重度订阅用户量身定制的优质账号服务商。平台提供涵盖 ChatGPT Plus/Pro、Claude Max、Grok Super/Heavy 以及 Gemini 等主流大模型的官方代充与稳定成品账号服务。点击<a href="https://chefshop.ai">这里</a>购买!</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 280 KiB |
@@ -1,14 +0,0 @@
|
|||||||
#[tauri::command]
|
|
||||||
pub fn enter_lightweight_mode(app: tauri::AppHandle) -> Result<(), String> {
|
|
||||||
crate::lightweight::enter_lightweight_mode(&app)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn exit_lightweight_mode(app: tauri::AppHandle) -> Result<(), String> {
|
|
||||||
crate::lightweight::exit_lightweight_mode(&app)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn is_lightweight_mode() -> bool {
|
|
||||||
crate::lightweight::is_lightweight_mode()
|
|
||||||
}
|
|
||||||
+11
-171
@@ -6,7 +6,7 @@ use crate::services::ProviderService;
|
|||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::Path;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use tauri::AppHandle;
|
use tauri::AppHandle;
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
@@ -720,10 +720,8 @@ pub async fn open_provider_terminal(
|
|||||||
state: State<'_, crate::store::AppState>,
|
state: State<'_, crate::store::AppState>,
|
||||||
app: String,
|
app: String,
|
||||||
#[allow(non_snake_case)] providerId: String,
|
#[allow(non_snake_case)] providerId: String,
|
||||||
cwd: Option<String>,
|
|
||||||
) -> Result<bool, String> {
|
) -> Result<bool, String> {
|
||||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
let launch_cwd = resolve_launch_cwd(cwd)?;
|
|
||||||
|
|
||||||
// 获取提供商配置
|
// 获取提供商配置
|
||||||
let providers = ProviderService::list(state.inner(), app_type.clone())
|
let providers = ProviderService::list(state.inner(), app_type.clone())
|
||||||
@@ -738,8 +736,7 @@ pub async fn open_provider_terminal(
|
|||||||
let env_vars = extract_env_vars_from_config(config, &app_type);
|
let env_vars = extract_env_vars_from_config(config, &app_type);
|
||||||
|
|
||||||
// 根据平台启动终端,传入提供商ID用于生成唯一的配置文件名
|
// 根据平台启动终端,传入提供商ID用于生成唯一的配置文件名
|
||||||
launch_terminal_with_env(env_vars, &providerId, launch_cwd.as_deref())
|
launch_terminal_with_env(env_vars, &providerId).map_err(|e| format!("启动终端失败: {e}"))?;
|
||||||
.map_err(|e| format!("启动终端失败: {e}"))?;
|
|
||||||
|
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
@@ -794,49 +791,11 @@ fn extract_env_vars_from_config(
|
|||||||
env_vars
|
env_vars
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_launch_cwd(cwd: Option<String>) -> Result<Option<PathBuf>, String> {
|
|
||||||
let Some(raw_path) = cwd.filter(|value| !value.trim().is_empty()) else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
|
|
||||||
if raw_path.contains('\n') || raw_path.contains('\r') {
|
|
||||||
return Err("目录路径包含非法换行符".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
let path = Path::new(&raw_path);
|
|
||||||
if !path.exists() {
|
|
||||||
return Err(format!("目录不存在: {raw_path}"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let resolved = std::fs::canonicalize(path).map_err(|e| format!("解析目录失败: {e}"))?;
|
|
||||||
if !resolved.is_dir() {
|
|
||||||
return Err(format!("选择的路径不是文件夹: {}", resolved.display()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strip Windows extended-length prefix that canonicalize produces,
|
|
||||||
// as it can break batch scripts and other shell commands.
|
|
||||||
// Special-case \\?\UNC\server\share -> \\server\share for network/WSL paths.
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
let resolved = {
|
|
||||||
let s = resolved.to_string_lossy();
|
|
||||||
if let Some(unc) = s.strip_prefix(r"\\?\UNC\") {
|
|
||||||
PathBuf::from(format!(r"\\{unc}"))
|
|
||||||
} else if let Some(stripped) = s.strip_prefix(r"\\?\") {
|
|
||||||
PathBuf::from(stripped)
|
|
||||||
} else {
|
|
||||||
resolved
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Some(resolved))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 创建临时配置文件并启动 claude 终端
|
/// 创建临时配置文件并启动 claude 终端
|
||||||
/// 使用 --settings 参数传入提供商特定的 API 配置
|
/// 使用 --settings 参数传入提供商特定的 API 配置
|
||||||
fn launch_terminal_with_env(
|
fn launch_terminal_with_env(
|
||||||
env_vars: Vec<(String, String)>,
|
env_vars: Vec<(String, String)>,
|
||||||
provider_id: &str,
|
provider_id: &str,
|
||||||
cwd: Option<&Path>,
|
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let temp_dir = std::env::temp_dir();
|
let temp_dir = std::env::temp_dir();
|
||||||
let config_file = temp_dir.join(format!(
|
let config_file = temp_dir.join(format!(
|
||||||
@@ -850,19 +809,19 @@ fn launch_terminal_with_env(
|
|||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
{
|
{
|
||||||
launch_macos_terminal(&config_file, cwd)?;
|
launch_macos_terminal(&config_file)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
{
|
{
|
||||||
launch_linux_terminal(&config_file, cwd)?;
|
launch_linux_terminal(&config_file)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
{
|
{
|
||||||
launch_windows_terminal(&temp_dir, &config_file, cwd)?;
|
launch_windows_terminal(&temp_dir, &config_file)?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -892,7 +851,7 @@ fn write_claude_config(
|
|||||||
|
|
||||||
/// macOS: 根据用户首选终端启动
|
/// macOS: 根据用户首选终端启动
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
fn launch_macos_terminal(config_file: &std::path::Path, cwd: Option<&Path>) -> Result<(), String> {
|
fn launch_macos_terminal(config_file: &std::path::Path) -> Result<(), String> {
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|
||||||
let preferred = crate::settings::get_preferred_terminal();
|
let preferred = crate::settings::get_preferred_terminal();
|
||||||
@@ -901,21 +860,18 @@ fn launch_macos_terminal(config_file: &std::path::Path, cwd: Option<&Path>) -> R
|
|||||||
let temp_dir = std::env::temp_dir();
|
let temp_dir = std::env::temp_dir();
|
||||||
let script_file = temp_dir.join(format!("cc_switch_launcher_{}.sh", std::process::id()));
|
let script_file = temp_dir.join(format!("cc_switch_launcher_{}.sh", std::process::id()));
|
||||||
let config_path = config_file.to_string_lossy();
|
let config_path = config_file.to_string_lossy();
|
||||||
let cd_command = build_shell_cd_command(cwd);
|
|
||||||
|
|
||||||
// Write the shell script to a temp file
|
// Write the shell script to a temp file
|
||||||
let script_content = format!(
|
let script_content = format!(
|
||||||
r#"#!/bin/bash
|
r#"#!/bin/bash
|
||||||
trap 'rm -f "{config_path}" "{script_file}"' EXIT
|
trap 'rm -f "{config_path}" "{script_file}"' EXIT
|
||||||
{cd_command}
|
|
||||||
echo "Using provider-specific claude config:"
|
echo "Using provider-specific claude config:"
|
||||||
echo "{config_path}"
|
echo "{config_path}"
|
||||||
claude --settings "{config_path}"
|
claude --settings "{config_path}"
|
||||||
exec bash --norc --noprofile
|
exec bash --norc --noprofile
|
||||||
"#,
|
"#,
|
||||||
config_path = config_path,
|
config_path = config_path,
|
||||||
script_file = script_file.display(),
|
script_file = script_file.display()
|
||||||
cd_command = cd_command,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
std::fs::write(&script_file, &script_content).map_err(|e| format!("写入启动脚本失败: {e}"))?;
|
std::fs::write(&script_file, &script_content).map_err(|e| format!("写入启动脚本失败: {e}"))?;
|
||||||
@@ -1051,7 +1007,7 @@ fn launch_macos_open_app(
|
|||||||
|
|
||||||
/// Linux: 根据用户首选终端启动
|
/// Linux: 根据用户首选终端启动
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
fn launch_linux_terminal(config_file: &std::path::Path, cwd: Option<&Path>) -> Result<(), String> {
|
fn launch_linux_terminal(config_file: &std::path::Path) -> Result<(), String> {
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
@@ -1073,20 +1029,17 @@ fn launch_linux_terminal(config_file: &std::path::Path, cwd: Option<&Path>) -> R
|
|||||||
let temp_dir = std::env::temp_dir();
|
let temp_dir = std::env::temp_dir();
|
||||||
let script_file = temp_dir.join(format!("cc_switch_launcher_{}.sh", std::process::id()));
|
let script_file = temp_dir.join(format!("cc_switch_launcher_{}.sh", std::process::id()));
|
||||||
let config_path = config_file.to_string_lossy();
|
let config_path = config_file.to_string_lossy();
|
||||||
let cd_command = build_shell_cd_command(cwd);
|
|
||||||
|
|
||||||
let script_content = format!(
|
let script_content = format!(
|
||||||
r#"#!/bin/bash
|
r#"#!/bin/bash
|
||||||
trap 'rm -f "{config_path}" "{script_file}"' EXIT
|
trap 'rm -f "{config_path}" "{script_file}"' EXIT
|
||||||
{cd_command}
|
|
||||||
echo "Using provider-specific claude config:"
|
echo "Using provider-specific claude config:"
|
||||||
echo "{config_path}"
|
echo "{config_path}"
|
||||||
claude --settings "{config_path}"
|
claude --settings "{config_path}"
|
||||||
exec bash --norc --noprofile
|
exec bash --norc --noprofile
|
||||||
"#,
|
"#,
|
||||||
config_path = config_path,
|
config_path = config_path,
|
||||||
script_file = script_file.display(),
|
script_file = script_file.display()
|
||||||
cd_command = cd_command,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
std::fs::write(&script_file, &script_content).map_err(|e| format!("写入启动脚本失败: {e}"))?;
|
std::fs::write(&script_file, &script_content).map_err(|e| format!("写入启动脚本失败: {e}"))?;
|
||||||
@@ -1165,28 +1118,22 @@ fn which_command(cmd: &str) -> bool {
|
|||||||
fn launch_windows_terminal(
|
fn launch_windows_terminal(
|
||||||
temp_dir: &std::path::Path,
|
temp_dir: &std::path::Path,
|
||||||
config_file: &std::path::Path,
|
config_file: &std::path::Path,
|
||||||
cwd: Option<&Path>,
|
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let preferred = crate::settings::get_preferred_terminal();
|
let preferred = crate::settings::get_preferred_terminal();
|
||||||
let terminal = preferred.as_deref().unwrap_or("cmd");
|
let terminal = preferred.as_deref().unwrap_or("cmd");
|
||||||
|
|
||||||
let bat_file = temp_dir.join(format!("cc_switch_claude_{}.bat", std::process::id()));
|
let bat_file = temp_dir.join(format!("cc_switch_claude_{}.bat", std::process::id()));
|
||||||
let config_path_for_batch = escape_windows_batch_value(&config_file.to_string_lossy());
|
let config_path_for_batch = config_file.to_string_lossy().replace('&', "^&");
|
||||||
let cwd_command = build_windows_cwd_command(cwd);
|
|
||||||
|
|
||||||
let content = format!(
|
let content = format!(
|
||||||
"@echo off
|
"@echo off
|
||||||
{cwd_command}
|
|
||||||
echo Using provider-specific claude config:
|
echo Using provider-specific claude config:
|
||||||
echo {}
|
echo {}
|
||||||
claude --settings \"{}\"
|
claude --settings \"{}\"
|
||||||
del \"{}\" >nul 2>&1
|
del \"{}\" >nul 2>&1
|
||||||
del \"%~f0\" >nul 2>&1
|
del \"%~f0\" >nul 2>&1
|
||||||
",
|
",
|
||||||
config_path_for_batch,
|
config_path_for_batch, config_path_for_batch, config_path_for_batch
|
||||||
config_path_for_batch,
|
|
||||||
config_path_for_batch,
|
|
||||||
cwd_command = cwd_command,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
std::fs::write(&bat_file, &content).map_err(|e| format!("写入批处理文件失败: {e}"))?;
|
std::fs::write(&bat_file, &content).map_err(|e| format!("写入批处理文件失败: {e}"))?;
|
||||||
@@ -1217,55 +1164,6 @@ del \"%~f0\" >nul 2>&1
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_shell_cd_command(cwd: Option<&Path>) -> String {
|
|
||||||
cwd.map(|dir| {
|
|
||||||
format!(
|
|
||||||
"cd {} || exit 1\n",
|
|
||||||
shell_single_quote(&dir.to_string_lossy())
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn shell_single_quote(value: &str) -> String {
|
|
||||||
format!("'{}'", value.replace('\'', "'\"'\"'"))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
|
||||||
fn is_windows_unc_path(path: &str) -> bool {
|
|
||||||
path.starts_with(r"\\")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
|
||||||
fn build_windows_cwd_command_str(path: &str) -> String {
|
|
||||||
let escaped = escape_windows_batch_value(path);
|
|
||||||
|
|
||||||
if is_windows_unc_path(path) {
|
|
||||||
// `cmd.exe` cannot make a UNC path current via `cd`; `pushd` maps it first.
|
|
||||||
format!("pushd \"{escaped}\" || exit /b 1\r\n")
|
|
||||||
} else {
|
|
||||||
format!("cd /d \"{escaped}\" || exit /b 1\r\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
fn build_windows_cwd_command(cwd: Option<&Path>) -> String {
|
|
||||||
cwd.map(|dir| build_windows_cwd_command_str(&dir.to_string_lossy()))
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
|
||||||
fn escape_windows_batch_value(value: &str) -> String {
|
|
||||||
value
|
|
||||||
.replace('^', "^^")
|
|
||||||
.replace('%', "%%")
|
|
||||||
.replace('&', "^&")
|
|
||||||
.replace('|', "^|")
|
|
||||||
.replace('<', "^<")
|
|
||||||
.replace('>', "^>")
|
|
||||||
.replace('(', "^(")
|
|
||||||
.replace(')', "^)")
|
|
||||||
}
|
|
||||||
/// Windows: Run a start command with common error handling
|
/// Windows: Run a start command with common error handling
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
fn run_windows_start_command(args: &[&str], terminal_name: &str) -> Result<(), String> {
|
fn run_windows_start_command(args: &[&str], terminal_name: &str) -> Result<(), String> {
|
||||||
@@ -1440,62 +1338,4 @@ mod tests {
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_launch_cwd_accepts_existing_directory() {
|
|
||||||
let resolved =
|
|
||||||
resolve_launch_cwd(Some(std::env::temp_dir().to_string_lossy().into_owned()))
|
|
||||||
.expect("temp dir should resolve")
|
|
||||||
.expect("temp dir should be present");
|
|
||||||
|
|
||||||
assert!(resolved.is_dir());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_launch_cwd_rejects_missing_directory() {
|
|
||||||
let unique = std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.expect("clock should be after epoch")
|
|
||||||
.as_nanos();
|
|
||||||
let missing = std::env::temp_dir().join(format!("cc-switch-missing-{unique}"));
|
|
||||||
|
|
||||||
let error = resolve_launch_cwd(Some(missing.to_string_lossy().into_owned()))
|
|
||||||
.expect_err("missing directory should fail");
|
|
||||||
|
|
||||||
assert!(error.contains("目录不存在"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn build_shell_cd_command_quotes_spaces_and_single_quotes() {
|
|
||||||
let command = build_shell_cd_command(Some(Path::new("/tmp/project O'Brien")));
|
|
||||||
|
|
||||||
assert_eq!(command, "cd '/tmp/project O'\"'\"'Brien' || exit 1\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn build_windows_cwd_command_str_uses_cd_for_drive_paths() {
|
|
||||||
let command = build_windows_cwd_command_str(r"C:\work\repo");
|
|
||||||
|
|
||||||
assert_eq!(command, "cd /d \"C:\\work\\repo\" || exit /b 1\r\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn build_windows_cwd_command_str_uses_pushd_for_unc_paths() {
|
|
||||||
let command = build_windows_cwd_command_str(r"\\wsl$\Ubuntu\home\coder\repo");
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
command,
|
|
||||||
"pushd \"\\\\wsl$\\Ubuntu\\home\\coder\\repo\" || exit /b 1\r\n"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn build_windows_cwd_command_str_escapes_batch_metacharacters() {
|
|
||||||
let command = build_windows_cwd_command_str(r"\\server\share\100%&(test)");
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
command,
|
|
||||||
"pushd \"\\\\server\\share\\100%%^&^(test^)\" || exit /b 1\r\n"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ pub mod skill;
|
|||||||
mod stream_check;
|
mod stream_check;
|
||||||
mod sync_support;
|
mod sync_support;
|
||||||
|
|
||||||
mod lightweight;
|
|
||||||
mod usage;
|
mod usage;
|
||||||
mod webdav_sync;
|
mod webdav_sync;
|
||||||
mod workspace;
|
mod workspace;
|
||||||
@@ -48,7 +47,6 @@ pub use settings::*;
|
|||||||
pub use skill::*;
|
pub use skill::*;
|
||||||
pub use stream_check::*;
|
pub use stream_check::*;
|
||||||
|
|
||||||
pub use lightweight::*;
|
|
||||||
pub use usage::*;
|
pub use usage::*;
|
||||||
pub use webdav_sync::*;
|
pub use webdav_sync::*;
|
||||||
pub use workspace::*;
|
pub use workspace::*;
|
||||||
|
|||||||
@@ -36,11 +36,9 @@ pub fn add_provider(
|
|||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
app: String,
|
app: String,
|
||||||
provider: Provider,
|
provider: Provider,
|
||||||
#[allow(non_snake_case)] addToLive: Option<bool>,
|
|
||||||
) -> Result<bool, String> {
|
) -> Result<bool, String> {
|
||||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
ProviderService::add(state.inner(), app_type, provider, addToLive.unwrap_or(true))
|
ProviderService::add(state.inner(), app_type, provider).map_err(|e| e.to_string())
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -48,11 +46,9 @@ pub fn update_provider(
|
|||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
app: String,
|
app: String,
|
||||||
provider: Provider,
|
provider: Provider,
|
||||||
#[allow(non_snake_case)] originalId: Option<String>,
|
|
||||||
) -> Result<bool, String> {
|
) -> Result<bool, String> {
|
||||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
ProviderService::update(state.inner(), app_type, originalId.as_deref(), provider)
|
ProviderService::update(state.inner(), app_type, provider).map_err(|e| e.to_string())
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|||||||
@@ -74,12 +74,3 @@ pub async fn delete_session(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to delete session: {e}"))?
|
.map_err(|e| format!("Failed to delete session: {e}"))?
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn delete_sessions(
|
|
||||||
items: Vec<session_manager::DeleteSessionRequest>,
|
|
||||||
) -> Result<Vec<session_manager::DeleteSessionOutcome>, String> {
|
|
||||||
tauri::async_runtime::spawn_blocking(move || session_manager::delete_sessions(&items))
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Failed to delete sessions: {e}"))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ pub fn import_provider_from_deeplink(
|
|||||||
let provider_id = provider.id.clone();
|
let provider_id = provider.id.clone();
|
||||||
|
|
||||||
// Use ProviderService to add the provider
|
// Use ProviderService to add the provider
|
||||||
ProviderService::add(state, app_type.clone(), provider, true)?;
|
ProviderService::add(state, app_type.clone(), provider)?;
|
||||||
|
|
||||||
// Add extra endpoints as custom endpoints (skip first one as it's the primary)
|
// Add extra endpoints as custom endpoints (skip first one as it's the primary)
|
||||||
for ep in all_endpoints.iter().skip(1) {
|
for ep in all_endpoints.iter().skip(1) {
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ mod error;
|
|||||||
mod gemini_config;
|
mod gemini_config;
|
||||||
mod gemini_mcp;
|
mod gemini_mcp;
|
||||||
mod init_status;
|
mod init_status;
|
||||||
mod lightweight;
|
|
||||||
mod mcp;
|
mod mcp;
|
||||||
mod openclaw_config;
|
mod openclaw_config;
|
||||||
mod opencode_config;
|
mod opencode_config;
|
||||||
@@ -205,12 +204,6 @@ pub fn run() {
|
|||||||
log::debug!(" arg[{i}]: {}", redact_url_for_log(arg));
|
log::debug!(" arg[{i}]: {}", redact_url_for_log(arg));
|
||||||
}
|
}
|
||||||
|
|
||||||
if crate::lightweight::is_lightweight_mode() {
|
|
||||||
if let Err(e) = crate::lightweight::exit_lightweight_mode(app) {
|
|
||||||
log::error!("退出轻量模式重建窗口失败: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for deep link URL in args (mainly for Windows/Linux command line)
|
// Check for deep link URL in args (mainly for Windows/Linux command line)
|
||||||
let mut found_deeplink = false;
|
let mut found_deeplink = false;
|
||||||
for arg in &args {
|
for arg in &args {
|
||||||
@@ -622,12 +615,6 @@ pub fn run() {
|
|||||||
let urls = event.urls();
|
let urls = event.urls();
|
||||||
log::info!("Received {} URL(s)", urls.len());
|
log::info!("Received {} URL(s)", urls.len());
|
||||||
|
|
||||||
if crate::lightweight::is_lightweight_mode() {
|
|
||||||
if let Err(e) = crate::lightweight::exit_lightweight_mode(&app_handle) {
|
|
||||||
log::error!("退出轻量模式重建窗口失败: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (i, url) in urls.iter().enumerate() {
|
for (i, url) in urls.iter().enumerate() {
|
||||||
let url_str = url.as_str();
|
let url_str = url.as_str();
|
||||||
log::debug!(" URL[{i}]: {}", redact_url_for_log(url_str));
|
log::debug!(" URL[{i}]: {}", redact_url_for_log(url_str));
|
||||||
@@ -1021,7 +1008,6 @@ pub fn run() {
|
|||||||
commands::list_sessions,
|
commands::list_sessions,
|
||||||
commands::get_session_messages,
|
commands::get_session_messages,
|
||||||
commands::delete_session,
|
commands::delete_session,
|
||||||
commands::delete_sessions,
|
|
||||||
commands::launch_session_terminal,
|
commands::launch_session_terminal,
|
||||||
commands::get_tool_versions,
|
commands::get_tool_versions,
|
||||||
// Provider terminal
|
// Provider terminal
|
||||||
@@ -1099,10 +1085,6 @@ pub fn run() {
|
|||||||
commands::delete_daily_memory_file,
|
commands::delete_daily_memory_file,
|
||||||
commands::search_daily_memory_files,
|
commands::search_daily_memory_files,
|
||||||
commands::open_workspace_directory,
|
commands::open_workspace_directory,
|
||||||
// lightweight mode (for testing or low-resource environments)
|
|
||||||
commands::enter_lightweight_mode,
|
|
||||||
commands::exit_lightweight_mode,
|
|
||||||
commands::is_lightweight_mode,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let app = builder
|
let app = builder
|
||||||
@@ -1153,10 +1135,6 @@ pub fn run() {
|
|||||||
let _ = window.show();
|
let _ = window.show();
|
||||||
let _ = window.set_focus();
|
let _ = window.set_focus();
|
||||||
tray::apply_tray_policy(app_handle, true);
|
tray::apply_tray_policy(app_handle, true);
|
||||||
} else if crate::lightweight::is_lightweight_mode() {
|
|
||||||
if let Err(e) = crate::lightweight::exit_lightweight_mode(app_handle) {
|
|
||||||
log::error!("退出轻量模式重建窗口失败: {e}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 处理通过自定义 URL 协议触发的打开事件(例如 ccswitch://...)
|
// 处理通过自定义 URL 协议触发的打开事件(例如 ccswitch://...)
|
||||||
@@ -1166,13 +1144,6 @@ pub fn run() {
|
|||||||
log::info!("RunEvent::Opened with URL: {url_str}");
|
log::info!("RunEvent::Opened with URL: {url_str}");
|
||||||
|
|
||||||
if url_str.starts_with("ccswitch://") {
|
if url_str.starts_with("ccswitch://") {
|
||||||
if crate::lightweight::is_lightweight_mode() {
|
|
||||||
if let Err(e) = crate::lightweight::exit_lightweight_mode(app_handle)
|
|
||||||
{
|
|
||||||
log::error!("退出轻量模式重建窗口失败: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 解析并广播深链接事件,复用与 single_instance 相同的逻辑
|
// 解析并广播深链接事件,复用与 single_instance 相同的逻辑
|
||||||
match crate::deeplink::parse_deeplink_url(&url_str) {
|
match crate::deeplink::parse_deeplink_url(&url_str) {
|
||||||
Ok(request) => {
|
Ok(request) => {
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
|
||||||
|
|
||||||
use tauri::Manager;
|
|
||||||
|
|
||||||
static LIGHTWEIGHT_MODE: AtomicBool = AtomicBool::new(false);
|
|
||||||
|
|
||||||
pub fn enter_lightweight_mode(app: &tauri::AppHandle) -> Result<(), String> {
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
if let Some(window) = app.get_webview_window("main") {
|
|
||||||
let _ = window.set_skip_taskbar(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
{
|
|
||||||
crate::tray::apply_tray_policy(app, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(window) = app.get_webview_window("main") {
|
|
||||||
window
|
|
||||||
.destroy()
|
|
||||||
.map_err(|e| format!("销毁主窗口失败: {e}"))?;
|
|
||||||
}
|
|
||||||
// else: already in lightweight mode or window not found, just set the flag
|
|
||||||
|
|
||||||
LIGHTWEIGHT_MODE.store(true, Ordering::Release);
|
|
||||||
crate::tray::refresh_tray_menu(app);
|
|
||||||
log::info!("进入轻量模式");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn exit_lightweight_mode(app: &tauri::AppHandle) -> Result<(), String> {
|
|
||||||
use tauri::WebviewWindowBuilder;
|
|
||||||
|
|
||||||
if let Some(window) = app.get_webview_window("main") {
|
|
||||||
let _ = window.unminimize();
|
|
||||||
let _ = window.show();
|
|
||||||
let _ = window.set_focus();
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
let _ = window.set_skip_taskbar(false);
|
|
||||||
}
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
{
|
|
||||||
crate::tray::apply_tray_policy(app, true);
|
|
||||||
}
|
|
||||||
LIGHTWEIGHT_MODE.store(false, Ordering::Release);
|
|
||||||
crate::tray::refresh_tray_menu(app);
|
|
||||||
log::info!("退出轻量模式");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let window_config = app
|
|
||||||
.config()
|
|
||||||
.app
|
|
||||||
.windows
|
|
||||||
.iter()
|
|
||||||
.find(|w| w.label == "main")
|
|
||||||
.ok_or("主窗口配置未找到")?;
|
|
||||||
|
|
||||||
WebviewWindowBuilder::from_config(app, window_config)
|
|
||||||
.map_err(|e| format!("加载主窗口配置失败: {e}"))?
|
|
||||||
.visible(true)
|
|
||||||
.build()
|
|
||||||
.map_err(|e| format!("创建主窗口失败: {e}"))?;
|
|
||||||
|
|
||||||
if let Some(window) = app.get_webview_window("main") {
|
|
||||||
let _ = window.set_focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
if let Some(window) = app.get_webview_window("main") {
|
|
||||||
let _ = window.set_skip_taskbar(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
{
|
|
||||||
crate::tray::apply_tray_policy(app, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
LIGHTWEIGHT_MODE.store(false, Ordering::Release);
|
|
||||||
crate::tray::refresh_tray_menu(app);
|
|
||||||
log::info!("退出轻量模式");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_lightweight_mode() -> bool {
|
|
||||||
LIGHTWEIGHT_MODE.load(Ordering::Acquire)
|
|
||||||
}
|
|
||||||
@@ -8,6 +8,7 @@ use crate::error::AppError;
|
|||||||
use crate::settings::{effective_backup_retain_count, get_openclaw_override_dir};
|
use crate::settings::{effective_backup_retain_count, get_openclaw_override_dir};
|
||||||
use chrono::Local;
|
use chrono::Local;
|
||||||
use indexmap::IndexMap;
|
use indexmap::IndexMap;
|
||||||
|
use json_five::parser::{FormatConfiguration, TrailingComma};
|
||||||
use json_five::rt::parser::{
|
use json_five::rt::parser::{
|
||||||
from_str as rt_from_str, JSONKeyValuePair as RtJSONKeyValuePair,
|
from_str as rt_from_str, JSONKeyValuePair as RtJSONKeyValuePair,
|
||||||
JSONObjectContext as RtJSONObjectContext, JSONText as RtJSONText, JSONValue as RtJSONValue,
|
JSONObjectContext as RtJSONObjectContext, JSONText as RtJSONText, JSONValue as RtJSONValue,
|
||||||
@@ -489,11 +490,11 @@ fn derive_entry_separator(leading_ws: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn value_to_rt_value(value: &Value, parent_indent: &str) -> Result<RtJSONValue, AppError> {
|
fn value_to_rt_value(value: &Value, parent_indent: &str) -> Result<RtJSONValue, AppError> {
|
||||||
// `json-five` 0.3.1 can panic when pretty-printing nested empty maps/arrays.
|
let source = json_five::to_string_formatted(
|
||||||
// Serialize with `serde_json` instead; the resulting JSON is valid JSON5 and
|
value,
|
||||||
// can still be parsed back into the round-trip AST we use for insertion.
|
FormatConfiguration::with_indent(2, TrailingComma::NONE),
|
||||||
let source = serde_json::to_string_pretty(value)
|
)
|
||||||
.map_err(|e| AppError::Config(format!("Failed to serialize JSON section: {e}")))?;
|
.map_err(|e| AppError::Config(format!("Failed to serialize JSON5 section: {e}")))?;
|
||||||
|
|
||||||
let adjusted = reindent_json5_block(&source, parent_indent);
|
let adjusted = reindent_json5_block(&source, parent_indent);
|
||||||
let text = rt_from_str(&adjusted).map_err(|e| {
|
let text = rt_from_str(&adjusted).map_err(|e| {
|
||||||
@@ -1050,37 +1051,4 @@ mod tests {
|
|||||||
assert!(err.to_string().contains("OpenClaw config changed on disk"));
|
assert!(err.to_string().contains("OpenClaw config changed on disk"));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn remove_last_provider_writes_empty_providers_without_panic() {
|
|
||||||
let source = r#"{
|
|
||||||
models: {
|
|
||||||
mode: 'merge',
|
|
||||||
providers: {
|
|
||||||
'1-copy': {
|
|
||||||
api: 'anthropic-messages',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
"#;
|
|
||||||
|
|
||||||
with_test_paths(source, |_| {
|
|
||||||
let outcome = remove_provider("1-copy").unwrap();
|
|
||||||
assert!(outcome.backup_path.is_some());
|
|
||||||
|
|
||||||
let config = read_openclaw_config().unwrap();
|
|
||||||
let providers = config
|
|
||||||
.get("models")
|
|
||||||
.and_then(|models| models.get("providers"))
|
|
||||||
.and_then(Value::as_object)
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
assert!(providers.is_empty());
|
|
||||||
|
|
||||||
let written = fs::read_to_string(get_openclaw_config_path()).unwrap();
|
|
||||||
assert!(written.contains("\"providers\": {}"));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,32 +6,6 @@ use indexmap::IndexMap;
|
|||||||
use serde_json::{json, Map, Value};
|
use serde_json::{json, Map, Value};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
const STANDARD_OMO_PLUGIN_PREFIXES: [&str; 2] = ["oh-my-openagent", "oh-my-opencode"];
|
|
||||||
const SLIM_OMO_PLUGIN_PREFIXES: [&str; 1] = ["oh-my-opencode-slim"];
|
|
||||||
|
|
||||||
fn matches_plugin_prefix(plugin_name: &str, prefix: &str) -> bool {
|
|
||||||
plugin_name == prefix
|
|
||||||
|| plugin_name
|
|
||||||
.strip_prefix(prefix)
|
|
||||||
.map(|suffix| suffix.starts_with('@'))
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn matches_any_plugin_prefix(plugin_name: &str, prefixes: &[&str]) -> bool {
|
|
||||||
prefixes
|
|
||||||
.iter()
|
|
||||||
.any(|prefix| matches_plugin_prefix(plugin_name, prefix))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn canonicalize_plugin_name(plugin_name: &str) -> String {
|
|
||||||
if let Some(suffix) = plugin_name.strip_prefix("oh-my-opencode") {
|
|
||||||
if suffix.is_empty() || suffix.starts_with('@') {
|
|
||||||
return format!("oh-my-openagent{suffix}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
plugin_name.to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_opencode_dir() -> PathBuf {
|
pub fn get_opencode_dir() -> PathBuf {
|
||||||
if let Some(override_dir) = get_opencode_override_dir() {
|
if let Some(override_dir) = get_opencode_override_dir() {
|
||||||
return override_dir;
|
return override_dir;
|
||||||
@@ -166,56 +140,58 @@ pub fn remove_mcp_server(id: &str) -> Result<(), AppError> {
|
|||||||
|
|
||||||
pub fn add_plugin(plugin_name: &str) -> Result<(), AppError> {
|
pub fn add_plugin(plugin_name: &str) -> Result<(), AppError> {
|
||||||
let mut config = read_opencode_config()?;
|
let mut config = read_opencode_config()?;
|
||||||
let normalized_plugin_name = canonicalize_plugin_name(plugin_name);
|
|
||||||
|
|
||||||
let plugins = config.get_mut("plugin").and_then(|v| v.as_array_mut());
|
let plugins = config.get_mut("plugin").and_then(|v| v.as_array_mut());
|
||||||
|
|
||||||
match plugins {
|
match plugins {
|
||||||
Some(arr) => {
|
Some(arr) => {
|
||||||
// Mutual exclusion: standard OMO and OMO Slim cannot coexist as plugins
|
// Mutual exclusion: standard OMO and OMO Slim cannot coexist as plugins
|
||||||
if matches_any_plugin_prefix(&normalized_plugin_name, &STANDARD_OMO_PLUGIN_PREFIXES) {
|
if plugin_name.starts_with("oh-my-opencode")
|
||||||
|
&& !plugin_name.starts_with("oh-my-opencode-slim")
|
||||||
|
{
|
||||||
|
// Adding standard OMO -> remove all Slim variants
|
||||||
arr.retain(|v| {
|
arr.retain(|v| {
|
||||||
v.as_str()
|
v.as_str()
|
||||||
.map(|s| {
|
.map(|s| !s.starts_with("oh-my-opencode-slim"))
|
||||||
!matches_any_plugin_prefix(s, &STANDARD_OMO_PLUGIN_PREFIXES)
|
|
||||||
&& !matches_any_plugin_prefix(s, &SLIM_OMO_PLUGIN_PREFIXES)
|
|
||||||
})
|
|
||||||
.unwrap_or(true)
|
.unwrap_or(true)
|
||||||
});
|
});
|
||||||
} else if matches_any_plugin_prefix(&normalized_plugin_name, &SLIM_OMO_PLUGIN_PREFIXES)
|
} else if plugin_name.starts_with("oh-my-opencode-slim") {
|
||||||
{
|
// Adding Slim -> remove all standard OMO variants (but keep slim)
|
||||||
arr.retain(|v| {
|
arr.retain(|v| {
|
||||||
v.as_str()
|
v.as_str()
|
||||||
.map(|s| {
|
.map(|s| {
|
||||||
!matches_any_plugin_prefix(s, &STANDARD_OMO_PLUGIN_PREFIXES)
|
!s.starts_with("oh-my-opencode") || s.starts_with("oh-my-opencode-slim")
|
||||||
&& !matches_any_plugin_prefix(s, &SLIM_OMO_PLUGIN_PREFIXES)
|
|
||||||
})
|
})
|
||||||
.unwrap_or(true)
|
.unwrap_or(true)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let already_exists = arr
|
let already_exists = arr.iter().any(|v| v.as_str() == Some(plugin_name));
|
||||||
.iter()
|
|
||||||
.any(|v| v.as_str() == Some(normalized_plugin_name.as_str()));
|
|
||||||
if !already_exists {
|
if !already_exists {
|
||||||
arr.push(Value::String(normalized_plugin_name));
|
arr.push(Value::String(plugin_name.to_string()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
config["plugin"] = json!([normalized_plugin_name]);
|
config["plugin"] = json!([plugin_name]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
write_opencode_config(&config)
|
write_opencode_config(&config)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn remove_plugins_by_prefixes(prefixes: &[&str]) -> Result<(), AppError> {
|
pub fn remove_plugin_by_prefix(prefix: &str) -> Result<(), AppError> {
|
||||||
let mut config = read_opencode_config()?;
|
let mut config = read_opencode_config()?;
|
||||||
|
|
||||||
if let Some(arr) = config.get_mut("plugin").and_then(|v| v.as_array_mut()) {
|
if let Some(arr) = config.get_mut("plugin").and_then(|v| v.as_array_mut()) {
|
||||||
arr.retain(|v| {
|
arr.retain(|v| {
|
||||||
v.as_str()
|
v.as_str()
|
||||||
.map(|s| !matches_any_plugin_prefix(s, prefixes))
|
.map(|s| {
|
||||||
|
if !s.starts_with(prefix) {
|
||||||
|
return true; // Keep: doesn't match prefix at all
|
||||||
|
}
|
||||||
|
let rest = &s[prefix.len()..];
|
||||||
|
rest.starts_with('-')
|
||||||
|
})
|
||||||
.unwrap_or(true)
|
.unwrap_or(true)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -283,10 +283,6 @@ pub struct ProviderMeta {
|
|||||||
/// If not set, provider ID is used automatically during format conversion.
|
/// If not set, provider ID is used automatically during format conversion.
|
||||||
#[serde(rename = "promptCacheKey", skip_serializing_if = "Option::is_none")]
|
#[serde(rename = "promptCacheKey", skip_serializing_if = "Option::is_none")]
|
||||||
pub prompt_cache_key: Option<String>,
|
pub prompt_cache_key: Option<String>,
|
||||||
/// 累加模式应用中,该 provider 是否已写入 live config。
|
|
||||||
/// `None` 表示旧数据/未知状态,`Some(false)` 表示明确仅存在于数据库中。
|
|
||||||
#[serde(rename = "liveConfigManaged", skip_serializing_if = "Option::is_none")]
|
|
||||||
pub live_config_managed: Option<bool>,
|
|
||||||
/// 供应商类型标识(用于特殊供应商检测)
|
/// 供应商类型标识(用于特殊供应商检测)
|
||||||
/// - "github_copilot": GitHub Copilot 供应商
|
/// - "github_copilot": GitHub Copilot 供应商
|
||||||
#[serde(rename = "providerType", skip_serializing_if = "Option::is_none")]
|
#[serde(rename = "providerType", skip_serializing_if = "Option::is_none")]
|
||||||
|
|||||||
@@ -2,12 +2,15 @@
|
|||||||
//!
|
//!
|
||||||
//! 处理故障转移成功后的供应商切换逻辑,包括:
|
//! 处理故障转移成功后的供应商切换逻辑,包括:
|
||||||
//! - 去重控制(避免多个请求同时触发)
|
//! - 去重控制(避免多个请求同时触发)
|
||||||
|
//! - 数据库更新
|
||||||
//! - 托盘菜单更新
|
//! - 托盘菜单更新
|
||||||
//! - 前端事件发射
|
//! - 前端事件发射
|
||||||
|
//! - Live 备份更新
|
||||||
|
|
||||||
use crate::database::Database;
|
use crate::database::Database;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
use std::str::FromStr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tauri::{Emitter, Manager};
|
use tauri::{Emitter, Manager};
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
@@ -95,21 +98,30 @@ impl FailoverSwitchManager {
|
|||||||
|
|
||||||
log::info!("[FO-001] 切换: {app_type} → {provider_name}");
|
log::info!("[FO-001] 切换: {app_type} → {provider_name}");
|
||||||
|
|
||||||
let mut switched = false;
|
// 1. 更新数据库 is_current
|
||||||
|
self.db.set_current_provider(app_type, provider_id)?;
|
||||||
|
|
||||||
|
// 2. 更新本地 settings(设备级)
|
||||||
|
let app_type_enum = crate::app_config::AppType::from_str(app_type)
|
||||||
|
.map_err(|_| AppError::Message(format!("无效的应用类型: {app_type}")))?;
|
||||||
|
crate::settings::set_current_provider(&app_type_enum, Some(provider_id))?;
|
||||||
|
|
||||||
|
// 3. 更新托盘菜单和发射事件
|
||||||
if let Some(app) = app_handle {
|
if let Some(app) = app_handle {
|
||||||
|
// 更新托盘菜单
|
||||||
if let Some(app_state) = app.try_state::<crate::store::AppState>() {
|
if let Some(app_state) = app.try_state::<crate::store::AppState>() {
|
||||||
switched = app_state
|
// 更新 Live 备份(确保代理停止时恢复正确配置)
|
||||||
.proxy_service
|
if let Ok(Some(provider)) = self.db.get_provider_by_id(provider_id, app_type) {
|
||||||
.hot_switch_provider(app_type, provider_id)
|
if let Err(e) = app_state
|
||||||
.await
|
.proxy_service
|
||||||
.map_err(AppError::Message)?
|
.update_live_backup_from_provider(app_type, &provider)
|
||||||
.logical_target_changed;
|
.await
|
||||||
|
{
|
||||||
if !switched {
|
log::warn!("[FO-003] Live 备份更新失败: {e}");
|
||||||
return Ok(false);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 重建托盘菜单
|
||||||
if let Ok(new_menu) = crate::tray::create_tray_menu(app, app_state.inner()) {
|
if let Ok(new_menu) = crate::tray::create_tray_menu(app, app_state.inner()) {
|
||||||
if let Some(tray) = app.tray_by_id("main") {
|
if let Some(tray) = app.tray_by_id("main") {
|
||||||
if let Err(e) = tray.set_menu(Some(new_menu)) {
|
if let Err(e) = tray.set_menu(Some(new_menu)) {
|
||||||
@@ -130,6 +142,6 @@ impl FailoverSwitchManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(switched)
|
Ok(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ pub mod response_processor;
|
|||||||
pub(crate) mod server;
|
pub(crate) mod server;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
pub(crate) mod sse;
|
pub(crate) mod sse;
|
||||||
pub(crate) mod switch_lock;
|
|
||||||
pub mod thinking_budget_rectifier;
|
pub mod thinking_budget_rectifier;
|
||||||
pub mod thinking_optimizer;
|
pub mod thinking_optimizer;
|
||||||
pub mod thinking_rectifier;
|
pub mod thinking_rectifier;
|
||||||
|
|||||||
@@ -974,9 +974,7 @@ mod tests {
|
|||||||
"data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":5,\"output_tokens\":2}}}\n\n"
|
"data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":5,\"output_tokens\":2}}}\n\n"
|
||||||
);
|
);
|
||||||
|
|
||||||
let upstream = stream::iter(vec![Ok::<_, std::io::Error>(Bytes::from(
|
let upstream = stream::iter(vec![Ok(Bytes::from(input.as_bytes().to_vec()))]);
|
||||||
input.as_bytes().to_vec(),
|
|
||||||
))]);
|
|
||||||
let converted = create_anthropic_sse_stream_from_responses(upstream);
|
let converted = create_anthropic_sse_stream_from_responses(upstream);
|
||||||
let chunks: Vec<_> = converted.collect().await;
|
let chunks: Vec<_> = converted.collect().await;
|
||||||
let events: Vec<Value> = chunks
|
let events: Vec<Value> = chunks
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
//! Per-app switch lock
|
|
||||||
//!
|
|
||||||
//! 确保同一应用同时只有一个供应商切换操作在执行,
|
|
||||||
//! 防止并发切换导致 is_current 与 Live 备份不一致。
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use tokio::sync::{Mutex, OwnedMutexGuard, RwLock};
|
|
||||||
|
|
||||||
/// 每个应用类型一把互斥锁,保证同一应用的切换操作串行执行。
|
|
||||||
///
|
|
||||||
/// 不同应用之间(如 Claude 和 Codex)可以并行切换。
|
|
||||||
#[derive(Clone, Default)]
|
|
||||||
pub struct SwitchLockManager {
|
|
||||||
locks: Arc<RwLock<HashMap<String, Arc<Mutex<()>>>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SwitchLockManager {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self::default()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取指定应用的切换锁。
|
|
||||||
///
|
|
||||||
/// 返回 `OwnedMutexGuard`,持有期间同一 `app_type` 的其他切换会排队等待。
|
|
||||||
pub async fn lock_for_app(&self, app_type: &str) -> OwnedMutexGuard<()> {
|
|
||||||
let lock = {
|
|
||||||
let locks = self.locks.read().await;
|
|
||||||
if let Some(lock) = locks.get(app_type) {
|
|
||||||
lock.clone()
|
|
||||||
} else {
|
|
||||||
drop(locks);
|
|
||||||
let mut locks = self.locks.write().await;
|
|
||||||
locks
|
|
||||||
.entry(app_type.to_string())
|
|
||||||
.or_insert_with(|| Arc::new(Mutex::new(())))
|
|
||||||
.clone()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
lock.lock_owned().await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+42
-148
@@ -1,7 +1,6 @@
|
|||||||
use crate::config::{atomic_write, write_json_file};
|
use crate::config::write_json_file;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::opencode_config::get_opencode_dir;
|
use crate::opencode_config::get_opencode_dir;
|
||||||
use crate::provider::Provider;
|
|
||||||
use crate::store::AppState;
|
use crate::store::AppState;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{Map, Value};
|
use serde_json::{Map, Value};
|
||||||
@@ -22,41 +21,33 @@ type OmoProfileData = (Option<Value>, Option<Value>, Option<Value>);
|
|||||||
// ── Variant descriptor ─────────────────────────────────────────
|
// ── Variant descriptor ─────────────────────────────────────────
|
||||||
|
|
||||||
pub struct OmoVariant {
|
pub struct OmoVariant {
|
||||||
pub preferred_filename: &'static str,
|
pub filename: &'static str,
|
||||||
pub config_candidates: &'static [&'static str],
|
|
||||||
pub category: &'static str,
|
pub category: &'static str,
|
||||||
pub provider_prefix: &'static str,
|
pub provider_prefix: &'static str,
|
||||||
pub plugin_name: &'static str,
|
pub plugin_name: &'static str,
|
||||||
pub plugin_prefixes: &'static [&'static str],
|
pub plugin_prefix: &'static str,
|
||||||
pub has_categories: bool,
|
pub has_categories: bool,
|
||||||
pub label: &'static str,
|
pub label: &'static str,
|
||||||
pub import_label: &'static str,
|
pub import_label: &'static str,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const STANDARD: OmoVariant = OmoVariant {
|
pub const STANDARD: OmoVariant = OmoVariant {
|
||||||
preferred_filename: "oh-my-openagent.jsonc",
|
filename: "oh-my-opencode.jsonc",
|
||||||
config_candidates: &[
|
|
||||||
"oh-my-openagent.jsonc",
|
|
||||||
"oh-my-openagent.json",
|
|
||||||
"oh-my-opencode.jsonc",
|
|
||||||
"oh-my-opencode.json",
|
|
||||||
],
|
|
||||||
category: "omo",
|
category: "omo",
|
||||||
provider_prefix: "omo-",
|
provider_prefix: "omo-",
|
||||||
plugin_name: "oh-my-openagent@latest",
|
plugin_name: "oh-my-opencode@latest",
|
||||||
plugin_prefixes: &["oh-my-openagent", "oh-my-opencode"],
|
plugin_prefix: "oh-my-opencode",
|
||||||
has_categories: true,
|
has_categories: true,
|
||||||
label: "OMO",
|
label: "OMO",
|
||||||
import_label: "Imported",
|
import_label: "Imported",
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const SLIM: OmoVariant = OmoVariant {
|
pub const SLIM: OmoVariant = OmoVariant {
|
||||||
preferred_filename: "oh-my-opencode-slim.jsonc",
|
filename: "oh-my-opencode-slim.jsonc",
|
||||||
config_candidates: &["oh-my-opencode-slim.jsonc", "oh-my-opencode-slim.json"],
|
|
||||||
category: "omo-slim",
|
category: "omo-slim",
|
||||||
provider_prefix: "omo-slim-",
|
provider_prefix: "omo-slim-",
|
||||||
plugin_name: "oh-my-opencode-slim@latest",
|
plugin_name: "oh-my-opencode-slim@latest",
|
||||||
plugin_prefixes: &["oh-my-opencode-slim"],
|
plugin_prefix: "oh-my-opencode-slim",
|
||||||
has_categories: false,
|
has_categories: false,
|
||||||
label: "OMO Slim",
|
label: "OMO Slim",
|
||||||
import_label: "Imported Slim",
|
import_label: "Imported Slim",
|
||||||
@@ -69,27 +60,22 @@ pub struct OmoService;
|
|||||||
impl OmoService {
|
impl OmoService {
|
||||||
// ── Path helpers ────────────────────────────────────────
|
// ── Path helpers ────────────────────────────────────────
|
||||||
|
|
||||||
fn config_candidates(v: &OmoVariant, base_dir: &Path) -> Vec<PathBuf> {
|
|
||||||
v.config_candidates
|
|
||||||
.iter()
|
|
||||||
.map(|name| base_dir.join(name))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn find_existing_config_path(v: &OmoVariant, base_dir: &Path) -> Option<PathBuf> {
|
|
||||||
Self::config_candidates(v, base_dir)
|
|
||||||
.into_iter()
|
|
||||||
.find(|path| path.exists())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn config_path(v: &OmoVariant) -> PathBuf {
|
fn config_path(v: &OmoVariant) -> PathBuf {
|
||||||
let base_dir = get_opencode_dir();
|
get_opencode_dir().join(v.filename)
|
||||||
Self::find_existing_config_path(v, &base_dir)
|
|
||||||
.unwrap_or_else(|| base_dir.join(v.preferred_filename))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_local_config_path(v: &OmoVariant) -> Result<PathBuf, AppError> {
|
fn resolve_local_config_path(v: &OmoVariant) -> Result<PathBuf, AppError> {
|
||||||
Self::find_existing_config_path(v, &get_opencode_dir()).ok_or(AppError::OmoConfigNotFound)
|
let config_path = Self::config_path(v);
|
||||||
|
if config_path.exists() {
|
||||||
|
return Ok(config_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
let json_path = config_path.with_extension("json");
|
||||||
|
if json_path.exists() {
|
||||||
|
return Ok(json_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(AppError::OmoConfigNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_jsonc_object(path: &Path) -> Result<Map<String, Value>, AppError> {
|
fn read_jsonc_object(path: &Path) -> Result<Map<String, Value>, AppError> {
|
||||||
@@ -134,102 +120,44 @@ impl OmoService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn profile_data_from_provider(provider: &Provider, v: &OmoVariant) -> OmoProfileData {
|
// ── Public API (variant-parameterized) ─────────────────
|
||||||
let agents = provider.settings_config.get("agents").cloned();
|
|
||||||
let categories = if v.has_categories {
|
|
||||||
provider.settings_config.get("categories").cloned()
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
let other_fields = provider.settings_config.get("otherFields").cloned();
|
|
||||||
(agents, categories, other_fields)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn snapshot_config_file(path: &Path) -> Result<Option<Vec<u8>>, AppError> {
|
pub fn delete_config_file(v: &OmoVariant) -> Result<(), AppError> {
|
||||||
if !path.exists() {
|
let config_path = Self::config_path(v);
|
||||||
return Ok(None);
|
if config_path.exists() {
|
||||||
|
std::fs::remove_file(&config_path).map_err(|e| AppError::io(&config_path, e))?;
|
||||||
|
log::info!("{} config file deleted: {config_path:?}", v.label);
|
||||||
}
|
}
|
||||||
|
crate::opencode_config::remove_plugin_by_prefix(v.plugin_prefix)?;
|
||||||
std::fs::read(path)
|
Ok(())
|
||||||
.map(Some)
|
|
||||||
.map_err(|e| AppError::io(path, e))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn restore_config_file(path: &Path, snapshot: Option<&[u8]>) -> Result<(), AppError> {
|
pub fn write_config_to_file(state: &AppState, v: &OmoVariant) -> Result<(), AppError> {
|
||||||
match snapshot {
|
let current_omo = state.db.get_current_omo_provider("opencode", v.category)?;
|
||||||
Some(bytes) => atomic_write(path, bytes),
|
let profile_data = current_omo.as_ref().map(|p| {
|
||||||
None => {
|
let agents = p.settings_config.get("agents").cloned();
|
||||||
if path.exists() {
|
let categories = if v.has_categories {
|
||||||
std::fs::remove_file(path).map_err(|e| AppError::io(path, e))?;
|
p.settings_config.get("categories").cloned()
|
||||||
}
|
} else {
|
||||||
Ok(())
|
None
|
||||||
}
|
};
|
||||||
}
|
let other_fields = p.settings_config.get("otherFields").cloned();
|
||||||
}
|
(agents, categories, other_fields)
|
||||||
|
});
|
||||||
|
|
||||||
fn write_profile_config(
|
let merged = Self::build_config(v, profile_data.as_ref());
|
||||||
v: &OmoVariant,
|
|
||||||
profile_data: Option<&OmoProfileData>,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let merged = Self::build_config(v, profile_data);
|
|
||||||
let config_path = Self::config_path(v);
|
let config_path = Self::config_path(v);
|
||||||
|
|
||||||
if let Some(parent) = config_path.parent() {
|
if let Some(parent) = config_path.parent() {
|
||||||
std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let previous_contents = Self::snapshot_config_file(&config_path)?;
|
|
||||||
write_json_file(&config_path, &merged)?;
|
write_json_file(&config_path, &merged)?;
|
||||||
if let Err(err) = crate::opencode_config::add_plugin(v.plugin_name) {
|
crate::opencode_config::add_plugin(v.plugin_name)?;
|
||||||
if let Err(rollback_err) =
|
|
||||||
Self::restore_config_file(&config_path, previous_contents.as_deref())
|
|
||||||
{
|
|
||||||
log::warn!(
|
|
||||||
"Failed to roll back {} config after plugin sync error: {}",
|
|
||||||
v.label,
|
|
||||||
rollback_err
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return Err(err);
|
|
||||||
}
|
|
||||||
log::info!("{} config written to {config_path:?}", v.label);
|
log::info!("{} config written to {config_path:?}", v.label);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Public API (variant-parameterized) ─────────────────
|
|
||||||
|
|
||||||
pub fn delete_config_file(v: &OmoVariant) -> Result<(), AppError> {
|
|
||||||
let base_dir = get_opencode_dir();
|
|
||||||
let mut deleted_paths = Vec::new();
|
|
||||||
for config_path in Self::config_candidates(v, &base_dir) {
|
|
||||||
if config_path.exists() {
|
|
||||||
std::fs::remove_file(&config_path).map_err(|e| AppError::io(&config_path, e))?;
|
|
||||||
deleted_paths.push(config_path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !deleted_paths.is_empty() {
|
|
||||||
log::info!("{} config files deleted: {deleted_paths:?}", v.label);
|
|
||||||
}
|
|
||||||
crate::opencode_config::remove_plugins_by_prefixes(v.plugin_prefixes)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn write_config_to_file(state: &AppState, v: &OmoVariant) -> Result<(), AppError> {
|
|
||||||
let current_omo = state.db.get_current_omo_provider("opencode", v.category)?;
|
|
||||||
let profile_data = current_omo
|
|
||||||
.as_ref()
|
|
||||||
.map(|provider| Self::profile_data_from_provider(provider, v));
|
|
||||||
Self::write_profile_config(v, profile_data.as_ref())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn write_provider_config_to_file(
|
|
||||||
provider: &Provider,
|
|
||||||
v: &OmoVariant,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let profile_data = Self::profile_data_from_provider(provider, v);
|
|
||||||
Self::write_profile_config(v, Some(&profile_data))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_config(v: &OmoVariant, profile_data: Option<&OmoProfileData>) -> Value {
|
fn build_config(v: &OmoVariant, profile_data: Option<&OmoProfileData>) -> Value {
|
||||||
let mut result = Map::new();
|
let mut result = Map::new();
|
||||||
if let Some((agents, categories, other_fields)) = profile_data {
|
if let Some((agents, categories, other_fields)) = profile_data {
|
||||||
@@ -523,38 +451,4 @@ mod tests {
|
|||||||
assert!(obj.contains_key("agents"));
|
assert!(obj.contains_key("agents"));
|
||||||
assert!(obj.contains_key("disabled_agents"));
|
assert!(obj.contains_key("disabled_agents"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_find_existing_config_prefers_new_name_over_old() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let old_path = dir.path().join("oh-my-opencode.jsonc");
|
|
||||||
let new_path = dir.path().join("oh-my-openagent.jsonc");
|
|
||||||
|
|
||||||
// Create both old and new files
|
|
||||||
std::fs::write(&old_path, r#"{"agents":{}}"#).unwrap();
|
|
||||||
std::fs::write(&new_path, r#"{"agents":{}}"#).unwrap();
|
|
||||||
|
|
||||||
let found = OmoService::find_existing_config_path(&STANDARD, dir.path());
|
|
||||||
assert_eq!(
|
|
||||||
found.unwrap(),
|
|
||||||
new_path,
|
|
||||||
"When both old and new config files exist, the new name (oh-my-openagent) must be preferred"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_find_existing_config_falls_back_to_old_name() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let old_path = dir.path().join("oh-my-opencode.jsonc");
|
|
||||||
|
|
||||||
// Only old file exists
|
|
||||||
std::fs::write(&old_path, r#"{"agents":{}}"#).unwrap();
|
|
||||||
|
|
||||||
let found = OmoService::find_existing_config_path(&STANDARD, dir.path());
|
|
||||||
assert_eq!(
|
|
||||||
found.unwrap(),
|
|
||||||
old_path,
|
|
||||||
"When only the old config file exists, it should still be found"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,19 +33,6 @@ pub(crate) fn sanitize_claude_settings_for_live(settings: &Value) -> Value {
|
|||||||
v
|
v
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn provider_exists_in_live_config(
|
|
||||||
app_type: &AppType,
|
|
||||||
provider_id: &str,
|
|
||||||
) -> Result<bool, AppError> {
|
|
||||||
match app_type {
|
|
||||||
AppType::OpenCode => crate::opencode_config::get_providers()
|
|
||||||
.map(|providers| providers.contains_key(provider_id)),
|
|
||||||
AppType::OpenClaw => crate::openclaw_config::get_providers()
|
|
||||||
.map(|providers| providers.contains_key(provider_id)),
|
|
||||||
_ => Ok(false),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn json_is_subset(target: &Value, source: &Value) -> bool {
|
fn json_is_subset(target: &Value, source: &Value) -> bool {
|
||||||
match source {
|
match source {
|
||||||
Value::Object(source_map) => {
|
Value::Object(source_map) => {
|
||||||
@@ -740,10 +727,10 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
|
|||||||
provider.id
|
provider.id
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return Err(AppError::Message(format!(
|
log::error!(
|
||||||
"OpenCode provider '{}' has invalid config structure for live config (must contain 'npm' or 'options')",
|
"OpenCode provider '{}' has invalid config structure, skipping write",
|
||||||
provider.id
|
provider.id
|
||||||
)));
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -782,10 +769,10 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
|
|||||||
provider.id
|
provider.id
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return Err(AppError::Message(format!(
|
log::error!(
|
||||||
"OpenClaw provider '{}' has invalid config structure for live config (must contain 'baseUrl', 'api', or 'models')",
|
"OpenClaw provider '{}' has invalid config structure, skipping write",
|
||||||
provider.id
|
provider.id
|
||||||
)));
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -800,30 +787,23 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
|
|||||||
/// Used for OpenCode and other additive mode applications.
|
/// Used for OpenCode and other additive mode applications.
|
||||||
fn sync_all_providers_to_live(state: &AppState, app_type: &AppType) -> Result<(), AppError> {
|
fn sync_all_providers_to_live(state: &AppState, app_type: &AppType) -> Result<(), AppError> {
|
||||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||||
let mut synced_count = 0usize;
|
|
||||||
|
|
||||||
for provider in providers.values() {
|
for provider in providers.values() {
|
||||||
if provider
|
|
||||||
.meta
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|meta| meta.live_config_managed)
|
|
||||||
== Some(false)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Err(e) = write_live_with_common_config(state.db.as_ref(), app_type, provider) {
|
if let Err(e) = write_live_with_common_config(state.db.as_ref(), app_type, provider) {
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"Failed to sync {:?} provider '{}' to live: {e}",
|
"Failed to sync {:?} provider '{}' to live: {e}",
|
||||||
app_type,
|
app_type,
|
||||||
provider.id
|
provider.id
|
||||||
);
|
);
|
||||||
continue;
|
// Continue syncing other providers, don't abort
|
||||||
}
|
}
|
||||||
synced_count += 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log::info!("Synced {synced_count} {app_type:?} providers to live config");
|
log::info!(
|
||||||
|
"Synced {} {:?} providers to live config",
|
||||||
|
providers.len(),
|
||||||
|
app_type
|
||||||
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1227,16 +1207,12 @@ pub fn import_opencode_providers_from_live(state: &AppState) -> Result<usize, Ap
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Create provider
|
// Create provider
|
||||||
let mut provider = Provider::with_id(
|
let provider = Provider::with_id(
|
||||||
id.clone(),
|
id.clone(),
|
||||||
config.name.clone().unwrap_or_else(|| id.clone()),
|
config.name.clone().unwrap_or_else(|| id.clone()),
|
||||||
settings_config,
|
settings_config,
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
provider.meta = Some(crate::provider::ProviderMeta {
|
|
||||||
live_config_managed: Some(true),
|
|
||||||
..Default::default()
|
|
||||||
});
|
|
||||||
|
|
||||||
// Save to database
|
// Save to database
|
||||||
if let Err(e) = state.db.save_provider("opencode", &provider) {
|
if let Err(e) = state.db.save_provider("opencode", &provider) {
|
||||||
@@ -1301,11 +1277,7 @@ pub fn import_openclaw_providers_from_live(state: &AppState) -> Result<usize, Ap
|
|||||||
.unwrap_or_else(|| id.clone());
|
.unwrap_or_else(|| id.clone());
|
||||||
|
|
||||||
// Create provider
|
// Create provider
|
||||||
let mut provider = Provider::with_id(id.clone(), display_name, settings_config, None);
|
let provider = Provider::with_id(id.clone(), display_name, settings_config, None);
|
||||||
provider.meta = Some(crate::provider::ProviderMeta {
|
|
||||||
live_config_managed: Some(true),
|
|
||||||
..Default::default()
|
|
||||||
});
|
|
||||||
|
|
||||||
// Save to database
|
// Save to database
|
||||||
if let Err(e) = state.db.save_provider("openclaw", &provider) {
|
if let Err(e) = state.db.save_provider("openclaw", &provider) {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+39
-280
@@ -7,7 +7,6 @@ use crate::config::{get_claude_settings_path, read_json_file, write_json_file};
|
|||||||
use crate::database::Database;
|
use crate::database::Database;
|
||||||
use crate::provider::Provider;
|
use crate::provider::Provider;
|
||||||
use crate::proxy::server::ProxyServer;
|
use crate::proxy::server::ProxyServer;
|
||||||
use crate::proxy::switch_lock::SwitchLockManager;
|
|
||||||
use crate::proxy::types::*;
|
use crate::proxy::types::*;
|
||||||
use crate::services::provider::{
|
use crate::services::provider::{
|
||||||
build_effective_settings_with_common_config, write_live_with_common_config,
|
build_effective_settings_with_common_config, write_live_with_common_config,
|
||||||
@@ -40,12 +39,6 @@ pub struct ProxyService {
|
|||||||
server: Arc<RwLock<Option<ProxyServer>>>,
|
server: Arc<RwLock<Option<ProxyServer>>>,
|
||||||
/// AppHandle,用于传递给 ProxyServer 以支持故障转移时的 UI 更新
|
/// AppHandle,用于传递给 ProxyServer 以支持故障转移时的 UI 更新
|
||||||
app_handle: Arc<RwLock<Option<tauri::AppHandle>>>,
|
app_handle: Arc<RwLock<Option<tauri::AppHandle>>>,
|
||||||
switch_locks: SwitchLockManager,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Default)]
|
|
||||||
pub struct HotSwitchOutcome {
|
|
||||||
pub logical_target_changed: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProxyService {
|
impl ProxyService {
|
||||||
@@ -54,7 +47,6 @@ impl ProxyService {
|
|||||||
db,
|
db,
|
||||||
server: Arc::new(RwLock::new(None)),
|
server: Arc::new(RwLock::new(None)),
|
||||||
app_handle: Arc::new(RwLock::new(None)),
|
app_handle: Arc::new(RwLock::new(None)),
|
||||||
switch_locks: SwitchLockManager::new(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1108,11 +1100,6 @@ impl ProxyService {
|
|||||||
|
|
||||||
/// 恢复指定应用的 Live 配置(若无备份则不做任何操作)
|
/// 恢复指定应用的 Live 配置(若无备份则不做任何操作)
|
||||||
async fn restore_live_config_for_app(&self, app_type: &AppType) -> Result<(), String> {
|
async fn restore_live_config_for_app(&self, app_type: &AppType) -> Result<(), String> {
|
||||||
let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;
|
|
||||||
self.restore_live_config_for_app_inner(app_type).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn restore_live_config_for_app_inner(&self, app_type: &AppType) -> Result<(), String> {
|
|
||||||
match app_type {
|
match app_type {
|
||||||
AppType::Claude => {
|
AppType::Claude => {
|
||||||
if let Ok(Some(backup)) = self.db.get_live_backup("claude").await {
|
if let Ok(Some(backup)) = self.db.get_live_backup("claude").await {
|
||||||
@@ -1172,15 +1159,6 @@ impl ProxyService {
|
|||||||
async fn restore_live_config_for_app_with_fallback(
|
async fn restore_live_config_for_app_with_fallback(
|
||||||
&self,
|
&self,
|
||||||
app_type: &AppType,
|
app_type: &AppType,
|
||||||
) -> Result<(), String> {
|
|
||||||
let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;
|
|
||||||
self.restore_live_config_for_app_with_fallback_inner(app_type)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn restore_live_config_for_app_with_fallback_inner(
|
|
||||||
&self,
|
|
||||||
app_type: &AppType,
|
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let app_type_str = app_type.as_str();
|
let app_type_str = app_type.as_str();
|
||||||
|
|
||||||
@@ -1509,17 +1487,6 @@ impl ProxyService {
|
|||||||
&self,
|
&self,
|
||||||
app_type: &str,
|
app_type: &str,
|
||||||
provider: &Provider,
|
provider: &Provider,
|
||||||
) -> Result<(), String> {
|
|
||||||
let _guard = self.switch_locks.lock_for_app(app_type).await;
|
|
||||||
self.update_live_backup_from_provider_inner(app_type, provider)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 仅供已持有 per-app 切换锁的调用方使用。
|
|
||||||
async fn update_live_backup_from_provider_inner(
|
|
||||||
&self,
|
|
||||||
app_type: &str,
|
|
||||||
provider: &Provider,
|
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let app_type_enum =
|
let app_type_enum =
|
||||||
AppType::from_str(app_type).map_err(|_| format!("未知的应用类型: {app_type}"))?;
|
AppType::from_str(app_type).map_err(|_| format!("未知的应用类型: {app_type}"))?;
|
||||||
@@ -1573,69 +1540,6 @@ impl ProxyService {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn hot_switch_provider(
|
|
||||||
&self,
|
|
||||||
app_type: &str,
|
|
||||||
provider_id: &str,
|
|
||||||
) -> Result<HotSwitchOutcome, String> {
|
|
||||||
let _guard = self.switch_locks.lock_for_app(app_type).await;
|
|
||||||
|
|
||||||
let app_type_enum =
|
|
||||||
AppType::from_str(app_type).map_err(|_| format!("无效的应用类型: {app_type}"))?;
|
|
||||||
let provider = self
|
|
||||||
.db
|
|
||||||
.get_provider_by_id(provider_id, app_type)
|
|
||||||
.map_err(|e| format!("读取供应商失败: {e}"))?
|
|
||||||
.ok_or_else(|| format!("供应商不存在: {provider_id}"))?;
|
|
||||||
|
|
||||||
let logical_target_changed =
|
|
||||||
crate::settings::get_effective_current_provider(&self.db, &app_type_enum)
|
|
||||||
.map_err(|e| format!("读取当前供应商失败: {e}"))?
|
|
||||||
.as_deref()
|
|
||||||
!= Some(provider_id);
|
|
||||||
|
|
||||||
let has_backup = self
|
|
||||||
.db
|
|
||||||
.get_live_backup(app_type_enum.as_str())
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("读取 {app_type} 备份失败: {e}"))?
|
|
||||||
.is_some();
|
|
||||||
let live_taken_over = self.detect_takeover_in_live_config_for_app(&app_type_enum);
|
|
||||||
let should_sync_backup = has_backup || live_taken_over;
|
|
||||||
|
|
||||||
self.db
|
|
||||||
.set_current_provider(app_type_enum.as_str(), provider_id)
|
|
||||||
.map_err(|e| format!("更新当前供应商失败: {e}"))?;
|
|
||||||
crate::settings::set_current_provider(&app_type_enum, Some(provider_id))
|
|
||||||
.map_err(|e| format!("更新本地当前供应商失败: {e}"))?;
|
|
||||||
|
|
||||||
if should_sync_backup {
|
|
||||||
self.update_live_backup_from_provider_inner(app_type, &provider)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if matches!(app_type_enum, AppType::Claude) {
|
|
||||||
if let Err(e) = self.cleanup_claude_model_overrides_in_live() {
|
|
||||||
log::warn!("清理 Claude Live 模型字段失败(不影响热切换结果): {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(server) = self.server.read().await.as_ref() {
|
|
||||||
server
|
|
||||||
.set_active_target(app_type_enum.as_str(), &provider.id, &provider.name)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(HotSwitchOutcome {
|
|
||||||
logical_target_changed,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
async fn lock_switch_for_test(&self, app_type: &str) -> tokio::sync::OwnedMutexGuard<()> {
|
|
||||||
self.switch_locks.lock_for_app(app_type).await
|
|
||||||
}
|
|
||||||
|
|
||||||
fn preserve_codex_mcp_servers_in_backup(
|
fn preserve_codex_mcp_servers_in_backup(
|
||||||
target_settings: &mut Value,
|
target_settings: &mut Value,
|
||||||
existing_backup: &Value,
|
existing_backup: &Value,
|
||||||
@@ -1703,13 +1607,47 @@ impl ProxyService {
|
|||||||
app_type: &str,
|
app_type: &str,
|
||||||
provider_id: &str,
|
provider_id: &str,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let outcome = self.hot_switch_provider(app_type, provider_id).await?;
|
// 代理模式切换供应商(热切换):
|
||||||
|
// - 更新 SSOT(数据库 is_current)
|
||||||
|
// - 同步本地 settings(设备级 current_provider_*)
|
||||||
|
// - 若该应用正处于接管模式,则同步更新 Live 备份(用于停止代理时恢复)
|
||||||
|
let app_type_enum =
|
||||||
|
AppType::from_str(app_type).map_err(|_| format!("无效的应用类型: {app_type}"))?;
|
||||||
|
|
||||||
if outcome.logical_target_changed {
|
self.db
|
||||||
log::info!("代理模式:已切换 {app_type} 的目标供应商为 {provider_id}");
|
.set_current_provider(app_type_enum.as_str(), provider_id)
|
||||||
} else {
|
.map_err(|e| format!("更新当前供应商失败: {e}"))?;
|
||||||
log::debug!("代理模式:{app_type} 已对齐到目标供应商 {provider_id}");
|
|
||||||
|
// 同步本地 settings(设备级优先)
|
||||||
|
crate::settings::set_current_provider(&app_type_enum, Some(provider_id))
|
||||||
|
.map_err(|e| format!("更新本地当前供应商失败: {e}"))?;
|
||||||
|
|
||||||
|
// 仅在确实处于接管状态时才更新 Live 备份,避免无接管时误写覆盖 Live
|
||||||
|
let has_backup = self
|
||||||
|
.db
|
||||||
|
.get_live_backup(app_type_enum.as_str())
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.is_some();
|
||||||
|
let live_taken_over = self.detect_takeover_in_live_config_for_app(&app_type_enum);
|
||||||
|
|
||||||
|
if let Ok(Some(provider)) = self.db.get_provider_by_id(provider_id, app_type) {
|
||||||
|
// 同步更新 Live 备份(用于 stop_with_restore 恢复)
|
||||||
|
if has_backup || live_taken_over {
|
||||||
|
self.update_live_backup_from_provider(app_type, &provider)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同步更新 ProxyStatus.active_targets(用于 UI 立即反映切换目标)
|
||||||
|
if let Some(server) = self.server.read().await.as_ref() {
|
||||||
|
server
|
||||||
|
.set_active_target(app_type_enum.as_str(), &provider.id, &provider.name)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log::info!("代理模式:已切换 {app_type} 的目标供应商为 {provider_id}");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2255,185 +2193,6 @@ model = "gpt-5.1-codex"
|
|||||||
assert_eq!(backup.original_config, expected);
|
assert_eq!(backup.original_config, expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
#[serial]
|
|
||||||
async fn hot_switch_provider_serializes_same_app_switches() {
|
|
||||||
use tokio::time::{sleep, Duration};
|
|
||||||
|
|
||||||
let _home = TempHome::new();
|
|
||||||
crate::settings::reload_settings().expect("reload settings");
|
|
||||||
|
|
||||||
let db = Arc::new(Database::memory().expect("init db"));
|
|
||||||
let service = ProxyService::new(db.clone());
|
|
||||||
|
|
||||||
let provider_a = Provider::with_id(
|
|
||||||
"a".to_string(),
|
|
||||||
"A".to_string(),
|
|
||||||
json!({ "env": { "ANTHROPIC_API_KEY": "a-key" } }),
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
let provider_b = Provider::with_id(
|
|
||||||
"b".to_string(),
|
|
||||||
"B".to_string(),
|
|
||||||
json!({ "env": { "ANTHROPIC_API_KEY": "b-key" } }),
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
let provider_c = Provider::with_id(
|
|
||||||
"c".to_string(),
|
|
||||||
"C".to_string(),
|
|
||||||
json!({ "env": { "ANTHROPIC_API_KEY": "c-key" } }),
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
|
|
||||||
db.save_provider("claude", &provider_a)
|
|
||||||
.expect("save provider a");
|
|
||||||
db.save_provider("claude", &provider_b)
|
|
||||||
.expect("save provider b");
|
|
||||||
db.save_provider("claude", &provider_c)
|
|
||||||
.expect("save provider c");
|
|
||||||
db.set_current_provider("claude", "a")
|
|
||||||
.expect("set current provider");
|
|
||||||
crate::settings::set_current_provider(&AppType::Claude, Some("a"))
|
|
||||||
.expect("set local current provider");
|
|
||||||
db.save_live_backup("claude", "{\"env\":{}}")
|
|
||||||
.await
|
|
||||||
.expect("seed live backup");
|
|
||||||
|
|
||||||
let guard = service.lock_switch_for_test("claude").await;
|
|
||||||
let service_for_b = service.clone();
|
|
||||||
let service_for_c = service.clone();
|
|
||||||
|
|
||||||
let switch_b = tokio::spawn(async move {
|
|
||||||
service_for_b
|
|
||||||
.hot_switch_provider("claude", "b")
|
|
||||||
.await
|
|
||||||
.expect("switch to b")
|
|
||||||
});
|
|
||||||
sleep(Duration::from_millis(20)).await;
|
|
||||||
let switch_c = tokio::spawn(async move {
|
|
||||||
service_for_c
|
|
||||||
.hot_switch_provider("claude", "c")
|
|
||||||
.await
|
|
||||||
.expect("switch to c")
|
|
||||||
});
|
|
||||||
|
|
||||||
sleep(Duration::from_millis(20)).await;
|
|
||||||
drop(guard);
|
|
||||||
|
|
||||||
let outcome_b = switch_b.await.expect("join switch b");
|
|
||||||
let outcome_c = switch_c.await.expect("join switch c");
|
|
||||||
assert!(outcome_b.logical_target_changed);
|
|
||||||
assert!(outcome_c.logical_target_changed);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
crate::settings::get_effective_current_provider(&db, &AppType::Claude)
|
|
||||||
.expect("effective current"),
|
|
||||||
Some("c".to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
crate::settings::get_current_provider(&AppType::Claude).as_deref(),
|
|
||||||
Some("c")
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
db.get_current_provider("claude").expect("db current"),
|
|
||||||
Some("c".to_string())
|
|
||||||
);
|
|
||||||
|
|
||||||
let backup = db
|
|
||||||
.get_live_backup("claude")
|
|
||||||
.await
|
|
||||||
.expect("get live backup")
|
|
||||||
.expect("backup exists");
|
|
||||||
let expected = serde_json::to_string(&provider_c.settings_config).expect("serialize");
|
|
||||||
assert_eq!(backup.original_config, expected);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
#[serial]
|
|
||||||
async fn restore_waits_for_hot_switch_and_restores_latest_backup() {
|
|
||||||
use tokio::time::{sleep, Duration};
|
|
||||||
|
|
||||||
let _home = TempHome::new();
|
|
||||||
crate::settings::reload_settings().expect("reload settings");
|
|
||||||
|
|
||||||
let db = Arc::new(Database::memory().expect("init db"));
|
|
||||||
let service = ProxyService::new(db.clone());
|
|
||||||
|
|
||||||
let provider_a = Provider::with_id(
|
|
||||||
"a".to_string(),
|
|
||||||
"A".to_string(),
|
|
||||||
json!({ "env": { "ANTHROPIC_API_KEY": "a-key" } }),
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
let provider_b = Provider::with_id(
|
|
||||||
"b".to_string(),
|
|
||||||
"B".to_string(),
|
|
||||||
json!({ "env": { "ANTHROPIC_API_KEY": "b-key" } }),
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
|
|
||||||
db.save_provider("claude", &provider_a)
|
|
||||||
.expect("save provider a");
|
|
||||||
db.save_provider("claude", &provider_b)
|
|
||||||
.expect("save provider b");
|
|
||||||
db.set_current_provider("claude", "a")
|
|
||||||
.expect("set current provider");
|
|
||||||
crate::settings::set_current_provider(&AppType::Claude, Some("a"))
|
|
||||||
.expect("set local current provider");
|
|
||||||
db.save_live_backup(
|
|
||||||
"claude",
|
|
||||||
&serde_json::to_string(&provider_a.settings_config).expect("serialize provider a"),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("seed live backup");
|
|
||||||
service
|
|
||||||
.write_claude_live(&json!({ "env": { "ANTHROPIC_API_KEY": "stale" } }))
|
|
||||||
.expect("seed live file");
|
|
||||||
|
|
||||||
let guard = service.lock_switch_for_test("claude").await;
|
|
||||||
let service_for_switch = service.clone();
|
|
||||||
let service_for_restore = service.clone();
|
|
||||||
|
|
||||||
let switch_to_b = tokio::spawn(async move {
|
|
||||||
service_for_switch
|
|
||||||
.hot_switch_provider("claude", "b")
|
|
||||||
.await
|
|
||||||
.expect("switch to b")
|
|
||||||
});
|
|
||||||
sleep(Duration::from_millis(20)).await;
|
|
||||||
let restore = tokio::spawn(async move {
|
|
||||||
service_for_restore
|
|
||||||
.restore_live_config_for_app_with_fallback(&AppType::Claude)
|
|
||||||
.await
|
|
||||||
.expect("restore claude live")
|
|
||||||
});
|
|
||||||
|
|
||||||
sleep(Duration::from_millis(20)).await;
|
|
||||||
drop(guard);
|
|
||||||
|
|
||||||
let outcome = switch_to_b.await.expect("join switch");
|
|
||||||
restore.await.expect("join restore");
|
|
||||||
assert!(outcome.logical_target_changed);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
crate::settings::get_effective_current_provider(&db, &AppType::Claude)
|
|
||||||
.expect("effective current"),
|
|
||||||
Some("b".to_string())
|
|
||||||
);
|
|
||||||
|
|
||||||
let backup = db
|
|
||||||
.get_live_backup("claude")
|
|
||||||
.await
|
|
||||||
.expect("get live backup")
|
|
||||||
.expect("backup exists");
|
|
||||||
let expected = serde_json::to_string(&provider_b.settings_config).expect("serialize");
|
|
||||||
assert_eq!(backup.original_config, expected);
|
|
||||||
assert_eq!(
|
|
||||||
service.read_claude_live().expect("read live"),
|
|
||||||
provider_b.settings_config
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial]
|
#[serial]
|
||||||
async fn update_live_backup_from_provider_applies_claude_common_config() {
|
async fn update_live_backup_from_provider_applies_claude_common_config() {
|
||||||
|
|||||||
@@ -204,11 +204,10 @@ pub async fn ensure_remote_directories(
|
|||||||
s if s == StatusCode::CREATED || s.is_success() => {
|
s if s == StatusCode::CREATED || s.is_success() => {
|
||||||
log::info!("[WebDAV] MKCOL ok: {}", redact_url(&dir_url));
|
log::info!("[WebDAV] MKCOL ok: {}", redact_url(&dir_url));
|
||||||
}
|
}
|
||||||
|
// 405 commonly means "already exists" on many WebDAV servers
|
||||||
|
StatusCode::METHOD_NOT_ALLOWED => {}
|
||||||
// Ambiguous — verify directory actually exists via PROPFIND
|
// Ambiguous — verify directory actually exists via PROPFIND
|
||||||
s if s == StatusCode::METHOD_NOT_ALLOWED
|
s if s == StatusCode::CONFLICT || s.is_redirection() => {
|
||||||
|| s == StatusCode::CONFLICT
|
|
||||||
|| s.is_redirection() =>
|
|
||||||
{
|
|
||||||
if !propfind_exists(&client, &dir_url, auth).await? {
|
if !propfind_exists(&client, &dir_url, auth).await? {
|
||||||
return Err(webdav_status_error("MKCOL", status, &dir_url));
|
return Err(webdav_status_error("MKCOL", status, &dir_url));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
pub mod providers;
|
pub mod providers;
|
||||||
pub mod terminal;
|
pub mod terminal;
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::Serialize;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use providers::{claude, codex, gemini, openclaw, opencode};
|
use providers::{claude, codex, gemini, openclaw, opencode};
|
||||||
@@ -36,25 +36,6 @@ pub struct SessionMessage {
|
|||||||
pub ts: Option<i64>,
|
pub ts: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct DeleteSessionRequest {
|
|
||||||
pub provider_id: String,
|
|
||||||
pub session_id: String,
|
|
||||||
pub source_path: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct DeleteSessionOutcome {
|
|
||||||
pub provider_id: String,
|
|
||||||
pub session_id: String,
|
|
||||||
pub source_path: String,
|
|
||||||
pub success: bool,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub error: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn scan_sessions() -> Vec<SessionMeta> {
|
pub fn scan_sessions() -> Vec<SessionMeta> {
|
||||||
let (r1, r2, r3, r4, r5) = std::thread::scope(|s| {
|
let (r1, r2, r3, r4, r5) = std::thread::scope(|s| {
|
||||||
let h1 = s.spawn(codex::scan_sessions);
|
let h1 = s.spawn(codex::scan_sessions);
|
||||||
@@ -118,16 +99,6 @@ pub fn delete_session(
|
|||||||
delete_session_with_root(provider_id, session_id, Path::new(source_path), &root)
|
delete_session_with_root(provider_id, session_id, Path::new(source_path), &root)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_sessions(requests: &[DeleteSessionRequest]) -> Vec<DeleteSessionOutcome> {
|
|
||||||
collect_delete_session_outcomes(requests, |request| {
|
|
||||||
delete_session(
|
|
||||||
&request.provider_id,
|
|
||||||
&request.session_id,
|
|
||||||
&request.source_path,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn delete_session_with_root(
|
fn delete_session_with_root(
|
||||||
provider_id: &str,
|
provider_id: &str,
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
@@ -176,41 +147,6 @@ fn canonicalize_existing_path(path: &Path, label: &str) -> Result<PathBuf, Strin
|
|||||||
.map_err(|e| format!("Failed to resolve {label} {}: {e}", path.display()))
|
.map_err(|e| format!("Failed to resolve {label} {}: {e}", path.display()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn collect_delete_session_outcomes<F>(
|
|
||||||
requests: &[DeleteSessionRequest],
|
|
||||||
mut deleter: F,
|
|
||||||
) -> Vec<DeleteSessionOutcome>
|
|
||||||
where
|
|
||||||
F: FnMut(&DeleteSessionRequest) -> Result<bool, String>,
|
|
||||||
{
|
|
||||||
requests
|
|
||||||
.iter()
|
|
||||||
.map(|request| match deleter(request) {
|
|
||||||
Ok(true) => DeleteSessionOutcome {
|
|
||||||
provider_id: request.provider_id.clone(),
|
|
||||||
session_id: request.session_id.clone(),
|
|
||||||
source_path: request.source_path.clone(),
|
|
||||||
success: true,
|
|
||||||
error: None,
|
|
||||||
},
|
|
||||||
Ok(false) => DeleteSessionOutcome {
|
|
||||||
provider_id: request.provider_id.clone(),
|
|
||||||
session_id: request.session_id.clone(),
|
|
||||||
source_path: request.source_path.clone(),
|
|
||||||
success: false,
|
|
||||||
error: Some("Session was not deleted".to_string()),
|
|
||||||
},
|
|
||||||
Err(error) => DeleteSessionOutcome {
|
|
||||||
provider_id: request.provider_id.clone(),
|
|
||||||
session_id: request.session_id.clone(),
|
|
||||||
source_path: request.source_path.clone(),
|
|
||||||
success: false,
|
|
||||||
error: Some(error),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -239,44 +175,4 @@ mod tests {
|
|||||||
|
|
||||||
assert!(err.contains("session source not found"));
|
assert!(err.contains("session source not found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn batch_delete_collects_successes_and_failures_in_order() {
|
|
||||||
let requests = vec![
|
|
||||||
DeleteSessionRequest {
|
|
||||||
provider_id: "codex".to_string(),
|
|
||||||
session_id: "s1".to_string(),
|
|
||||||
source_path: "/tmp/s1".to_string(),
|
|
||||||
},
|
|
||||||
DeleteSessionRequest {
|
|
||||||
provider_id: "claude".to_string(),
|
|
||||||
session_id: "s2".to_string(),
|
|
||||||
source_path: "/tmp/s2".to_string(),
|
|
||||||
},
|
|
||||||
DeleteSessionRequest {
|
|
||||||
provider_id: "gemini".to_string(),
|
|
||||||
session_id: "s3".to_string(),
|
|
||||||
source_path: "/tmp/s3".to_string(),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
let outcomes = collect_delete_session_outcomes(&requests, |request| {
|
|
||||||
match request.session_id.as_str() {
|
|
||||||
"s1" => Ok(true),
|
|
||||||
"s2" => Err("boom".to_string()),
|
|
||||||
_ => Ok(false),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
assert_eq!(outcomes.len(), 3);
|
|
||||||
assert!(outcomes[0].success);
|
|
||||||
assert_eq!(outcomes[0].error, None);
|
|
||||||
assert!(!outcomes[1].success);
|
|
||||||
assert_eq!(outcomes[1].error.as_deref(), Some("boom"));
|
|
||||||
assert!(!outcomes[2].success);
|
|
||||||
assert_eq!(
|
|
||||||
outcomes[2].error.as_deref(),
|
|
||||||
Some("Session was not deleted")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -584,14 +584,17 @@ pub fn get_current_provider(app_type: &AppType) -> Option<String> {
|
|||||||
/// 这是设备级别的设置,不随数据库同步。
|
/// 这是设备级别的设置,不随数据库同步。
|
||||||
/// 传入 `None` 会清除当前供应商设置。
|
/// 传入 `None` 会清除当前供应商设置。
|
||||||
pub fn set_current_provider(app_type: &AppType, id: Option<&str>) -> Result<(), AppError> {
|
pub fn set_current_provider(app_type: &AppType, id: Option<&str>) -> Result<(), AppError> {
|
||||||
let id_owned = id.map(|s| s.to_string());
|
let mut settings = get_settings();
|
||||||
mutate_settings(|settings| match app_type {
|
|
||||||
AppType::Claude => settings.current_provider_claude = id_owned.clone(),
|
match app_type {
|
||||||
AppType::Codex => settings.current_provider_codex = id_owned.clone(),
|
AppType::Claude => settings.current_provider_claude = id.map(|s| s.to_string()),
|
||||||
AppType::Gemini => settings.current_provider_gemini = id_owned.clone(),
|
AppType::Codex => settings.current_provider_codex = id.map(|s| s.to_string()),
|
||||||
AppType::OpenCode => settings.current_provider_opencode = id_owned.clone(),
|
AppType::Gemini => settings.current_provider_gemini = id.map(|s| s.to_string()),
|
||||||
AppType::OpenClaw => settings.current_provider_openclaw = id_owned.clone(),
|
AppType::OpenCode => settings.current_provider_opencode = id.map(|s| s.to_string()),
|
||||||
})
|
AppType::OpenClaw => settings.current_provider_openclaw = id.map(|s| s.to_string()),
|
||||||
|
}
|
||||||
|
|
||||||
|
update_settings(settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取有效的当前供应商 ID(验证存在性)
|
/// 获取有效的当前供应商 ID(验证存在性)
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ use crate::store::AppState;
|
|||||||
pub struct TrayTexts {
|
pub struct TrayTexts {
|
||||||
pub show_main: &'static str,
|
pub show_main: &'static str,
|
||||||
pub no_provider_hint: &'static str,
|
pub no_provider_hint: &'static str,
|
||||||
pub lightweight_mode: &'static str,
|
|
||||||
pub quit: &'static str,
|
pub quit: &'static str,
|
||||||
pub _auto_label: &'static str,
|
pub _auto_label: &'static str,
|
||||||
}
|
}
|
||||||
@@ -25,7 +24,6 @@ impl TrayTexts {
|
|||||||
"en" => Self {
|
"en" => Self {
|
||||||
show_main: "Open main window",
|
show_main: "Open main window",
|
||||||
no_provider_hint: " (No providers yet, please add them from the main window)",
|
no_provider_hint: " (No providers yet, please add them from the main window)",
|
||||||
lightweight_mode: "Lightweight Mode",
|
|
||||||
quit: "Quit",
|
quit: "Quit",
|
||||||
_auto_label: "Auto (Failover)",
|
_auto_label: "Auto (Failover)",
|
||||||
},
|
},
|
||||||
@@ -33,14 +31,12 @@ impl TrayTexts {
|
|||||||
show_main: "メインウィンドウを開く",
|
show_main: "メインウィンドウを開く",
|
||||||
no_provider_hint:
|
no_provider_hint:
|
||||||
" (プロバイダーがまだありません。メイン画面から追加してください)",
|
" (プロバイダーがまだありません。メイン画面から追加してください)",
|
||||||
lightweight_mode: "軽量モード",
|
|
||||||
quit: "終了",
|
quit: "終了",
|
||||||
_auto_label: "自動 (フェイルオーバー)",
|
_auto_label: "自動 (フェイルオーバー)",
|
||||||
},
|
},
|
||||||
_ => Self {
|
_ => Self {
|
||||||
show_main: "打开主界面",
|
show_main: "打开主界面",
|
||||||
no_provider_hint: " (无供应商,请在主界面添加)",
|
no_provider_hint: " (无供应商,请在主界面添加)",
|
||||||
lightweight_mode: "轻量模式",
|
|
||||||
quit: "退出",
|
quit: "退出",
|
||||||
_auto_label: "自动 (故障转移)",
|
_auto_label: "自动 (故障转移)",
|
||||||
},
|
},
|
||||||
@@ -386,18 +382,6 @@ pub fn create_tray_menu(
|
|||||||
menu_builder = menu_builder.separator();
|
menu_builder = menu_builder.separator();
|
||||||
}
|
}
|
||||||
|
|
||||||
let lightweight_item = CheckMenuItem::with_id(
|
|
||||||
app,
|
|
||||||
"lightweight_mode",
|
|
||||||
tray_texts.lightweight_mode,
|
|
||||||
true,
|
|
||||||
crate::lightweight::is_lightweight_mode(),
|
|
||||||
None::<&str>,
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Message(format!("创建轻量模式菜单失败: {e}")))?;
|
|
||||||
|
|
||||||
menu_builder = menu_builder.item(&lightweight_item).separator();
|
|
||||||
|
|
||||||
// 退出菜单(分隔符已在上面的 section 循环中添加)
|
// 退出菜单(分隔符已在上面的 section 循环中添加)
|
||||||
let quit_item = MenuItem::with_id(app, "quit", tray_texts.quit, true, None::<&str>)
|
let quit_item = MenuItem::with_id(app, "quit", tray_texts.quit, true, None::<&str>)
|
||||||
.map_err(|e| AppError::Message(format!("创建退出菜单失败: {e}")))?;
|
.map_err(|e| AppError::Message(format!("创建退出菜单失败: {e}")))?;
|
||||||
@@ -409,20 +393,6 @@ pub fn create_tray_menu(
|
|||||||
.map_err(|e| AppError::Message(format!("构建菜单失败: {e}")))
|
.map_err(|e| AppError::Message(format!("构建菜单失败: {e}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn refresh_tray_menu(app: &tauri::AppHandle) {
|
|
||||||
use crate::store::AppState;
|
|
||||||
|
|
||||||
if let Some(state) = app.try_state::<AppState>() {
|
|
||||||
if let Ok(new_menu) = create_tray_menu(app, state.inner()) {
|
|
||||||
if let Some(tray) = app.tray_by_id("main") {
|
|
||||||
if let Err(e) = tray.set_menu(Some(new_menu)) {
|
|
||||||
log::error!("刷新托盘菜单失败: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
pub fn apply_tray_policy(app: &tauri::AppHandle, dock_visible: bool) {
|
pub fn apply_tray_policy(app: &tauri::AppHandle, dock_visible: bool) {
|
||||||
use tauri::ActivationPolicy;
|
use tauri::ActivationPolicy;
|
||||||
@@ -460,19 +430,6 @@ pub fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
|
|||||||
{
|
{
|
||||||
apply_tray_policy(app, true);
|
apply_tray_policy(app, true);
|
||||||
}
|
}
|
||||||
} else if crate::lightweight::is_lightweight_mode() {
|
|
||||||
if let Err(e) = crate::lightweight::exit_lightweight_mode(app) {
|
|
||||||
log::error!("退出轻量模式重建窗口失败: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"lightweight_mode" => {
|
|
||||||
if crate::lightweight::is_lightweight_mode() {
|
|
||||||
if let Err(e) = crate::lightweight::exit_lightweight_mode(app) {
|
|
||||||
log::error!("退出轻量模式失败: {e}");
|
|
||||||
}
|
|
||||||
} else if let Err(e) = crate::lightweight::enter_lightweight_mode(app) {
|
|
||||||
log::error!("进入轻量模式失败: {e}");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"quit" => {
|
"quit" => {
|
||||||
|
|||||||
+7
-49
@@ -533,14 +533,8 @@ function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditProvider = async ({
|
const handleEditProvider = async (provider: Provider) => {
|
||||||
provider,
|
await updateProvider(provider);
|
||||||
originalId,
|
|
||||||
}: {
|
|
||||||
provider: Provider;
|
|
||||||
originalId?: string;
|
|
||||||
}) => {
|
|
||||||
await updateProvider(provider, originalId);
|
|
||||||
setEditingProvider(null);
|
setEditingProvider(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -577,7 +571,7 @@ function App() {
|
|||||||
setConfirmAction(null);
|
setConfirmAction(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const generateUniqueProviderCopyKey = (
|
const generateUniqueOpencodeKey = (
|
||||||
originalKey: string,
|
originalKey: string,
|
||||||
existingKeys: string[],
|
existingKeys: string[],
|
||||||
): string => {
|
): string => {
|
||||||
@@ -600,7 +594,6 @@ function App() {
|
|||||||
|
|
||||||
const duplicatedProvider: Omit<Provider, "id" | "createdAt"> & {
|
const duplicatedProvider: Omit<Provider, "id" | "createdAt"> & {
|
||||||
providerKey?: string;
|
providerKey?: string;
|
||||||
addToLive?: boolean;
|
|
||||||
} = {
|
} = {
|
||||||
name: `${provider.name} copy`,
|
name: `${provider.name} copy`,
|
||||||
settingsConfig: JSON.parse(JSON.stringify(provider.settingsConfig)), // 深拷贝
|
settingsConfig: JSON.parse(JSON.stringify(provider.settingsConfig)), // 深拷贝
|
||||||
@@ -614,40 +607,12 @@ function App() {
|
|||||||
iconColor: provider.iconColor,
|
iconColor: provider.iconColor,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (activeApp === "opencode" || activeApp === "openclaw") {
|
if (activeApp === "opencode") {
|
||||||
let liveProviderIds: string[] = [];
|
const existingKeys = Object.keys(providers);
|
||||||
try {
|
duplicatedProvider.providerKey = generateUniqueOpencodeKey(
|
||||||
liveProviderIds =
|
|
||||||
activeApp === "opencode"
|
|
||||||
? await queryClient.ensureQueryData({
|
|
||||||
queryKey: ["opencodeLiveProviderIds"],
|
|
||||||
queryFn: () => providersApi.getOpenCodeLiveProviderIds(),
|
|
||||||
})
|
|
||||||
: await queryClient.ensureQueryData({
|
|
||||||
queryKey: openclawKeys.liveProviderIds,
|
|
||||||
queryFn: () => providersApi.getOpenClawLiveProviderIds(),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error(
|
|
||||||
"[App] Failed to load live provider IDs for duplication",
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
const errorMessage = extractErrorMessage(error);
|
|
||||||
toast.error(
|
|
||||||
t("provider.duplicateLiveIdsLoadFailed", {
|
|
||||||
defaultValue: "读取配置中的供应商标识失败,请先修复配置后再试",
|
|
||||||
}) + (errorMessage ? `: ${errorMessage}` : ""),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const existingKeys = Array.from(
|
|
||||||
new Set([...Object.keys(providers), ...liveProviderIds]),
|
|
||||||
);
|
|
||||||
duplicatedProvider.providerKey = generateUniqueProviderCopyKey(
|
|
||||||
provider.id,
|
provider.id,
|
||||||
existingKeys,
|
existingKeys,
|
||||||
);
|
);
|
||||||
duplicatedProvider.addToLive = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (provider.sortIndex !== undefined) {
|
if (provider.sortIndex !== undefined) {
|
||||||
@@ -683,14 +648,7 @@ function App() {
|
|||||||
|
|
||||||
const handleOpenTerminal = async (provider: Provider) => {
|
const handleOpenTerminal = async (provider: Provider) => {
|
||||||
try {
|
try {
|
||||||
const selectedDir = await settingsApi.pickDirectory();
|
await providersApi.openTerminal(provider.id, activeApp);
|
||||||
if (!selectedDir) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await providersApi.openTerminal(provider.id, activeApp, {
|
|
||||||
cwd: selectedDir,
|
|
||||||
});
|
|
||||||
toast.success(
|
toast.success(
|
||||||
t("provider.terminalOpened", {
|
t("provider.terminalOpened", {
|
||||||
defaultValue: "终端已打开",
|
defaultValue: "终端已打开",
|
||||||
|
|||||||
@@ -14,10 +14,7 @@ interface EditProviderDialogProps {
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
provider: Provider | null;
|
provider: Provider | null;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
onSubmit: (payload: {
|
onSubmit: (provider: Provider) => Promise<void> | void;
|
||||||
provider: Provider;
|
|
||||||
originalId?: string;
|
|
||||||
}) => Promise<void> | void;
|
|
||||||
appId: AppId;
|
appId: AppId;
|
||||||
isProxyTakeover?: boolean; // 代理接管模式下不读取 live(避免显示被接管后的代理配置)
|
isProxyTakeover?: boolean; // 代理接管模式下不读取 live(避免显示被接管后的代理配置)
|
||||||
}
|
}
|
||||||
@@ -168,15 +165,9 @@ export function EditProviderDialog({
|
|||||||
string,
|
string,
|
||||||
unknown
|
unknown
|
||||||
>;
|
>;
|
||||||
const nextProviderId =
|
|
||||||
(appId === "opencode" || appId === "openclaw") &&
|
|
||||||
values.providerKey?.trim()
|
|
||||||
? values.providerKey.trim()
|
|
||||||
: provider.id;
|
|
||||||
|
|
||||||
const updatedProvider: Provider = {
|
const updatedProvider: Provider = {
|
||||||
...provider,
|
...provider,
|
||||||
id: nextProviderId,
|
|
||||||
name: values.name.trim(),
|
name: values.name.trim(),
|
||||||
notes: values.notes?.trim() || undefined,
|
notes: values.notes?.trim() || undefined,
|
||||||
websiteUrl: values.websiteUrl?.trim() || undefined,
|
websiteUrl: values.websiteUrl?.trim() || undefined,
|
||||||
@@ -188,13 +179,10 @@ export function EditProviderDialog({
|
|||||||
...(values.meta ? { meta: values.meta } : {}),
|
...(values.meta ? { meta: values.meta } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
await onSubmit({
|
await onSubmit(updatedProvider);
|
||||||
provider: updatedProvider,
|
|
||||||
originalId: provider.id,
|
|
||||||
});
|
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
},
|
},
|
||||||
[appId, onSubmit, onOpenChange, provider],
|
[onSubmit, onOpenChange, provider],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!provider || !initialData) {
|
if (!provider || !initialData) {
|
||||||
|
|||||||
@@ -123,7 +123,6 @@ export function ProviderCard({
|
|||||||
// OMO and OMO Slim share the same card behavior
|
// OMO and OMO Slim share the same card behavior
|
||||||
const isAnyOmo = isOmo || isOmoSlim;
|
const isAnyOmo = isOmo || isOmoSlim;
|
||||||
const handleDisableAnyOmo = isOmoSlim ? onDisableOmoSlim : onDisableOmo;
|
const handleDisableAnyOmo = isOmoSlim ? onDisableOmoSlim : onDisableOmo;
|
||||||
const isAdditiveMode = appId === "opencode" && !isAnyOmo;
|
|
||||||
|
|
||||||
const { data: health } = useProviderHealth(provider.id, appId);
|
const { data: health } = useProviderHealth(provider.id, appId);
|
||||||
|
|
||||||
@@ -210,12 +209,9 @@ export function ProviderCard({
|
|||||||
: isCurrent;
|
: isCurrent;
|
||||||
|
|
||||||
const shouldUseGreen = !isAnyOmo && isProxyTakeover && isActiveProvider;
|
const shouldUseGreen = !isAnyOmo && isProxyTakeover && isActiveProvider;
|
||||||
const hasPersistentConfigHighlight = isAdditiveMode && isInConfig;
|
|
||||||
const shouldUseBlue =
|
const shouldUseBlue =
|
||||||
(isAnyOmo && isActiveProvider) ||
|
(isAnyOmo && isActiveProvider) ||
|
||||||
(!isAnyOmo &&
|
(!isAnyOmo && !isProxyTakeover && isActiveProvider);
|
||||||
!isProxyTakeover &&
|
|
||||||
(isActiveProvider || hasPersistentConfigHighlight));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -228,8 +224,7 @@ export function ProviderCard({
|
|||||||
shouldUseGreen &&
|
shouldUseGreen &&
|
||||||
"border-emerald-500/60 shadow-sm shadow-emerald-500/10",
|
"border-emerald-500/60 shadow-sm shadow-emerald-500/10",
|
||||||
shouldUseBlue && "border-blue-500/60 shadow-sm shadow-blue-500/10",
|
shouldUseBlue && "border-blue-500/60 shadow-sm shadow-blue-500/10",
|
||||||
!(isActiveProvider || hasPersistentConfigHighlight) &&
|
!isActiveProvider && "hover:shadow-sm",
|
||||||
"hover:shadow-sm",
|
|
||||||
dragHandleProps?.isDragging &&
|
dragHandleProps?.isDragging &&
|
||||||
"cursor-grabbing border-primary shadow-lg scale-105 z-10",
|
"cursor-grabbing border-primary shadow-lg scale-105 z-10",
|
||||||
)}
|
)}
|
||||||
@@ -239,10 +234,8 @@ export function ProviderCard({
|
|||||||
"absolute inset-0 bg-gradient-to-r to-transparent transition-opacity duration-500 pointer-events-none",
|
"absolute inset-0 bg-gradient-to-r to-transparent transition-opacity duration-500 pointer-events-none",
|
||||||
shouldUseGreen && "from-emerald-500/10",
|
shouldUseGreen && "from-emerald-500/10",
|
||||||
shouldUseBlue && "from-blue-500/10",
|
shouldUseBlue && "from-blue-500/10",
|
||||||
!shouldUseGreen && !shouldUseBlue && "from-primary/10",
|
!isActiveProvider && "from-primary/10",
|
||||||
isActiveProvider || hasPersistentConfigHighlight
|
isActiveProvider ? "opacity-100" : "opacity-0",
|
||||||
? "opacity-100"
|
|
||||||
: "opacity-0",
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div className="relative flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
<div className="relative flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import { useEffect, useMemo, useState, useCallback } from "react";
|
import { useEffect, useMemo, useState, useCallback } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Form, FormField, FormItem, FormMessage } from "@/components/ui/form";
|
import { Form, FormField, FormItem, FormMessage } from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { providerSchema, type ProviderFormData } from "@/lib/schemas/provider";
|
import { providerSchema, type ProviderFormData } from "@/lib/schemas/provider";
|
||||||
import { providersApi, type AppId } from "@/lib/api";
|
import type { AppId } from "@/lib/api";
|
||||||
import type {
|
import type {
|
||||||
ProviderCategory,
|
ProviderCategory,
|
||||||
ProviderMeta,
|
ProviderMeta,
|
||||||
@@ -92,7 +91,6 @@ import {
|
|||||||
normalizePricingSource,
|
normalizePricingSource,
|
||||||
} from "./helpers/opencodeFormUtils";
|
} from "./helpers/opencodeFormUtils";
|
||||||
import { resolveManagedAccountId } from "@/lib/authBinding";
|
import { resolveManagedAccountId } from "@/lib/authBinding";
|
||||||
import { useOpenClawLiveProviderIds } from "@/hooks/useOpenClaw";
|
|
||||||
|
|
||||||
type PresetEntry = {
|
type PresetEntry = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -579,15 +577,6 @@ export function ProviderForm({
|
|||||||
existingOpencodeKeys,
|
existingOpencodeKeys,
|
||||||
} = useOmoModelSource({ isOmoCategory: isAnyOmoCategory, providerId });
|
} = useOmoModelSource({ isOmoCategory: isAnyOmoCategory, providerId });
|
||||||
|
|
||||||
const {
|
|
||||||
data: opencodeLiveProviderIds = [],
|
|
||||||
isLoading: isOpencodeLiveProviderIdsLoading,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["opencodeLiveProviderIds"],
|
|
||||||
queryFn: () => providersApi.getOpenCodeLiveProviderIds(),
|
|
||||||
enabled: appId === "opencode" && !isAnyOmoCategory,
|
|
||||||
});
|
|
||||||
|
|
||||||
const opencodeForm = useOpencodeFormState({
|
const opencodeForm = useOpencodeFormState({
|
||||||
initialData,
|
initialData,
|
||||||
appId,
|
appId,
|
||||||
@@ -616,78 +605,6 @@ export function ProviderForm({
|
|||||||
onSettingsConfigChange: (config) => form.setValue("settingsConfig", config),
|
onSettingsConfigChange: (config) => form.setValue("settingsConfig", config),
|
||||||
getSettingsConfig: () => form.getValues("settingsConfig"),
|
getSettingsConfig: () => form.getValues("settingsConfig"),
|
||||||
});
|
});
|
||||||
const {
|
|
||||||
data: openclawLiveProviderIds = [],
|
|
||||||
isLoading: isOpenclawLiveProviderIdsLoading,
|
|
||||||
} = useOpenClawLiveProviderIds(appId === "openclaw");
|
|
||||||
|
|
||||||
const additiveExistingProviderKeys = useMemo(() => {
|
|
||||||
if (appId === "opencode" && !isAnyOmoCategory) {
|
|
||||||
return Array.from(
|
|
||||||
new Set(
|
|
||||||
[...existingOpencodeKeys, ...opencodeLiveProviderIds].filter(
|
|
||||||
(key) => key !== providerId,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (appId === "openclaw") {
|
|
||||||
return Array.from(
|
|
||||||
new Set(
|
|
||||||
[
|
|
||||||
...openclawForm.existingOpenclawKeys,
|
|
||||||
...openclawLiveProviderIds,
|
|
||||||
].filter((key) => key !== providerId),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
|
||||||
}, [
|
|
||||||
appId,
|
|
||||||
existingOpencodeKeys,
|
|
||||||
isAnyOmoCategory,
|
|
||||||
openclawForm.existingOpenclawKeys,
|
|
||||||
openclawLiveProviderIds,
|
|
||||||
opencodeLiveProviderIds,
|
|
||||||
providerId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const isProviderKeyLockStateLoading = useMemo(() => {
|
|
||||||
if (!isEditMode) return false;
|
|
||||||
if (appId === "opencode" && !isAnyOmoCategory) {
|
|
||||||
return isOpencodeLiveProviderIdsLoading;
|
|
||||||
}
|
|
||||||
if (appId === "openclaw") {
|
|
||||||
return isOpenclawLiveProviderIdsLoading;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}, [
|
|
||||||
appId,
|
|
||||||
isAnyOmoCategory,
|
|
||||||
isEditMode,
|
|
||||||
isOpenclawLiveProviderIdsLoading,
|
|
||||||
isOpencodeLiveProviderIdsLoading,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const isProviderKeyLocked = useMemo(() => {
|
|
||||||
if (!isEditMode || !providerId) return false;
|
|
||||||
if (appId === "opencode" && !isAnyOmoCategory) {
|
|
||||||
return opencodeLiveProviderIds.includes(providerId);
|
|
||||||
}
|
|
||||||
if (appId === "openclaw") {
|
|
||||||
return openclawLiveProviderIds.includes(providerId);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}, [
|
|
||||||
appId,
|
|
||||||
isAnyOmoCategory,
|
|
||||||
isEditMode,
|
|
||||||
openclawLiveProviderIds,
|
|
||||||
opencodeLiveProviderIds,
|
|
||||||
providerId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
|
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
|
||||||
|
|
||||||
@@ -724,17 +641,9 @@ export function ProviderForm({
|
|||||||
toast.error(t("opencode.providerKeyInvalid"));
|
toast.error(t("opencode.providerKeyInvalid"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isProviderKeyLockStateLoading) {
|
|
||||||
toast.error(
|
|
||||||
t("providerForm.providerKeyStatusLoading", {
|
|
||||||
defaultValue: "正在加载供应商标识状态,请稍后再试",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
if (
|
||||||
!isProviderKeyLocked &&
|
!isEditMode &&
|
||||||
additiveExistingProviderKeys.includes(opencodeForm.opencodeProviderKey)
|
existingOpencodeKeys.includes(opencodeForm.opencodeProviderKey)
|
||||||
) {
|
) {
|
||||||
toast.error(t("opencode.providerKeyDuplicate"));
|
toast.error(t("opencode.providerKeyDuplicate"));
|
||||||
return;
|
return;
|
||||||
@@ -756,17 +665,11 @@ export function ProviderForm({
|
|||||||
toast.error(t("openclaw.providerKeyInvalid"));
|
toast.error(t("openclaw.providerKeyInvalid"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isProviderKeyLockStateLoading) {
|
|
||||||
toast.error(
|
|
||||||
t("providerForm.providerKeyStatusLoading", {
|
|
||||||
defaultValue: "正在加载供应商标识状态,请稍后再试",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
if (
|
||||||
!isProviderKeyLocked &&
|
!isEditMode &&
|
||||||
additiveExistingProviderKeys.includes(openclawForm.openclawProviderKey)
|
openclawForm.existingOpenclawKeys.includes(
|
||||||
|
openclawForm.openclawProviderKey,
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
toast.error(t("openclaw.providerKeyDuplicate"));
|
toast.error(t("openclaw.providerKeyDuplicate"));
|
||||||
return;
|
return;
|
||||||
@@ -1350,14 +1253,12 @@ export function ProviderForm({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
placeholder={t("opencode.providerKeyPlaceholder")}
|
placeholder={t("opencode.providerKeyPlaceholder")}
|
||||||
disabled={
|
disabled={isEditMode}
|
||||||
isProviderKeyLocked || isProviderKeyLockStateLoading
|
|
||||||
}
|
|
||||||
className={
|
className={
|
||||||
(additiveExistingProviderKeys.includes(
|
(existingOpencodeKeys.includes(
|
||||||
opencodeForm.opencodeProviderKey,
|
opencodeForm.opencodeProviderKey,
|
||||||
) &&
|
) &&
|
||||||
!isProviderKeyLocked) ||
|
!isEditMode) ||
|
||||||
(opencodeForm.opencodeProviderKey.trim() !== "" &&
|
(opencodeForm.opencodeProviderKey.trim() !== "" &&
|
||||||
!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(
|
!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(
|
||||||
opencodeForm.opencodeProviderKey,
|
opencodeForm.opencodeProviderKey,
|
||||||
@@ -1366,10 +1267,10 @@ export function ProviderForm({
|
|||||||
: ""
|
: ""
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{additiveExistingProviderKeys.includes(
|
{existingOpencodeKeys.includes(
|
||||||
opencodeForm.opencodeProviderKey,
|
opencodeForm.opencodeProviderKey,
|
||||||
) &&
|
) &&
|
||||||
!isProviderKeyLocked && (
|
!isEditMode && (
|
||||||
<p className="text-xs text-destructive">
|
<p className="text-xs text-destructive">
|
||||||
{t("opencode.providerKeyDuplicate")}
|
{t("opencode.providerKeyDuplicate")}
|
||||||
</p>
|
</p>
|
||||||
@@ -1383,21 +1284,16 @@ export function ProviderForm({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{!(
|
{!(
|
||||||
additiveExistingProviderKeys.includes(
|
existingOpencodeKeys.includes(
|
||||||
opencodeForm.opencodeProviderKey,
|
opencodeForm.opencodeProviderKey,
|
||||||
) && !isProviderKeyLocked
|
) && !isEditMode
|
||||||
) &&
|
) &&
|
||||||
(opencodeForm.opencodeProviderKey.trim() === "" ||
|
(opencodeForm.opencodeProviderKey.trim() === "" ||
|
||||||
/^[a-z0-9]+(-[a-z0-9]+)*$/.test(
|
/^[a-z0-9]+(-[a-z0-9]+)*$/.test(
|
||||||
opencodeForm.opencodeProviderKey,
|
opencodeForm.opencodeProviderKey,
|
||||||
)) && (
|
)) && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{isProviderKeyLocked
|
{t("opencode.providerKeyHint")}
|
||||||
? t("opencode.providerKeyLockedHint", {
|
|
||||||
defaultValue:
|
|
||||||
"该供应商已添加到应用配置中,供应商标识不可修改",
|
|
||||||
})
|
|
||||||
: t("opencode.providerKeyHint")}
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1416,14 +1312,12 @@ export function ProviderForm({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
placeholder={t("openclaw.providerKeyPlaceholder")}
|
placeholder={t("openclaw.providerKeyPlaceholder")}
|
||||||
disabled={
|
disabled={isEditMode}
|
||||||
isProviderKeyLocked || isProviderKeyLockStateLoading
|
|
||||||
}
|
|
||||||
className={
|
className={
|
||||||
(additiveExistingProviderKeys.includes(
|
(openclawForm.existingOpenclawKeys.includes(
|
||||||
openclawForm.openclawProviderKey,
|
openclawForm.openclawProviderKey,
|
||||||
) &&
|
) &&
|
||||||
!isProviderKeyLocked) ||
|
!isEditMode) ||
|
||||||
(openclawForm.openclawProviderKey.trim() !== "" &&
|
(openclawForm.openclawProviderKey.trim() !== "" &&
|
||||||
!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(
|
!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(
|
||||||
openclawForm.openclawProviderKey,
|
openclawForm.openclawProviderKey,
|
||||||
@@ -1432,10 +1326,10 @@ export function ProviderForm({
|
|||||||
: ""
|
: ""
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{additiveExistingProviderKeys.includes(
|
{openclawForm.existingOpenclawKeys.includes(
|
||||||
openclawForm.openclawProviderKey,
|
openclawForm.openclawProviderKey,
|
||||||
) &&
|
) &&
|
||||||
!isProviderKeyLocked && (
|
!isEditMode && (
|
||||||
<p className="text-xs text-destructive">
|
<p className="text-xs text-destructive">
|
||||||
{t("openclaw.providerKeyDuplicate")}
|
{t("openclaw.providerKeyDuplicate")}
|
||||||
</p>
|
</p>
|
||||||
@@ -1449,21 +1343,16 @@ export function ProviderForm({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{!(
|
{!(
|
||||||
additiveExistingProviderKeys.includes(
|
openclawForm.existingOpenclawKeys.includes(
|
||||||
openclawForm.openclawProviderKey,
|
openclawForm.openclawProviderKey,
|
||||||
) && !isProviderKeyLocked
|
) && !isEditMode
|
||||||
) &&
|
) &&
|
||||||
(openclawForm.openclawProviderKey.trim() === "" ||
|
(openclawForm.openclawProviderKey.trim() === "" ||
|
||||||
/^[a-z0-9]+(-[a-z0-9]+)*$/.test(
|
/^[a-z0-9]+(-[a-z0-9]+)*$/.test(
|
||||||
openclawForm.openclawProviderKey,
|
openclawForm.openclawProviderKey,
|
||||||
)) && (
|
)) && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{isProviderKeyLocked
|
{t("openclaw.providerKeyHint")}
|
||||||
? t("openclaw.providerKeyLockedHint", {
|
|
||||||
defaultValue:
|
|
||||||
"该供应商已添加到应用配置中,供应商标识不可修改",
|
|
||||||
})
|
|
||||||
: t("openclaw.providerKeyHint")}
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export function ProviderPresetSelector({
|
|||||||
case "omo":
|
case "omo":
|
||||||
return t("providerForm.omoHint", {
|
return t("providerForm.omoHint", {
|
||||||
defaultValue:
|
defaultValue:
|
||||||
"💡 OMO 配置管理 Agent 模型分配,兼容 oh-my-openagent.jsonc / oh-my-opencode.jsonc",
|
"💡 OMO 配置管理 Agent 模型分配,写入 oh-my-opencode.jsonc",
|
||||||
});
|
});
|
||||||
default:
|
default:
|
||||||
return t("providerPreset.hint", {
|
return t("providerPreset.hint", {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { ChevronRight, Clock } from "lucide-react";
|
import { ChevronRight, Clock } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -20,21 +19,13 @@ import {
|
|||||||
interface SessionItemProps {
|
interface SessionItemProps {
|
||||||
session: SessionMeta;
|
session: SessionMeta;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
selectionMode: boolean;
|
|
||||||
isChecked: boolean;
|
|
||||||
isCheckDisabled?: boolean;
|
|
||||||
onSelect: (key: string) => void;
|
onSelect: (key: string) => void;
|
||||||
onToggleChecked: (checked: boolean) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SessionItem({
|
export function SessionItem({
|
||||||
session,
|
session,
|
||||||
isSelected,
|
isSelected,
|
||||||
selectionMode,
|
|
||||||
isChecked,
|
|
||||||
isCheckDisabled = false,
|
|
||||||
onSelect,
|
onSelect,
|
||||||
onToggleChecked,
|
|
||||||
}: SessionItemProps) {
|
}: SessionItemProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const title = formatSessionTitle(session);
|
const title = formatSessionTitle(session);
|
||||||
@@ -42,64 +33,46 @@ export function SessionItem({
|
|||||||
const sessionKey = getSessionKey(session);
|
const sessionKey = getSessionKey(session);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(sessionKey)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-start gap-2 rounded-lg px-3 py-2.5 transition-all group",
|
"w-full text-left rounded-lg px-3 py-2.5 transition-all group",
|
||||||
isSelected
|
isSelected
|
||||||
? "bg-primary/10 border border-primary/30"
|
? "bg-primary/10 border border-primary/30"
|
||||||
: "hover:bg-muted/60 border border-transparent",
|
: "hover:bg-muted/60 border border-transparent",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{selectionMode && (
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<div className="shrink-0 pt-0.5">
|
<Tooltip>
|
||||||
<Checkbox
|
<TooltipTrigger asChild>
|
||||||
checked={isChecked}
|
<span className="shrink-0">
|
||||||
disabled={isCheckDisabled}
|
<ProviderIcon
|
||||||
aria-label={t("sessionManager.selectForBatch", {
|
icon={getProviderIconName(session.providerId)}
|
||||||
defaultValue: "选择会话",
|
name={session.providerId}
|
||||||
})}
|
size={18}
|
||||||
onCheckedChange={(checked) => onToggleChecked(Boolean(checked))}
|
/>
|
||||||
/>
|
</span>
|
||||||
</div>
|
</TooltipTrigger>
|
||||||
)}
|
<TooltipContent>
|
||||||
<button
|
{getProviderLabel(session.providerId, t)}
|
||||||
type="button"
|
</TooltipContent>
|
||||||
onClick={() => onSelect(sessionKey)}
|
</Tooltip>
|
||||||
className="min-w-0 flex-1 text-left"
|
<span className="text-sm font-medium truncate flex-1">{title}</span>
|
||||||
>
|
<ChevronRight
|
||||||
<div className="flex items-center gap-2 mb-1">
|
className={cn(
|
||||||
<Tooltip>
|
"size-4 text-muted-foreground/50 shrink-0 transition-transform",
|
||||||
<TooltipTrigger asChild>
|
isSelected && "text-primary rotate-90",
|
||||||
<span className="shrink-0">
|
)}
|
||||||
<ProviderIcon
|
/>
|
||||||
icon={getProviderIconName(session.providerId)}
|
</div>
|
||||||
name={session.providerId}
|
|
||||||
size={18}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
{getProviderLabel(session.providerId, t)}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<span className="text-sm font-medium truncate flex-1">{title}</span>
|
|
||||||
<ChevronRight
|
|
||||||
className={cn(
|
|
||||||
"size-4 text-muted-foreground/50 shrink-0 transition-transform",
|
|
||||||
isSelected && "text-primary rotate-90",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1 text-[11px] text-muted-foreground">
|
<div className="flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||||
<Clock className="size-3" />
|
<Clock className="size-3" />
|
||||||
<span>
|
<span>
|
||||||
{lastActive
|
{lastActive ? formatRelativeTime(lastActive, t) : t("common.unknown")}
|
||||||
? formatRelativeTime(lastActive, t)
|
</span>
|
||||||
: t("common.unknown")}
|
</div>
|
||||||
</span>
|
</button>
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import { useSessionSearch } from "@/hooks/useSessionSearch";
|
import { useSessionSearch } from "@/hooks/useSessionSearch";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import {
|
import {
|
||||||
Copy,
|
Copy,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
@@ -13,7 +12,6 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
X,
|
X,
|
||||||
CheckSquare,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
useDeleteSessionMutation,
|
useDeleteSessionMutation,
|
||||||
@@ -65,7 +63,6 @@ type ProviderFilter =
|
|||||||
|
|
||||||
export function SessionManagerPage({ appId }: { appId: string }) {
|
export function SessionManagerPage({ appId }: { appId: string }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { data, isLoading, refetch } = useSessionsQuery();
|
const { data, isLoading, refetch } = useSessionsQuery();
|
||||||
const sessions = data ?? [];
|
const sessions = data ?? [];
|
||||||
const detailRef = useRef<HTMLDivElement | null>(null);
|
const detailRef = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -76,14 +73,7 @@ export function SessionManagerPage({ appId }: { appId: string }) {
|
|||||||
);
|
);
|
||||||
const [tocDialogOpen, setTocDialogOpen] = useState(false);
|
const [tocDialogOpen, setTocDialogOpen] = useState(false);
|
||||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||||
const [deleteTargets, setDeleteTargets] = useState<SessionMeta[] | null>(
|
const [deleteTarget, setDeleteTarget] = useState<SessionMeta | null>(null);
|
||||||
null,
|
|
||||||
);
|
|
||||||
const [selectedSessionKeys, setSelectedSessionKeys] = useState<Set<string>>(
|
|
||||||
() => new Set(),
|
|
||||||
);
|
|
||||||
const [isBatchDeleting, setIsBatchDeleting] = useState(false);
|
|
||||||
const [selectionMode, setSelectionMode] = useState(false);
|
|
||||||
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
@@ -132,25 +122,6 @@ export function SessionManagerPage({ appId }: { appId: string }) {
|
|||||||
selectedSession?.sourcePath,
|
selectedSession?.sourcePath,
|
||||||
);
|
);
|
||||||
const deleteSessionMutation = useDeleteSessionMutation();
|
const deleteSessionMutation = useDeleteSessionMutation();
|
||||||
const isDeleting = deleteSessionMutation.isPending || isBatchDeleting;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const validKeys = new Set(
|
|
||||||
sessions.map((session) => getSessionKey(session)),
|
|
||||||
);
|
|
||||||
setSelectedSessionKeys((current) => {
|
|
||||||
let changed = false;
|
|
||||||
const next = new Set<string>();
|
|
||||||
current.forEach((key) => {
|
|
||||||
if (validKeys.has(key)) {
|
|
||||||
next.add(key);
|
|
||||||
} else {
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return changed ? next : current;
|
|
||||||
});
|
|
||||||
}, [sessions]);
|
|
||||||
|
|
||||||
// 提取用户消息用于目录
|
// 提取用户消息用于目录
|
||||||
const userMessagesToc = useMemo(() => {
|
const userMessagesToc = useMemo(() => {
|
||||||
@@ -223,195 +194,16 @@ export function SessionManagerPage({ appId }: { appId: string }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteConfirm = async () => {
|
const handleDeleteConfirm = async () => {
|
||||||
if (!deleteTargets || deleteTargets.length === 0 || isDeleting) {
|
if (!deleteTarget?.sourcePath || deleteSessionMutation.isPending) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const targets = deleteTargets.filter((session) => session.sourcePath);
|
setDeleteTarget(null);
|
||||||
setDeleteTargets(null);
|
await deleteSessionMutation.mutateAsync({
|
||||||
|
providerId: deleteTarget.providerId,
|
||||||
if (targets.length === 0) {
|
sessionId: deleteTarget.sessionId,
|
||||||
return;
|
sourcePath: deleteTarget.sourcePath,
|
||||||
}
|
|
||||||
|
|
||||||
if (targets.length === 1) {
|
|
||||||
const [target] = targets;
|
|
||||||
await deleteSessionMutation.mutateAsync({
|
|
||||||
providerId: target.providerId,
|
|
||||||
sessionId: target.sessionId,
|
|
||||||
sourcePath: target.sourcePath!,
|
|
||||||
});
|
|
||||||
setSelectedSessionKeys((current) => {
|
|
||||||
const next = new Set(current);
|
|
||||||
next.delete(getSessionKey(target));
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsBatchDeleting(true);
|
|
||||||
try {
|
|
||||||
const results = await sessionsApi.deleteMany(
|
|
||||||
targets.map((session) => ({
|
|
||||||
providerId: session.providerId,
|
|
||||||
sessionId: session.sessionId,
|
|
||||||
sourcePath: session.sourcePath!,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
const deletedKeys = results
|
|
||||||
.filter((result) => result.success)
|
|
||||||
.map(
|
|
||||||
(result) =>
|
|
||||||
`${result.providerId}:${result.sessionId}:${result.sourcePath ?? ""}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const failedErrors = results
|
|
||||||
.filter((result) => !result.success)
|
|
||||||
.map((result) => result.error || t("common.unknown"));
|
|
||||||
|
|
||||||
if (deletedKeys.length > 0) {
|
|
||||||
const deletedKeySet = new Set(deletedKeys);
|
|
||||||
queryClient.setQueryData<SessionMeta[]>(["sessions"], (current) =>
|
|
||||||
(current ?? []).filter(
|
|
||||||
(session) => !deletedKeySet.has(getSessionKey(session)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
results
|
|
||||||
.filter((result) => result.success)
|
|
||||||
.forEach((result) => {
|
|
||||||
queryClient.removeQueries({
|
|
||||||
queryKey: ["sessionMessages", result.providerId, result.sourcePath],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
setSelectedSessionKeys((current) => {
|
|
||||||
const next = new Set(current);
|
|
||||||
deletedKeys.forEach((key) => next.delete(key));
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
|
|
||||||
await queryClient.invalidateQueries({ queryKey: ["sessions"] });
|
|
||||||
|
|
||||||
if (deletedKeys.length > 0) {
|
|
||||||
toast.success(
|
|
||||||
t("sessionManager.batchDeleteSuccess", {
|
|
||||||
defaultValue: "已删除 {{count}} 个会话",
|
|
||||||
count: deletedKeys.length,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (failedErrors.length > 0) {
|
|
||||||
toast.error(
|
|
||||||
t("sessionManager.batchDeleteFailed", {
|
|
||||||
defaultValue: "{{failed}} 个会话删除失败",
|
|
||||||
failed: failedErrors.length,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
description: failedErrors[0],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(
|
|
||||||
extractErrorMessage(error) ||
|
|
||||||
t("sessionManager.batchDeleteRequestFailed", {
|
|
||||||
defaultValue: "批量删除失败,请稍后重试",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIsBatchDeleting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const deletableFilteredSessions = useMemo(
|
|
||||||
() => filteredSessions.filter((session) => Boolean(session.sourcePath)),
|
|
||||||
[filteredSessions],
|
|
||||||
);
|
|
||||||
|
|
||||||
const selectedSessions = useMemo(
|
|
||||||
() =>
|
|
||||||
sessions.filter((session) =>
|
|
||||||
selectedSessionKeys.has(getSessionKey(session)),
|
|
||||||
),
|
|
||||||
[sessions, selectedSessionKeys],
|
|
||||||
);
|
|
||||||
|
|
||||||
const selectedDeletableSessions = useMemo(
|
|
||||||
() => selectedSessions.filter((session) => Boolean(session.sourcePath)),
|
|
||||||
[selectedSessions],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectionMode) return;
|
|
||||||
|
|
||||||
const visibleKeys = new Set(
|
|
||||||
deletableFilteredSessions.map((session) => getSessionKey(session)),
|
|
||||||
);
|
|
||||||
|
|
||||||
setSelectedSessionKeys((current) => {
|
|
||||||
let changed = false;
|
|
||||||
const next = new Set<string>();
|
|
||||||
|
|
||||||
current.forEach((key) => {
|
|
||||||
if (visibleKeys.has(key)) {
|
|
||||||
next.add(key);
|
|
||||||
} else {
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return changed ? next : current;
|
|
||||||
});
|
});
|
||||||
}, [deletableFilteredSessions, selectionMode]);
|
|
||||||
|
|
||||||
const allFilteredSelected =
|
|
||||||
deletableFilteredSessions.length > 0 &&
|
|
||||||
deletableFilteredSessions.every((session) =>
|
|
||||||
selectedSessionKeys.has(getSessionKey(session)),
|
|
||||||
);
|
|
||||||
|
|
||||||
const toggleSessionChecked = (session: SessionMeta, checked: boolean) => {
|
|
||||||
if (!session.sourcePath) return;
|
|
||||||
const key = getSessionKey(session);
|
|
||||||
setSelectedSessionKeys((current) => {
|
|
||||||
const next = new Set(current);
|
|
||||||
if (checked) {
|
|
||||||
next.add(key);
|
|
||||||
} else {
|
|
||||||
next.delete(key);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToggleSelectAll = () => {
|
|
||||||
setSelectedSessionKeys((current) => {
|
|
||||||
const next = new Set(current);
|
|
||||||
if (allFilteredSelected) {
|
|
||||||
deletableFilteredSessions.forEach((session) =>
|
|
||||||
next.delete(getSessionKey(session)),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
deletableFilteredSessions.forEach((session) =>
|
|
||||||
next.add(getSessionKey(session)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const openBatchDeleteDialog = () => {
|
|
||||||
if (selectedDeletableSessions.length === 0) return;
|
|
||||||
setDeleteTargets(selectedDeletableSessions);
|
|
||||||
};
|
|
||||||
|
|
||||||
const exitSelectionMode = () => {
|
|
||||||
setSelectionMode(false);
|
|
||||||
setSelectedSessionKeys(new Set());
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -427,315 +219,174 @@ export function SessionManagerPage({ appId }: { appId: string }) {
|
|||||||
<Card className="flex flex-col flex-1 min-h-0 overflow-hidden">
|
<Card className="flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||||
<CardHeader className="py-2 px-3 border-b">
|
<CardHeader className="py-2 px-3 border-b">
|
||||||
{isSearchOpen ? (
|
{isSearchOpen ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="relative flex-1">
|
||||||
<div className="relative flex-1">
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
|
||||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
|
<Input
|
||||||
<Input
|
ref={searchInputRef}
|
||||||
ref={searchInputRef}
|
value={search}
|
||||||
value={search}
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
onChange={(event) => setSearch(event.target.value)}
|
placeholder={t("sessionManager.searchPlaceholder")}
|
||||||
placeholder={t("sessionManager.searchPlaceholder")}
|
className="h-8 pl-8 pr-8 text-sm"
|
||||||
className="h-8 pl-8 pr-8 text-sm"
|
autoFocus
|
||||||
autoFocus
|
onKeyDown={(e) => {
|
||||||
onKeyDown={(e) => {
|
if (e.key === "Escape") {
|
||||||
if (e.key === "Escape") {
|
|
||||||
setIsSearchOpen(false);
|
|
||||||
setSearch("");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onBlur={() => {
|
|
||||||
if (search.trim() === "") {
|
|
||||||
setIsSearchOpen(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="absolute right-1 top-1/2 -translate-y-1/2 size-6"
|
|
||||||
onClick={() => {
|
|
||||||
setIsSearchOpen(false);
|
setIsSearchOpen(false);
|
||||||
setSearch("");
|
setSearch("");
|
||||||
}}
|
}
|
||||||
>
|
}}
|
||||||
<X className="size-3" />
|
onBlur={() => {
|
||||||
</Button>
|
if (search.trim() === "") {
|
||||||
|
setIsSearchOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute right-1 top-1/2 -translate-y-1/2 size-6"
|
||||||
|
onClick={() => {
|
||||||
|
setIsSearchOpen(false);
|
||||||
|
setSearch("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="size-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
{t("sessionManager.sessionList")}
|
||||||
|
</CardTitle>
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{filteredSessions.length}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
{selectionMode && (
|
<div className="flex items-center gap-1">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="size-7 bg-blue-50 text-blue-600 hover:bg-blue-100 dark:bg-blue-950/40 dark:text-blue-300 dark:hover:bg-blue-950/60"
|
className="size-7"
|
||||||
aria-label={t("sessionManager.exitBatchModeTooltip", {
|
onClick={() => {
|
||||||
defaultValue: "退出批量管理",
|
setIsSearchOpen(true);
|
||||||
})}
|
setTimeout(
|
||||||
onClick={exitSelectionMode}
|
() => searchInputRef.current?.focus(),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<CheckSquare className="size-3.5" />
|
<Search className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
{t("sessionManager.exitBatchModeTooltip", {
|
{t("sessionManager.searchSessions")}
|
||||||
defaultValue: "退出批量管理",
|
|
||||||
})}
|
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
|
||||||
</div>
|
<Select
|
||||||
) : (
|
value={providerFilter}
|
||||||
<div className="flex flex-col gap-2">
|
onValueChange={(value) =>
|
||||||
<div className="flex items-center justify-between gap-2">
|
setProviderFilter(value as ProviderFilter)
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
}
|
||||||
<CardTitle className="text-sm font-medium whitespace-nowrap">
|
>
|
||||||
{t("sessionManager.sessionList")}
|
|
||||||
</CardTitle>
|
|
||||||
<Badge variant="secondary" className="text-xs">
|
|
||||||
{filteredSessions.length}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
|
||||||
{(selectionMode ||
|
|
||||||
deletableFilteredSessions.length > 0) && (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant={selectionMode ? "secondary" : "ghost"}
|
|
||||||
size="icon"
|
|
||||||
className={
|
|
||||||
selectionMode
|
|
||||||
? "size-7 bg-blue-50 text-blue-600 hover:bg-blue-100 dark:bg-blue-950/40 dark:text-blue-300 dark:hover:bg-blue-950/60"
|
|
||||||
: "size-7"
|
|
||||||
}
|
|
||||||
aria-label={
|
|
||||||
selectionMode
|
|
||||||
? t("sessionManager.exitBatchModeTooltip", {
|
|
||||||
defaultValue: "退出批量管理",
|
|
||||||
})
|
|
||||||
: t("sessionManager.manageBatchTooltip", {
|
|
||||||
defaultValue: "批量管理",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
onClick={() => {
|
|
||||||
if (selectionMode) {
|
|
||||||
exitSelectionMode();
|
|
||||||
} else {
|
|
||||||
setSelectionMode(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CheckSquare className="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
{selectionMode
|
|
||||||
? t("sessionManager.exitBatchModeTooltip", {
|
|
||||||
defaultValue: "退出批量管理",
|
|
||||||
})
|
|
||||||
: t("sessionManager.manageBatchTooltip", {
|
|
||||||
defaultValue: "批量管理",
|
|
||||||
})}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<SelectTrigger className="size-7 p-0 justify-center border-0 bg-transparent hover:bg-muted">
|
||||||
variant="ghost"
|
<ProviderIcon
|
||||||
size="icon"
|
icon={
|
||||||
className="size-7"
|
providerFilter === "all"
|
||||||
onClick={() => {
|
? "apps"
|
||||||
setIsSearchOpen(true);
|
: getProviderIconName(providerFilter)
|
||||||
setTimeout(
|
}
|
||||||
() => searchInputRef.current?.focus(),
|
name={providerFilter}
|
||||||
0,
|
size={14}
|
||||||
);
|
/>
|
||||||
}}
|
</SelectTrigger>
|
||||||
>
|
|
||||||
<Search className="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
{t("sessionManager.searchSessions")}
|
{providerFilter === "all"
|
||||||
|
? t("sessionManager.providerFilterAll")
|
||||||
|
: providerFilter}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ProviderIcon icon="apps" name="all" size={14} />
|
||||||
|
<span>
|
||||||
|
{t("sessionManager.providerFilterAll")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="codex">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ProviderIcon
|
||||||
|
icon="openai"
|
||||||
|
name="codex"
|
||||||
|
size={14}
|
||||||
|
/>
|
||||||
|
<span>Codex</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="claude">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ProviderIcon
|
||||||
|
icon="claude"
|
||||||
|
name="claude"
|
||||||
|
size={14}
|
||||||
|
/>
|
||||||
|
<span>Claude Code</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="opencode">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ProviderIcon
|
||||||
|
icon="opencode"
|
||||||
|
name="opencode"
|
||||||
|
size={14}
|
||||||
|
/>
|
||||||
|
<span>OpenCode</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="openclaw">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ProviderIcon
|
||||||
|
icon="openclaw"
|
||||||
|
name="openclaw"
|
||||||
|
size={14}
|
||||||
|
/>
|
||||||
|
<span>OpenClaw</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="gemini">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ProviderIcon
|
||||||
|
icon="gemini"
|
||||||
|
name="gemini"
|
||||||
|
size={14}
|
||||||
|
/>
|
||||||
|
<span>Gemini CLI</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
<Select
|
<Tooltip>
|
||||||
value={providerFilter}
|
<TooltipTrigger asChild>
|
||||||
onValueChange={(value) =>
|
|
||||||
setProviderFilter(value as ProviderFilter)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<SelectTrigger className="size-7 p-0 justify-center border-0 bg-transparent hover:bg-muted">
|
|
||||||
<ProviderIcon
|
|
||||||
icon={
|
|
||||||
providerFilter === "all"
|
|
||||||
? "apps"
|
|
||||||
: getProviderIconName(providerFilter)
|
|
||||||
}
|
|
||||||
name={providerFilter}
|
|
||||||
size={14}
|
|
||||||
/>
|
|
||||||
</SelectTrigger>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
{providerFilter === "all"
|
|
||||||
? t("sessionManager.providerFilterAll")
|
|
||||||
: providerFilter}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<ProviderIcon
|
|
||||||
icon="apps"
|
|
||||||
name="all"
|
|
||||||
size={14}
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
{t("sessionManager.providerFilterAll")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="codex">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<ProviderIcon
|
|
||||||
icon="openai"
|
|
||||||
name="codex"
|
|
||||||
size={14}
|
|
||||||
/>
|
|
||||||
<span>Codex</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="claude">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<ProviderIcon
|
|
||||||
icon="claude"
|
|
||||||
name="claude"
|
|
||||||
size={14}
|
|
||||||
/>
|
|
||||||
<span>Claude Code</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="opencode">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<ProviderIcon
|
|
||||||
icon="opencode"
|
|
||||||
name="opencode"
|
|
||||||
size={14}
|
|
||||||
/>
|
|
||||||
<span>OpenCode</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="openclaw">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<ProviderIcon
|
|
||||||
icon="openclaw"
|
|
||||||
name="openclaw"
|
|
||||||
size={14}
|
|
||||||
/>
|
|
||||||
<span>OpenClaw</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="gemini">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<ProviderIcon
|
|
||||||
icon="gemini"
|
|
||||||
name="gemini"
|
|
||||||
size={14}
|
|
||||||
/>
|
|
||||||
<span>Gemini CLI</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="size-7"
|
|
||||||
onClick={() => void refetch()}
|
|
||||||
>
|
|
||||||
<RefreshCw className="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>{t("common.refresh")}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{selectionMode && (
|
|
||||||
<div className="grid gap-3 rounded-md border bg-muted/40 px-3 py-2.5">
|
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
{t("sessionManager.selectedCount", {
|
|
||||||
defaultValue: "已选 {{count}} 项",
|
|
||||||
count: selectedDeletableSessions.length,
|
|
||||||
})}
|
|
||||||
</Badge>
|
|
||||||
<span className="truncate">
|
|
||||||
{t("sessionManager.batchModeHint", {
|
|
||||||
defaultValue: "勾选要删除的会话",
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-3 min-[520px]:grid-cols-[minmax(0,1fr)_auto] min-[520px]:items-center">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
{deletableFilteredSessions.length > 0 && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 px-2.5 text-xs whitespace-nowrap"
|
|
||||||
onClick={handleToggleSelectAll}
|
|
||||||
>
|
|
||||||
{allFilteredSelected
|
|
||||||
? t("sessionManager.clearFilteredSelection", {
|
|
||||||
defaultValue: "取消全选",
|
|
||||||
})
|
|
||||||
: t("sessionManager.selectAllFiltered", {
|
|
||||||
defaultValue: "全选当前",
|
|
||||||
})}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 px-2.5 text-xs whitespace-nowrap"
|
|
||||||
onClick={() => setSelectedSessionKeys(new Set())}
|
|
||||||
>
|
|
||||||
{t("sessionManager.clearSelection", {
|
|
||||||
defaultValue: "清空已选",
|
|
||||||
})}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="ghost"
|
||||||
size="sm"
|
size="icon"
|
||||||
className="h-7 gap-1.5 px-2.5 whitespace-nowrap justify-self-start min-[520px]:justify-self-end"
|
className="size-7"
|
||||||
onClick={openBatchDeleteDialog}
|
onClick={() => void refetch()}
|
||||||
disabled={
|
|
||||||
isDeleting ||
|
|
||||||
selectedDeletableSessions.length === 0
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Trash2 className="size-3.5" />
|
<RefreshCw className="size-3.5" />
|
||||||
<span className="text-xs">
|
|
||||||
{isBatchDeleting
|
|
||||||
? t("sessionManager.batchDeleting", {
|
|
||||||
defaultValue: "删除中...",
|
|
||||||
})
|
|
||||||
: t("sessionManager.deleteSelected", {
|
|
||||||
defaultValue: "批量删除",
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</TooltipTrigger>
|
||||||
</div>
|
<TooltipContent>{t("common.refresh")}</TooltipContent>
|
||||||
)}
|
</Tooltip>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -765,15 +416,7 @@ export function SessionManagerPage({ appId }: { appId: string }) {
|
|||||||
key={getSessionKey(session)}
|
key={getSessionKey(session)}
|
||||||
session={session}
|
session={session}
|
||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
selectionMode={selectionMode}
|
|
||||||
isChecked={selectedSessionKeys.has(
|
|
||||||
getSessionKey(session),
|
|
||||||
)}
|
|
||||||
isCheckDisabled={!session.sourcePath}
|
|
||||||
onSelect={setSelectedKey}
|
onSelect={setSelectedKey}
|
||||||
onToggleChecked={(checked) =>
|
|
||||||
toggleSessionChecked(session, checked)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -905,16 +548,15 @@ export function SessionManagerPage({ appId }: { appId: string }) {
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
className="gap-1.5"
|
className="gap-1.5"
|
||||||
onClick={() =>
|
onClick={() => setDeleteTarget(selectedSession)}
|
||||||
setDeleteTargets([selectedSession])
|
|
||||||
}
|
|
||||||
disabled={
|
disabled={
|
||||||
!selectedSession.sourcePath || isDeleting
|
!selectedSession.sourcePath ||
|
||||||
|
deleteSessionMutation.isPending
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Trash2 className="size-3.5" />
|
<Trash2 className="size-3.5" />
|
||||||
<span className="hidden sm:inline">
|
<span className="hidden sm:inline">
|
||||||
{isDeleting
|
{deleteSessionMutation.isPending
|
||||||
? t("sessionManager.deleting", {
|
? t("sessionManager.deleting", {
|
||||||
defaultValue: "删除中...",
|
defaultValue: "删除中...",
|
||||||
})
|
})
|
||||||
@@ -1043,47 +685,29 @@ export function SessionManagerPage({ appId }: { appId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
isOpen={Boolean(deleteTargets)}
|
isOpen={Boolean(deleteTarget)}
|
||||||
title={
|
title={t("sessionManager.deleteConfirmTitle", {
|
||||||
deleteTargets && deleteTargets.length > 1
|
defaultValue: "删除会话",
|
||||||
? t("sessionManager.batchDeleteConfirmTitle", {
|
})}
|
||||||
defaultValue: "批量删除会话",
|
|
||||||
})
|
|
||||||
: t("sessionManager.deleteConfirmTitle", {
|
|
||||||
defaultValue: "删除会话",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
message={
|
message={
|
||||||
deleteTargets && deleteTargets.length > 1
|
deleteTarget
|
||||||
? t("sessionManager.batchDeleteConfirmMessage", {
|
? t("sessionManager.deleteConfirmMessage", {
|
||||||
defaultValue:
|
defaultValue:
|
||||||
"将永久删除已选中的 {{count}} 个本地会话记录。\n\n此操作不可恢复。",
|
"将永久删除本地会话“{{title}}”\nSession ID: {{sessionId}}\n\n此操作不可恢复。",
|
||||||
count: deleteTargets.length,
|
title: formatSessionTitle(deleteTarget),
|
||||||
})
|
sessionId: deleteTarget.sessionId,
|
||||||
: deleteTargets?.[0]
|
|
||||||
? t("sessionManager.deleteConfirmMessage", {
|
|
||||||
defaultValue:
|
|
||||||
"将永久删除本地会话“{{title}}”\nSession ID: {{sessionId}}\n\n此操作不可恢复。",
|
|
||||||
title: formatSessionTitle(deleteTargets[0]),
|
|
||||||
sessionId: deleteTargets[0].sessionId,
|
|
||||||
})
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
confirmText={
|
|
||||||
deleteTargets && deleteTargets.length > 1
|
|
||||||
? t("sessionManager.batchDeleteConfirmAction", {
|
|
||||||
defaultValue: "删除所选会话",
|
|
||||||
})
|
|
||||||
: t("sessionManager.deleteConfirmAction", {
|
|
||||||
defaultValue: "删除会话",
|
|
||||||
})
|
})
|
||||||
|
: ""
|
||||||
}
|
}
|
||||||
|
confirmText={t("sessionManager.deleteConfirmAction", {
|
||||||
|
defaultValue: "删除会话",
|
||||||
|
})}
|
||||||
cancelText={t("common.cancel", { defaultValue: "取消" })}
|
cancelText={t("common.cancel", { defaultValue: "取消" })}
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onConfirm={() => void handleDeleteConfirm()}
|
onConfirm={() => void handleDeleteConfirm()}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
if (!isDeleting) {
|
if (!deleteSessionMutation.isPending) {
|
||||||
setDeleteTargets(null);
|
setDeleteTarget(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -98,20 +98,6 @@ function formatDbCompatVersion(version?: number | null): string | null {
|
|||||||
return typeof version === "number" ? `db-v${version}` : null;
|
return typeof version === "number" ? `db-v${version}` : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPasswordPreservationKey(values: {
|
|
||||||
baseUrl?: string | null;
|
|
||||||
username?: string | null;
|
|
||||||
remoteRoot?: string | null;
|
|
||||||
profile?: string | null;
|
|
||||||
}) {
|
|
||||||
return JSON.stringify({
|
|
||||||
baseUrl: values.baseUrl ?? "",
|
|
||||||
username: values.username ?? "",
|
|
||||||
remoteRoot: values.remoteRoot ?? "cc-switch-sync",
|
|
||||||
profile: values.profile ?? "default",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Types ──────────────────────────────────────────────────
|
// ─── Types ──────────────────────────────────────────────────
|
||||||
|
|
||||||
type ActionState =
|
type ActionState =
|
||||||
@@ -181,10 +167,6 @@ export function WebdavSyncSection({
|
|||||||
const [passwordTouched, setPasswordTouched] = useState(false);
|
const [passwordTouched, setPasswordTouched] = useState(false);
|
||||||
const [justSaved, setJustSaved] = useState(false);
|
const [justSaved, setJustSaved] = useState(false);
|
||||||
const justSavedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const justSavedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const pendingPasswordPreservationRef = useRef<{
|
|
||||||
key: string;
|
|
||||||
password: string;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
// Local form state — credentials are only persisted on explicit "Save".
|
// Local form state — credentials are only persisted on explicit "Save".
|
||||||
const [form, setForm] = useState(() => ({
|
const [form, setForm] = useState(() => ({
|
||||||
@@ -223,36 +205,13 @@ export function WebdavSyncSection({
|
|||||||
// Sync form when config is loaded/updated from backend, but not while user is editing
|
// Sync form when config is loaded/updated from backend, but not while user is editing
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!config || dirty) return;
|
if (!config || dirty) return;
|
||||||
setForm(() => {
|
setForm({
|
||||||
const nextBaseUrl = config.baseUrl ?? "";
|
baseUrl: config.baseUrl ?? "",
|
||||||
const nextUsername = config.username ?? "";
|
username: config.username ?? "",
|
||||||
const nextRemoteRoot = config.remoteRoot ?? "cc-switch-sync";
|
password: config.password ?? "",
|
||||||
const nextProfile = config.profile ?? "default";
|
remoteRoot: config.remoteRoot ?? "cc-switch-sync",
|
||||||
const nextKey = buildPasswordPreservationKey({
|
profile: config.profile ?? "default",
|
||||||
baseUrl: nextBaseUrl,
|
autoSync: config.autoSync ?? false,
|
||||||
username: nextUsername,
|
|
||||||
remoteRoot: nextRemoteRoot,
|
|
||||||
profile: nextProfile,
|
|
||||||
});
|
|
||||||
const shouldPreserveRedactedPassword =
|
|
||||||
!config.password &&
|
|
||||||
pendingPasswordPreservationRef.current?.key === nextKey &&
|
|
||||||
!!pendingPasswordPreservationRef.current.password;
|
|
||||||
|
|
||||||
const nextPassword = shouldPreserveRedactedPassword
|
|
||||||
? pendingPasswordPreservationRef.current!.password
|
|
||||||
: (config.password ?? "");
|
|
||||||
|
|
||||||
pendingPasswordPreservationRef.current = null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
baseUrl: nextBaseUrl,
|
|
||||||
username: nextUsername,
|
|
||||||
password: nextPassword,
|
|
||||||
remoteRoot: nextRemoteRoot,
|
|
||||||
profile: nextProfile,
|
|
||||||
autoSync: config.autoSync ?? false,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
setPasswordTouched(false);
|
setPasswordTouched(false);
|
||||||
setPresetId(detectPreset(config.baseUrl ?? ""));
|
setPresetId(detectPreset(config.baseUrl ?? ""));
|
||||||
@@ -330,13 +289,12 @@ export function WebdavSyncSection({
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
username: form.username.trim(),
|
username: form.username.trim(),
|
||||||
// 未重新触碰密码时,提交空值让后端沿用已保存密码,表单里的值仅用于 UI 显示
|
password: form.password,
|
||||||
password: passwordTouched ? form.password : "",
|
|
||||||
remoteRoot: form.remoteRoot.trim() || "cc-switch-sync",
|
remoteRoot: form.remoteRoot.trim() || "cc-switch-sync",
|
||||||
profile: form.profile.trim() || "default",
|
profile: form.profile.trim() || "default",
|
||||||
autoSync: form.autoSync,
|
autoSync: form.autoSync,
|
||||||
};
|
};
|
||||||
}, [form, passwordTouched]);
|
}, [form]);
|
||||||
|
|
||||||
// ─── Handlers ───────────────────────────────────────────
|
// ─── Handlers ───────────────────────────────────────────
|
||||||
|
|
||||||
@@ -368,12 +326,6 @@ export function WebdavSyncSection({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setActionState("saving");
|
setActionState("saving");
|
||||||
pendingPasswordPreservationRef.current = form.password
|
|
||||||
? {
|
|
||||||
key: buildPasswordPreservationKey(settings),
|
|
||||||
password: form.password,
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
try {
|
try {
|
||||||
await settingsApi.webdavSyncSaveSettings(settings, passwordTouched);
|
await settingsApi.webdavSyncSaveSettings(settings, passwordTouched);
|
||||||
setDirty(false);
|
setDirty(false);
|
||||||
@@ -387,7 +339,6 @@ export function WebdavSyncSection({
|
|||||||
}, 2000);
|
}, 2000);
|
||||||
await queryClient.invalidateQueries();
|
await queryClient.invalidateQueries();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
pendingPasswordPreservationRef.current = null;
|
|
||||||
toast.error(
|
toast.error(
|
||||||
t("settings.webdavSync.saveFailed", {
|
t("settings.webdavSync.saveFailed", {
|
||||||
error: (error as Error)?.message ?? String(error),
|
error: (error as Error)?.message ?? String(error),
|
||||||
@@ -411,7 +362,7 @@ export function WebdavSyncSection({
|
|||||||
} finally {
|
} finally {
|
||||||
setActionState("idle");
|
setActionState("idle");
|
||||||
}
|
}
|
||||||
}, [buildSettings, form.password, passwordTouched, queryClient, t]);
|
}, [buildSettings, passwordTouched, queryClient, t]);
|
||||||
|
|
||||||
/** Fetch remote info, then open upload confirmation dialog. */
|
/** Fetch remote info, then open upload confirmation dialog. */
|
||||||
const handleUploadClick = useCallback(async () => {
|
const handleUploadClick = useCallback(async () => {
|
||||||
|
|||||||
@@ -1343,7 +1343,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
|||||||
|
|
||||||
{
|
{
|
||||||
name: "Oh My OpenCode",
|
name: "Oh My OpenCode",
|
||||||
websiteUrl: "https://github.com/code-yeongyu/oh-my-openagent",
|
websiteUrl: "https://github.com/code-yeongyu/oh-my-opencode",
|
||||||
settingsConfig: {
|
settingsConfig: {
|
||||||
npm: "",
|
npm: "",
|
||||||
options: {},
|
options: {},
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) {
|
|||||||
provider: Omit<Provider, "id"> & {
|
provider: Omit<Provider, "id"> & {
|
||||||
providerKey?: string;
|
providerKey?: string;
|
||||||
suggestedDefaults?: OpenClawSuggestedDefaults;
|
suggestedDefaults?: OpenClawSuggestedDefaults;
|
||||||
addToLive?: boolean;
|
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
await addProviderMutation.mutateAsync(provider);
|
await addProviderMutation.mutateAsync(provider);
|
||||||
@@ -121,8 +120,8 @@ export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) {
|
|||||||
|
|
||||||
// 更新供应商
|
// 更新供应商
|
||||||
const updateProvider = useCallback(
|
const updateProvider = useCallback(
|
||||||
async (provider: Provider, originalId?: string) => {
|
async (provider: Provider) => {
|
||||||
await updateProviderMutation.mutateAsync({ provider, originalId });
|
await updateProviderMutation.mutateAsync(provider);
|
||||||
|
|
||||||
// 更新托盘菜单(失败不影响主操作)
|
// 更新托盘菜单(失败不影响主操作)
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -613,16 +613,6 @@
|
|||||||
"searchSessions": "Search sessions",
|
"searchSessions": "Search sessions",
|
||||||
"providerFilterAll": "All",
|
"providerFilterAll": "All",
|
||||||
"sessionList": "Sessions",
|
"sessionList": "Sessions",
|
||||||
"manageBatchTooltip": "Enter batch management",
|
|
||||||
"exitBatchModeTooltip": "Exit batch management",
|
|
||||||
"batchModeHint": "Select sessions to delete",
|
|
||||||
"selectForBatch": "Select session",
|
|
||||||
"selectedCount": "{{count}} selected",
|
|
||||||
"selectAllFiltered": "Select all",
|
|
||||||
"clearFilteredSelection": "Clear selection",
|
|
||||||
"clearSelection": "Clear",
|
|
||||||
"deleteSelected": "Delete",
|
|
||||||
"batchDeleting": "Deleting...",
|
|
||||||
"loadingSessions": "Loading sessions...",
|
"loadingSessions": "Loading sessions...",
|
||||||
"noSessions": "No sessions found",
|
"noSessions": "No sessions found",
|
||||||
"selectSession": "Select a session to view details",
|
"selectSession": "Select a session to view details",
|
||||||
@@ -651,12 +641,6 @@
|
|||||||
"deleteConfirmAction": "Delete session",
|
"deleteConfirmAction": "Delete session",
|
||||||
"sessionDeleted": "Session deleted",
|
"sessionDeleted": "Session deleted",
|
||||||
"deleteFailed": "Failed to delete session: {{error}}",
|
"deleteFailed": "Failed to delete session: {{error}}",
|
||||||
"batchDeleteConfirmTitle": "Delete selected sessions",
|
|
||||||
"batchDeleteConfirmMessage": "This will permanently delete {{count}} selected local sessions.\n\nThis action cannot be undone.",
|
|
||||||
"batchDeleteConfirmAction": "Delete selected",
|
|
||||||
"batchDeleteSuccess": "Deleted {{count}} sessions",
|
|
||||||
"batchDeleteFailed": "{{failed}} sessions could not be deleted",
|
|
||||||
"batchDeleteRequestFailed": "Batch delete failed. Please try again later.",
|
|
||||||
"loadingMessages": "Loading transcript...",
|
"loadingMessages": "Loading transcript...",
|
||||||
"emptySession": "No messages available",
|
"emptySession": "No messages available",
|
||||||
"clickToCopyPath": "Click to copy path",
|
"clickToCopyPath": "Click to copy path",
|
||||||
@@ -714,7 +698,7 @@
|
|||||||
"aggregatorApiKeyHint": "💡 Only need to fill in API Key, endpoint is preset",
|
"aggregatorApiKeyHint": "💡 Only need to fill in API Key, endpoint is preset",
|
||||||
"thirdPartyApiKeyHint": "💡 Only need to fill in API Key, endpoint is preset",
|
"thirdPartyApiKeyHint": "💡 Only need to fill in API Key, endpoint is preset",
|
||||||
"customApiKeyHint": "💡 Custom configuration requires manually filling all necessary fields",
|
"customApiKeyHint": "💡 Custom configuration requires manually filling all necessary fields",
|
||||||
"omoHint": "💡 OMO config manages Agent model assignments and supports both oh-my-openagent.jsonc and oh-my-opencode.jsonc",
|
"omoHint": "💡 OMO config manages Agent model assignments and writes to oh-my-opencode.jsonc",
|
||||||
"officialHint": "💡 Official provider uses browser login, no API Key needed",
|
"officialHint": "💡 Official provider uses browser login, no API Key needed",
|
||||||
"getApiKey": "Get API Key",
|
"getApiKey": "Get API Key",
|
||||||
"partnerPromotion": {
|
"partnerPromotion": {
|
||||||
@@ -933,8 +917,7 @@
|
|||||||
"modelsRequired": "Please add at least one model",
|
"modelsRequired": "Please add at least one model",
|
||||||
"providerKey": "Provider Key",
|
"providerKey": "Provider Key",
|
||||||
"providerKeyPlaceholder": "my-provider",
|
"providerKeyPlaceholder": "my-provider",
|
||||||
"providerKeyHint": "Unique identifier in config file. Use lowercase letters, numbers, and hyphens only.",
|
"providerKeyHint": "Unique identifier in config file. Cannot be changed after creation. Use lowercase letters, numbers, and hyphens only.",
|
||||||
"providerKeyLockedHint": "This provider has already been added to the app config, so its key can no longer be changed.",
|
|
||||||
"providerKeyRequired": "Provider key is required",
|
"providerKeyRequired": "Provider key is required",
|
||||||
"providerKeyDuplicate": "This key is already in use",
|
"providerKeyDuplicate": "This key is already in use",
|
||||||
"providerKeyInvalid": "Invalid format. Use lowercase letters, numbers, and hyphens only.",
|
"providerKeyInvalid": "Invalid format. Use lowercase letters, numbers, and hyphens only.",
|
||||||
@@ -1393,8 +1376,7 @@
|
|||||||
"backupCreated": "Backup created: {{path}}",
|
"backupCreated": "Backup created: {{path}}",
|
||||||
"providerKey": "Provider Key",
|
"providerKey": "Provider Key",
|
||||||
"providerKeyPlaceholder": "my-provider",
|
"providerKeyPlaceholder": "my-provider",
|
||||||
"providerKeyHint": "Unique identifier in config file. Use lowercase letters, numbers, and hyphens only.",
|
"providerKeyHint": "Unique identifier in config file. Cannot be changed after creation. Use lowercase letters, numbers, and hyphens only.",
|
||||||
"providerKeyLockedHint": "This provider has already been added to the app config, so its key can no longer be changed.",
|
|
||||||
"providerKeyRequired": "Provider key is required",
|
"providerKeyRequired": "Provider key is required",
|
||||||
"providerKeyDuplicate": "This key is already in use",
|
"providerKeyDuplicate": "This key is already in use",
|
||||||
"providerKeyInvalid": "Invalid format. Use lowercase letters, numbers, and hyphens only.",
|
"providerKeyInvalid": "Invalid format. Use lowercase letters, numbers, and hyphens only.",
|
||||||
|
|||||||
@@ -613,16 +613,6 @@
|
|||||||
"searchSessions": "セッションを検索",
|
"searchSessions": "セッションを検索",
|
||||||
"providerFilterAll": "すべて",
|
"providerFilterAll": "すべて",
|
||||||
"sessionList": "セッション一覧",
|
"sessionList": "セッション一覧",
|
||||||
"manageBatchTooltip": "一括管理に入る",
|
|
||||||
"exitBatchModeTooltip": "一括管理を終了",
|
|
||||||
"batchModeHint": "削除するセッションを選択",
|
|
||||||
"selectForBatch": "セッションを選択",
|
|
||||||
"selectedCount": "{{count}} 件を選択中",
|
|
||||||
"selectAllFiltered": "一覧を全選択",
|
|
||||||
"clearFilteredSelection": "全選択を解除",
|
|
||||||
"clearSelection": "クリア",
|
|
||||||
"deleteSelected": "削除",
|
|
||||||
"batchDeleting": "削除中...",
|
|
||||||
"loadingSessions": "セッションを読み込み中...",
|
"loadingSessions": "セッションを読み込み中...",
|
||||||
"noSessions": "セッションが見つかりません",
|
"noSessions": "セッションが見つかりません",
|
||||||
"selectSession": "セッションを選択してください",
|
"selectSession": "セッションを選択してください",
|
||||||
@@ -651,12 +641,6 @@
|
|||||||
"deleteConfirmAction": "セッションを削除",
|
"deleteConfirmAction": "セッションを削除",
|
||||||
"sessionDeleted": "セッションを削除しました",
|
"sessionDeleted": "セッションを削除しました",
|
||||||
"deleteFailed": "セッションの削除に失敗しました: {{error}}",
|
"deleteFailed": "セッションの削除に失敗しました: {{error}}",
|
||||||
"batchDeleteConfirmTitle": "選択したセッションを削除",
|
|
||||||
"batchDeleteConfirmMessage": "選択した {{count}} 件のローカルセッションを完全に削除します。\n\nこの操作は元に戻せません。",
|
|
||||||
"batchDeleteConfirmAction": "選択した項目を削除",
|
|
||||||
"batchDeleteSuccess": "{{count}} 件のセッションを削除しました",
|
|
||||||
"batchDeleteFailed": "{{failed}} 件のセッションを削除できませんでした",
|
|
||||||
"batchDeleteRequestFailed": "一括削除に失敗しました。しばらくしてから再試行してください。",
|
|
||||||
"loadingMessages": "内容を読み込み中...",
|
"loadingMessages": "内容を読み込み中...",
|
||||||
"emptySession": "表示できる内容がありません",
|
"emptySession": "表示できる内容がありません",
|
||||||
"clickToCopyPath": "クリックしてパスをコピー",
|
"clickToCopyPath": "クリックしてパスをコピー",
|
||||||
@@ -714,7 +698,7 @@
|
|||||||
"aggregatorApiKeyHint": "💡 API Key のみ入力すれば OK。エンドポイントはプリセット済みです",
|
"aggregatorApiKeyHint": "💡 API Key のみ入力すれば OK。エンドポイントはプリセット済みです",
|
||||||
"thirdPartyApiKeyHint": "💡 API Key のみ入力すれば OK。エンドポイントはプリセット済みです",
|
"thirdPartyApiKeyHint": "💡 API Key のみ入力すれば OK。エンドポイントはプリセット済みです",
|
||||||
"customApiKeyHint": "💡 カスタム設定では必要な項目をすべて手動で入力してください",
|
"customApiKeyHint": "💡 カスタム設定では必要な項目をすべて手動で入力してください",
|
||||||
"omoHint": "💡 OMO 設定は Agent のモデル割り当てを管理し、oh-my-openagent.jsonc / oh-my-opencode.jsonc の両方に対応します",
|
"omoHint": "💡 OMO 設定は Agent のモデル割り当てを管理し、oh-my-opencode.jsonc に書き込みます",
|
||||||
"officialHint": "💡 公式プロバイダーはブラウザログインで、API Key は不要です",
|
"officialHint": "💡 公式プロバイダーはブラウザログインで、API Key は不要です",
|
||||||
"getApiKey": "API Key を取得",
|
"getApiKey": "API Key を取得",
|
||||||
"partnerPromotion": {
|
"partnerPromotion": {
|
||||||
@@ -933,8 +917,7 @@
|
|||||||
"modelsRequired": "モデルを少なくとも1つ追加してください",
|
"modelsRequired": "モデルを少なくとも1つ追加してください",
|
||||||
"providerKey": "プロバイダーキー",
|
"providerKey": "プロバイダーキー",
|
||||||
"providerKeyPlaceholder": "my-provider",
|
"providerKeyPlaceholder": "my-provider",
|
||||||
"providerKeyHint": "設定ファイルの一意の識別子です。小文字、数字、ハイフンのみ使用できます。",
|
"providerKeyHint": "設定ファイルの一意の識別子。作成後は変更できません。小文字、数字、ハイフンのみ使用できます。",
|
||||||
"providerKeyLockedHint": "このプロバイダーは既にアプリ設定へ追加されているため、キーは変更できません。",
|
|
||||||
"providerKeyRequired": "プロバイダーキーを入力してください",
|
"providerKeyRequired": "プロバイダーキーを入力してください",
|
||||||
"providerKeyDuplicate": "このキーは既に使用されています",
|
"providerKeyDuplicate": "このキーは既に使用されています",
|
||||||
"providerKeyInvalid": "無効な形式です。小文字、数字、ハイフンのみ使用できます。",
|
"providerKeyInvalid": "無効な形式です。小文字、数字、ハイフンのみ使用できます。",
|
||||||
@@ -1393,8 +1376,7 @@
|
|||||||
"backupCreated": "バックアップを作成しました: {{path}}",
|
"backupCreated": "バックアップを作成しました: {{path}}",
|
||||||
"providerKey": "プロバイダーキー",
|
"providerKey": "プロバイダーキー",
|
||||||
"providerKeyPlaceholder": "my-provider",
|
"providerKeyPlaceholder": "my-provider",
|
||||||
"providerKeyHint": "設定ファイル内のユニーク識別子。小文字、数字、ハイフンのみ使用可能。",
|
"providerKeyHint": "設定ファイル内のユニーク識別子。作成後は変更できません。小文字、数字、ハイフンのみ使用可能。",
|
||||||
"providerKeyLockedHint": "このプロバイダーは既にアプリ設定へ追加されているため、キーは変更できません。",
|
|
||||||
"providerKeyRequired": "プロバイダーキーを入力してください",
|
"providerKeyRequired": "プロバイダーキーを入力してください",
|
||||||
"providerKeyDuplicate": "このキーは既に使用されています",
|
"providerKeyDuplicate": "このキーは既に使用されています",
|
||||||
"providerKeyInvalid": "無効な形式です。小文字、数字、ハイフンのみ使用可能。",
|
"providerKeyInvalid": "無効な形式です。小文字、数字、ハイフンのみ使用可能。",
|
||||||
|
|||||||
@@ -613,16 +613,6 @@
|
|||||||
"searchSessions": "搜索会话",
|
"searchSessions": "搜索会话",
|
||||||
"providerFilterAll": "全部",
|
"providerFilterAll": "全部",
|
||||||
"sessionList": "会话列表",
|
"sessionList": "会话列表",
|
||||||
"manageBatchTooltip": "进入批量管理",
|
|
||||||
"exitBatchModeTooltip": "退出批量管理",
|
|
||||||
"batchModeHint": "勾选要删除的会话",
|
|
||||||
"selectForBatch": "选择会话",
|
|
||||||
"selectedCount": "已选 {{count}} 项",
|
|
||||||
"selectAllFiltered": "全选当前",
|
|
||||||
"clearFilteredSelection": "取消全选",
|
|
||||||
"clearSelection": "清空已选",
|
|
||||||
"deleteSelected": "批量删除",
|
|
||||||
"batchDeleting": "删除中...",
|
|
||||||
"loadingSessions": "加载会话中...",
|
"loadingSessions": "加载会话中...",
|
||||||
"noSessions": "未发现会话",
|
"noSessions": "未发现会话",
|
||||||
"selectSession": "请选择会话查看详情",
|
"selectSession": "请选择会话查看详情",
|
||||||
@@ -651,12 +641,6 @@
|
|||||||
"deleteConfirmAction": "删除会话",
|
"deleteConfirmAction": "删除会话",
|
||||||
"sessionDeleted": "会话已删除",
|
"sessionDeleted": "会话已删除",
|
||||||
"deleteFailed": "删除会话失败: {{error}}",
|
"deleteFailed": "删除会话失败: {{error}}",
|
||||||
"batchDeleteConfirmTitle": "批量删除会话",
|
|
||||||
"batchDeleteConfirmMessage": "将永久删除已选中的 {{count}} 个本地会话记录。\n\n此操作不可恢复。",
|
|
||||||
"batchDeleteConfirmAction": "删除所选会话",
|
|
||||||
"batchDeleteSuccess": "已删除 {{count}} 个会话",
|
|
||||||
"batchDeleteFailed": "{{failed}} 个会话删除失败",
|
|
||||||
"batchDeleteRequestFailed": "批量删除失败,请稍后重试",
|
|
||||||
"loadingMessages": "加载会话内容中...",
|
"loadingMessages": "加载会话内容中...",
|
||||||
"emptySession": "该会话暂无可展示内容",
|
"emptySession": "该会话暂无可展示内容",
|
||||||
"clickToCopyPath": "点击复制路径",
|
"clickToCopyPath": "点击复制路径",
|
||||||
@@ -714,7 +698,7 @@
|
|||||||
"aggregatorApiKeyHint": "💡 只需填写 API Key,请求地址已预设",
|
"aggregatorApiKeyHint": "💡 只需填写 API Key,请求地址已预设",
|
||||||
"thirdPartyApiKeyHint": "💡 只需填写 API Key,请求地址已预设",
|
"thirdPartyApiKeyHint": "💡 只需填写 API Key,请求地址已预设",
|
||||||
"customApiKeyHint": "💡 自定义配置需手动填写所有必要字段",
|
"customApiKeyHint": "💡 自定义配置需手动填写所有必要字段",
|
||||||
"omoHint": "💡 OMO 配置管理 Agent 模型分配,兼容 oh-my-openagent.jsonc / oh-my-opencode.jsonc",
|
"omoHint": "💡 OMO 配置管理 Agent 模型分配,写入 oh-my-opencode.jsonc",
|
||||||
"officialHint": "💡 官方供应商使用浏览器登录,无需配置 API Key",
|
"officialHint": "💡 官方供应商使用浏览器登录,无需配置 API Key",
|
||||||
"getApiKey": "获取 API Key",
|
"getApiKey": "获取 API Key",
|
||||||
"partnerPromotion": {
|
"partnerPromotion": {
|
||||||
@@ -933,8 +917,7 @@
|
|||||||
"modelsRequired": "请至少添加一个模型配置",
|
"modelsRequired": "请至少添加一个模型配置",
|
||||||
"providerKey": "供应商标识",
|
"providerKey": "供应商标识",
|
||||||
"providerKeyPlaceholder": "my-provider",
|
"providerKeyPlaceholder": "my-provider",
|
||||||
"providerKeyHint": "配置文件中的唯一标识符,只能使用小写字母、数字和连字符",
|
"providerKeyHint": "配置文件中的唯一标识符,创建后无法修改,只能使用小写字母、数字和连字符",
|
||||||
"providerKeyLockedHint": "该供应商已添加到应用配置中,供应商标识不可修改",
|
|
||||||
"providerKeyRequired": "请填写供应商标识",
|
"providerKeyRequired": "请填写供应商标识",
|
||||||
"providerKeyDuplicate": "此标识已被使用,请更换",
|
"providerKeyDuplicate": "此标识已被使用,请更换",
|
||||||
"providerKeyInvalid": "标识格式无效,只能使用小写字母、数字和连字符",
|
"providerKeyInvalid": "标识格式无效,只能使用小写字母、数字和连字符",
|
||||||
@@ -1393,8 +1376,7 @@
|
|||||||
"backupCreated": "已创建备份:{{path}}",
|
"backupCreated": "已创建备份:{{path}}",
|
||||||
"providerKey": "供应商标识",
|
"providerKey": "供应商标识",
|
||||||
"providerKeyPlaceholder": "my-provider",
|
"providerKeyPlaceholder": "my-provider",
|
||||||
"providerKeyHint": "配置文件中的唯一标识符,只能使用小写字母、数字和连字符",
|
"providerKeyHint": "配置文件中的唯一标识符,创建后无法修改,只能使用小写字母、数字和连字符",
|
||||||
"providerKeyLockedHint": "该供应商已添加到应用配置中,供应商标识不可修改",
|
|
||||||
"providerKeyRequired": "请填写供应商标识",
|
"providerKeyRequired": "请填写供应商标识",
|
||||||
"providerKeyDuplicate": "此标识已被使用,请更换",
|
"providerKeyDuplicate": "此标识已被使用,请更换",
|
||||||
"providerKeyInvalid": "标识格式无效,只能使用小写字母、数字和连字符",
|
"providerKeyInvalid": "标识格式无效,只能使用小写字母、数字和连字符",
|
||||||
|
|||||||
@@ -21,10 +21,6 @@ export interface SwitchResult {
|
|||||||
warnings: string[];
|
warnings: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OpenTerminalOptions {
|
|
||||||
cwd?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const providersApi = {
|
export const providersApi = {
|
||||||
async getAll(appId: AppId): Promise<Record<string, Provider>> {
|
async getAll(appId: AppId): Promise<Record<string, Provider>> {
|
||||||
return await invoke("get_providers", { app: appId });
|
return await invoke("get_providers", { app: appId });
|
||||||
@@ -34,24 +30,12 @@ export const providersApi = {
|
|||||||
return await invoke("get_current_provider", { app: appId });
|
return await invoke("get_current_provider", { app: appId });
|
||||||
},
|
},
|
||||||
|
|
||||||
async add(
|
async add(provider: Provider, appId: AppId): Promise<boolean> {
|
||||||
provider: Provider,
|
return await invoke("add_provider", { provider, app: appId });
|
||||||
appId: AppId,
|
|
||||||
addToLive?: boolean,
|
|
||||||
): Promise<boolean> {
|
|
||||||
return await invoke("add_provider", { provider, app: appId, addToLive });
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async update(
|
async update(provider: Provider, appId: AppId): Promise<boolean> {
|
||||||
provider: Provider,
|
return await invoke("update_provider", { provider, app: appId });
|
||||||
appId: AppId,
|
|
||||||
originalId?: string,
|
|
||||||
): Promise<boolean> {
|
|
||||||
return await invoke("update_provider", {
|
|
||||||
provider,
|
|
||||||
app: appId,
|
|
||||||
originalId,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async delete(id: string, appId: AppId): Promise<boolean> {
|
async delete(id: string, appId: AppId): Promise<boolean> {
|
||||||
@@ -99,17 +83,8 @@ export const providersApi = {
|
|||||||
* 任何提供商都可以打开终端,不受是否为当前激活提供商的限制
|
* 任何提供商都可以打开终端,不受是否为当前激活提供商的限制
|
||||||
* 终端会使用该提供商特定的 API 配置,不影响全局设置
|
* 终端会使用该提供商特定的 API 配置,不影响全局设置
|
||||||
*/
|
*/
|
||||||
async openTerminal(
|
async openTerminal(providerId: string, appId: AppId): Promise<boolean> {
|
||||||
providerId: string,
|
return await invoke("open_provider_terminal", { providerId, app: appId });
|
||||||
appId: AppId,
|
|
||||||
options?: OpenTerminalOptions,
|
|
||||||
): Promise<boolean> {
|
|
||||||
const { cwd } = options ?? {};
|
|
||||||
return await invoke("open_provider_terminal", {
|
|
||||||
providerId,
|
|
||||||
app: appId,
|
|
||||||
cwd,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -7,11 +7,6 @@ export interface DeleteSessionOptions {
|
|||||||
sourcePath: string;
|
sourcePath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeleteSessionResult extends DeleteSessionOptions {
|
|
||||||
success: boolean;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const sessionsApi = {
|
export const sessionsApi = {
|
||||||
async list(): Promise<SessionMeta[]> {
|
async list(): Promise<SessionMeta[]> {
|
||||||
return await invoke("list_sessions");
|
return await invoke("list_sessions");
|
||||||
@@ -33,12 +28,6 @@ export const sessionsApi = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async deleteMany(
|
|
||||||
items: DeleteSessionOptions[],
|
|
||||||
): Promise<DeleteSessionResult[]> {
|
|
||||||
return await invoke("delete_sessions", { items });
|
|
||||||
},
|
|
||||||
|
|
||||||
async launchTerminal(options: {
|
async launchTerminal(options: {
|
||||||
command: string;
|
command: string;
|
||||||
cwd?: string | null;
|
cwd?: string | null;
|
||||||
|
|||||||
@@ -47,10 +47,6 @@ export const settingsApi = {
|
|||||||
await invoke("open_config_folder", { app: appId });
|
await invoke("open_config_folder", { app: appId });
|
||||||
},
|
},
|
||||||
|
|
||||||
async pickDirectory(defaultPath?: string): Promise<string | null> {
|
|
||||||
return await invoke("pick_directory", { defaultPath });
|
|
||||||
},
|
|
||||||
|
|
||||||
async selectConfigDirectory(defaultPath?: string): Promise<string | null> {
|
async selectConfigDirectory(defaultPath?: string): Promise<string | null> {
|
||||||
return await invoke("pick_directory", { defaultPath });
|
return await invoke("pick_directory", { defaultPath });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,10 +15,7 @@ export const useAddProviderMutation = (appId: AppId) => {
|
|||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (
|
mutationFn: async (
|
||||||
providerInput: Omit<Provider, "id"> & {
|
providerInput: Omit<Provider, "id"> & { providerKey?: string },
|
||||||
providerKey?: string;
|
|
||||||
addToLive?: boolean;
|
|
||||||
},
|
|
||||||
) => {
|
) => {
|
||||||
let id: string;
|
let id: string;
|
||||||
|
|
||||||
@@ -39,7 +36,7 @@ export const useAddProviderMutation = (appId: AppId) => {
|
|||||||
id = generateUUID();
|
id = generateUUID();
|
||||||
}
|
}
|
||||||
|
|
||||||
const { providerKey: _providerKey, addToLive, ...rest } = providerInput;
|
const { providerKey: _providerKey, ...rest } = providerInput;
|
||||||
|
|
||||||
const newProvider: Provider = {
|
const newProvider: Provider = {
|
||||||
...rest,
|
...rest,
|
||||||
@@ -48,7 +45,7 @@ export const useAddProviderMutation = (appId: AppId) => {
|
|||||||
};
|
};
|
||||||
delete (newProvider as any).providerKey;
|
delete (newProvider as any).providerKey;
|
||||||
|
|
||||||
await providersApi.add(newProvider, appId, addToLive);
|
await providersApi.add(newProvider, appId);
|
||||||
return newProvider;
|
return newProvider;
|
||||||
},
|
},
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
@@ -110,14 +107,8 @@ export const useUpdateProviderMutation = (appId: AppId) => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async ({
|
mutationFn: async (provider: Provider) => {
|
||||||
provider,
|
await providersApi.update(provider, appId);
|
||||||
originalId,
|
|
||||||
}: {
|
|
||||||
provider: Provider;
|
|
||||||
originalId?: string;
|
|
||||||
}) => {
|
|
||||||
await providersApi.update(provider, appId, originalId);
|
|
||||||
return provider;
|
return provider;
|
||||||
},
|
},
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
|
|||||||
+1
-1
@@ -246,7 +246,7 @@ export const OMO_DISABLEABLE_SKILLS = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const OMO_DEFAULT_SCHEMA_URL =
|
export const OMO_DEFAULT_SCHEMA_URL =
|
||||||
"https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json";
|
"https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json";
|
||||||
|
|
||||||
export const OMO_SISYPHUS_AGENT_PLACEHOLDER = `{
|
export const OMO_SISYPHUS_AGENT_PLACEHOLDER = `{
|
||||||
"disabled": false,
|
"disabled": false,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
act,
|
|
||||||
fireEvent,
|
fireEvent,
|
||||||
render,
|
render,
|
||||||
screen,
|
screen,
|
||||||
@@ -9,7 +8,6 @@ import {
|
|||||||
} from "@testing-library/react";
|
} from "@testing-library/react";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { SessionManagerPage } from "@/components/sessions/SessionManagerPage";
|
import { SessionManagerPage } from "@/components/sessions/SessionManagerPage";
|
||||||
import { sessionsApi } from "@/lib/api/sessions";
|
|
||||||
import type { SessionMessage, SessionMeta } from "@/types";
|
import type { SessionMessage, SessionMeta } from "@/types";
|
||||||
import { setSessionFixtures } from "../msw/state";
|
import { setSessionFixtures } from "../msw/state";
|
||||||
|
|
||||||
@@ -64,19 +62,16 @@ const renderPage = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return render(
|
||||||
client,
|
<QueryClientProvider client={client}>
|
||||||
...render(
|
<SessionManagerPage appId="codex" />
|
||||||
<QueryClientProvider client={client}>
|
</QueryClientProvider>,
|
||||||
<SessionManagerPage appId="codex" />
|
);
|
||||||
</QueryClientProvider>,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const openSearch = () => {
|
const openSearch = () => {
|
||||||
const searchButton = Array.from(screen.getAllByRole("button")).find(
|
const searchButton = Array.from(screen.getAllByRole("button")).find((button) =>
|
||||||
(button) => button.querySelector(".lucide-search"),
|
button.querySelector(".lucide-search"),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!searchButton) {
|
if (!searchButton) {
|
||||||
@@ -86,23 +81,10 @@ const openSearch = () => {
|
|||||||
fireEvent.click(searchButton);
|
fireEvent.click(searchButton);
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeSearch = () => {
|
|
||||||
const closeButton = Array.from(screen.getAllByRole("button")).find(
|
|
||||||
(button) => button.querySelector(".lucide-x"),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!closeButton) {
|
|
||||||
throw new Error("Search close button not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
fireEvent.click(closeButton);
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("SessionManagerPage", () => {
|
describe("SessionManagerPage", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
toastSuccessMock.mockReset();
|
toastSuccessMock.mockReset();
|
||||||
toastErrorMock.mockReset();
|
toastErrorMock.mockReset();
|
||||||
Element.prototype.scrollIntoView = vi.fn();
|
|
||||||
|
|
||||||
const sessions: SessionMeta[] = [
|
const sessions: SessionMeta[] = [
|
||||||
{
|
{
|
||||||
@@ -196,136 +178,11 @@ describe("SessionManagerPage", () => {
|
|||||||
expect(screen.queryByText("Alpha Session")).not.toBeInTheDocument(),
|
expect(screen.queryByText("Alpha Session")).not.toBeInTheDocument(),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(
|
expect(screen.getByText("sessionManager.selectSession")).toBeInTheDocument();
|
||||||
screen.getByText("sessionManager.selectSession"),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
expect(
|
expect(
|
||||||
screen.queryByText("sessionManager.emptySession"),
|
screen.queryByText("sessionManager.emptySession"),
|
||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
expect(toastErrorMock).not.toHaveBeenCalled();
|
expect(toastErrorMock).not.toHaveBeenCalled();
|
||||||
expect(toastSuccessMock).toHaveBeenCalled();
|
expect(toastSuccessMock).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("restores batch delete controls when deleteMany rejects", async () => {
|
|
||||||
const deleteManySpy = vi
|
|
||||||
.spyOn(sessionsApi, "deleteMany")
|
|
||||||
.mockRejectedValueOnce(new Error("network error"));
|
|
||||||
|
|
||||||
renderPage();
|
|
||||||
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(
|
|
||||||
screen.getByRole("heading", { name: "Alpha Session" }),
|
|
||||||
).toBeInTheDocument(),
|
|
||||||
);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /批量管理/i }));
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /全选当前/i }));
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /批量删除/i }));
|
|
||||||
|
|
||||||
const dialog = screen.getByTestId("confirm-dialog");
|
|
||||||
fireEvent.click(
|
|
||||||
within(dialog).getByRole("button", { name: /删除所选会话/i }),
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(toastErrorMock).toHaveBeenCalledWith("network error"),
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(
|
|
||||||
screen.getByRole("button", { name: /批量删除/i }),
|
|
||||||
).not.toBeDisabled(),
|
|
||||||
);
|
|
||||||
|
|
||||||
deleteManySpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the exit batch mode button visible when search hides all sessions", async () => {
|
|
||||||
renderPage();
|
|
||||||
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(
|
|
||||||
screen.getByRole("heading", { name: "Alpha Session" }),
|
|
||||||
).toBeInTheDocument(),
|
|
||||||
);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /批量管理/i }));
|
|
||||||
openSearch();
|
|
||||||
fireEvent.change(screen.getByRole("textbox"), {
|
|
||||||
target: { value: "NoSuchSession" },
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => expect(screen.queryByText("Alpha Session")).toBeNull());
|
|
||||||
|
|
||||||
expect(screen.getByRole("button", { name: /退出批量管理/i })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("drops hidden selections when search narrows the result set", async () => {
|
|
||||||
renderPage();
|
|
||||||
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(
|
|
||||||
screen.getByRole("heading", { name: "Alpha Session" }),
|
|
||||||
).toBeInTheDocument(),
|
|
||||||
);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /批量管理/i }));
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /全选当前/i }));
|
|
||||||
|
|
||||||
expect(screen.getByText("已选 2 项")).toBeInTheDocument();
|
|
||||||
|
|
||||||
openSearch();
|
|
||||||
fireEvent.change(screen.getByRole("textbox"), {
|
|
||||||
target: { value: "Alpha" },
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(screen.queryByText("Beta Session")).not.toBeInTheDocument(),
|
|
||||||
);
|
|
||||||
|
|
||||||
closeSearch();
|
|
||||||
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(screen.getByText("已选 1 项")).toBeInTheDocument(),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("removes successfully deleted sessions from the UI before refetch completes", async () => {
|
|
||||||
const view = renderPage();
|
|
||||||
let resolveInvalidate!: () => void;
|
|
||||||
const invalidateSpy = vi
|
|
||||||
.spyOn(view.client, "invalidateQueries")
|
|
||||||
.mockImplementation(
|
|
||||||
() =>
|
|
||||||
new Promise((resolve) => {
|
|
||||||
resolveInvalidate = () => resolve(undefined);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(
|
|
||||||
screen.getByRole("heading", { name: "Alpha Session" }),
|
|
||||||
).toBeInTheDocument(),
|
|
||||||
);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /批量管理/i }));
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /全选当前/i }));
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /批量删除/i }));
|
|
||||||
|
|
||||||
const dialog = screen.getByTestId("confirm-dialog");
|
|
||||||
fireEvent.click(
|
|
||||||
within(dialog).getByRole("button", { name: /删除所选会话/i }),
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.queryByText("Alpha Session")).not.toBeInTheDocument();
|
|
||||||
expect(screen.queryByText("Beta Session")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
resolveInvalidate();
|
|
||||||
});
|
|
||||||
invalidateSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -104,12 +104,11 @@ function renderSection(config?: WebDavSyncSettings) {
|
|||||||
mutations: { retry: false },
|
mutations: { retry: false },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const view = render(
|
return render(
|
||||||
<QueryClientProvider client={client}>
|
<QueryClientProvider client={client}>
|
||||||
<WebdavSyncSection config={config} />
|
<WebdavSyncSection config={config} />
|
||||||
</QueryClientProvider>,
|
</QueryClientProvider>,
|
||||||
);
|
);
|
||||||
return { ...view, client };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("WebdavSyncSection", () => {
|
describe("WebdavSyncSection", () => {
|
||||||
@@ -205,7 +204,7 @@ describe("WebdavSyncSection", () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
baseUrl: "https://dav.example.com/dav/",
|
baseUrl: "https://dav.example.com/dav/",
|
||||||
username: "alice",
|
username: "alice",
|
||||||
password: "",
|
password: "secret",
|
||||||
autoSync: false,
|
autoSync: false,
|
||||||
}),
|
}),
|
||||||
false,
|
false,
|
||||||
@@ -223,111 +222,6 @@ describe("WebdavSyncSection", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves password only for the single post-save refresh", async () => {
|
|
||||||
const view = renderSection(baseConfig);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "settings.webdavSync.save" }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(settingsApiMock.webdavSyncSaveSettings).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
view.rerender(
|
|
||||||
<QueryClientProvider client={view.client}>
|
|
||||||
<WebdavSyncSection config={{ ...baseConfig, password: "" }} />
|
|
||||||
</QueryClientProvider>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
(
|
|
||||||
screen.getByPlaceholderText(
|
|
||||||
"settings.webdavSync.passwordPlaceholder",
|
|
||||||
) as HTMLInputElement
|
|
||||||
).value,
|
|
||||||
).toBe("secret");
|
|
||||||
|
|
||||||
view.rerender(
|
|
||||||
<QueryClientProvider client={view.client}>
|
|
||||||
<WebdavSyncSection config={{ ...baseConfig, password: "" }} />
|
|
||||||
</QueryClientProvider>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
(
|
|
||||||
screen.getByPlaceholderText(
|
|
||||||
"settings.webdavSync.passwordPlaceholder",
|
|
||||||
) as HTMLInputElement
|
|
||||||
).value,
|
|
||||||
).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not preserve password after a later external config refresh", async () => {
|
|
||||||
const view = renderSection(baseConfig);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "settings.webdavSync.save" }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(settingsApiMock.webdavSyncSaveSettings).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
view.rerender(
|
|
||||||
<QueryClientProvider client={view.client}>
|
|
||||||
<WebdavSyncSection config={{ ...baseConfig, password: "" }} />
|
|
||||||
</QueryClientProvider>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
(
|
|
||||||
screen.getByPlaceholderText(
|
|
||||||
"settings.webdavSync.passwordPlaceholder",
|
|
||||||
) as HTMLInputElement
|
|
||||||
).value,
|
|
||||||
).toBe("secret");
|
|
||||||
|
|
||||||
view.rerender(
|
|
||||||
<QueryClientProvider client={view.client}>
|
|
||||||
<WebdavSyncSection
|
|
||||||
config={{ ...baseConfig, username: "bob", password: "" }}
|
|
||||||
/>
|
|
||||||
</QueryClientProvider>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
(
|
|
||||||
screen.getByPlaceholderText(
|
|
||||||
"settings.webdavSync.passwordPlaceholder",
|
|
||||||
) as HTMLInputElement
|
|
||||||
).value,
|
|
||||||
).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not submit a preserved password again when testing without touching it", async () => {
|
|
||||||
const view = renderSection(baseConfig);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "settings.webdavSync.save" }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(settingsApiMock.webdavSyncSaveSettings).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
view.rerender(
|
|
||||||
<QueryClientProvider client={view.client}>
|
|
||||||
<WebdavSyncSection config={{ ...baseConfig, password: "" }} />
|
|
||||||
</QueryClientProvider>,
|
|
||||||
);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "settings.webdavSync.test" }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(settingsApiMock.webdavTestConnection).toHaveBeenLastCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
password: "",
|
|
||||||
}),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("saves auto sync as true after toggle", async () => {
|
it("saves auto sync as true after toggle", async () => {
|
||||||
renderSection(baseConfig);
|
renderSection(baseConfig);
|
||||||
|
|
||||||
|
|||||||
@@ -169,10 +169,7 @@ describe("useProviderActions", () => {
|
|||||||
await result.current.updateProvider(provider);
|
await result.current.updateProvider(provider);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(updateProviderMutateAsync).toHaveBeenCalledWith({
|
expect(updateProviderMutateAsync).toHaveBeenCalledWith(provider);
|
||||||
provider,
|
|
||||||
originalId: undefined,
|
|
||||||
});
|
|
||||||
expect(providersApiUpdateTrayMenuMock).toHaveBeenCalledTimes(1);
|
expect(providersApiUpdateTrayMenuMock).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,7 @@ import { Suspense, type ComponentType } from "react";
|
|||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
import { providersApi } from "@/lib/api/providers";
|
import { resetProviderState } from "../msw/state";
|
||||||
import {
|
|
||||||
resetProviderState,
|
|
||||||
setCurrentProviderId,
|
|
||||||
setLiveProviderIds,
|
|
||||||
setProviders,
|
|
||||||
} from "../msw/state";
|
|
||||||
import { emitTauriEvent } from "../msw/tauriMocks";
|
import { emitTauriEvent } from "../msw/tauriMocks";
|
||||||
|
|
||||||
const toastSuccessMock = vi.fn();
|
const toastSuccessMock = vi.fn();
|
||||||
@@ -81,11 +75,8 @@ vi.mock("@/components/providers/EditProviderDialog", () => ({
|
|||||||
<button
|
<button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
onSubmit({
|
onSubmit({
|
||||||
provider: {
|
...provider,
|
||||||
...provider,
|
name: `${provider.name}-edited`,
|
||||||
name: `${provider.name}-edited`,
|
|
||||||
},
|
|
||||||
originalId: provider.id,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -123,7 +114,6 @@ vi.mock("@/components/AppSwitcher", () => ({
|
|||||||
<span>{activeApp}</span>
|
<span>{activeApp}</span>
|
||||||
<button onClick={() => onSwitch("claude")}>switch-claude</button>
|
<button onClick={() => onSwitch("claude")}>switch-claude</button>
|
||||||
<button onClick={() => onSwitch("codex")}>switch-codex</button>
|
<button onClick={() => onSwitch("codex")}>switch-codex</button>
|
||||||
<button onClick={() => onSwitch("openclaw")}>switch-openclaw</button>
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
@@ -240,95 +230,4 @@ describe("App integration with MSW", () => {
|
|||||||
expect(toastErrorMock).toHaveBeenCalled();
|
expect(toastErrorMock).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("duplicates openclaw providers with a generated key that avoids live-only ids", async () => {
|
|
||||||
setProviders("openclaw", {
|
|
||||||
deepseek: {
|
|
||||||
id: "deepseek",
|
|
||||||
name: "DeepSeek",
|
|
||||||
settingsConfig: {
|
|
||||||
baseUrl: "https://api.deepseek.com",
|
|
||||||
apiKey: "test-key",
|
|
||||||
api: "openai-completions",
|
|
||||||
models: [],
|
|
||||||
},
|
|
||||||
category: "custom",
|
|
||||||
sortIndex: 0,
|
|
||||||
createdAt: Date.now(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
setCurrentProviderId("openclaw", "deepseek");
|
|
||||||
setLiveProviderIds("openclaw", ["deepseek-copy"]);
|
|
||||||
|
|
||||||
const { default: App } = await import("@/App");
|
|
||||||
renderApp(App);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByText("switch-openclaw"));
|
|
||||||
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(screen.getByTestId("provider-list").textContent).toContain(
|
|
||||||
"deepseek",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByText("duplicate"));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
const providerList = screen.getByTestId("provider-list").textContent;
|
|
||||||
expect(providerList).toContain("deepseek-copy-2");
|
|
||||||
expect(providerList).toContain("DeepSeek copy");
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(toastErrorMock).not.toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining("Provider key is required for openclaw"),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows toast when duplicate cannot load live provider ids", async () => {
|
|
||||||
setProviders("openclaw", {
|
|
||||||
deepseek: {
|
|
||||||
id: "deepseek",
|
|
||||||
name: "DeepSeek",
|
|
||||||
settingsConfig: {
|
|
||||||
baseUrl: "https://api.deepseek.com",
|
|
||||||
apiKey: "test-key",
|
|
||||||
api: "openai-completions",
|
|
||||||
models: [],
|
|
||||||
},
|
|
||||||
category: "custom",
|
|
||||||
sortIndex: 0,
|
|
||||||
createdAt: Date.now(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
setCurrentProviderId("openclaw", "deepseek");
|
|
||||||
|
|
||||||
const liveIdsSpy = vi
|
|
||||||
.spyOn(providersApi, "getOpenClawLiveProviderIds")
|
|
||||||
.mockRejectedValueOnce(new Error("broken config"));
|
|
||||||
|
|
||||||
const { default: App } = await import("@/App");
|
|
||||||
renderApp(App);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByText("switch-openclaw"));
|
|
||||||
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(screen.getByTestId("provider-list").textContent).toContain(
|
|
||||||
"deepseek",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByText("duplicate"));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(toastErrorMock).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining("读取配置中的供应商标识失败"),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getByTestId("provider-list").textContent).not.toContain(
|
|
||||||
"deepseek-copy",
|
|
||||||
);
|
|
||||||
|
|
||||||
liveIdsSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
deleteProvider,
|
deleteProvider,
|
||||||
deleteSession,
|
deleteSession,
|
||||||
getCurrentProviderId,
|
getCurrentProviderId,
|
||||||
getLiveProviderIds,
|
|
||||||
getSessionMessages,
|
getSessionMessages,
|
||||||
getProviders,
|
getProviders,
|
||||||
listProviders,
|
listProviders,
|
||||||
@@ -68,20 +67,6 @@ export const handlers = [
|
|||||||
|
|
||||||
http.post(`${TAURI_ENDPOINT}/update_tray_menu`, () => success(true)),
|
http.post(`${TAURI_ENDPOINT}/update_tray_menu`, () => success(true)),
|
||||||
|
|
||||||
http.post(`${TAURI_ENDPOINT}/get_opencode_live_provider_ids`, () =>
|
|
||||||
success(getLiveProviderIds("opencode")),
|
|
||||||
),
|
|
||||||
|
|
||||||
http.post(`${TAURI_ENDPOINT}/get_openclaw_live_provider_ids`, () =>
|
|
||||||
success(getLiveProviderIds("openclaw")),
|
|
||||||
),
|
|
||||||
|
|
||||||
http.post(`${TAURI_ENDPOINT}/get_openclaw_default_model`, () =>
|
|
||||||
success({ primary: null, fallback: [] }),
|
|
||||||
),
|
|
||||||
|
|
||||||
http.post(`${TAURI_ENDPOINT}/scan_openclaw_config_health`, () => success([])),
|
|
||||||
|
|
||||||
http.post(`${TAURI_ENDPOINT}/switch_provider`, async ({ request }) => {
|
http.post(`${TAURI_ENDPOINT}/switch_provider`, async ({ request }) => {
|
||||||
const { id, app } = await withJson<{ id: string; app: AppId }>(request);
|
const { id, app } = await withJson<{ id: string; app: AppId }>(request);
|
||||||
const providers = listProviders(app);
|
const providers = listProviders(app);
|
||||||
@@ -144,29 +129,6 @@ export const handlers = [
|
|||||||
return success(deleteSession(providerId, sessionId, sourcePath));
|
return success(deleteSession(providerId, sessionId, sourcePath));
|
||||||
}),
|
}),
|
||||||
|
|
||||||
http.post(`${TAURI_ENDPOINT}/delete_sessions`, async ({ request }) => {
|
|
||||||
const { items = [] } = await withJson<{
|
|
||||||
items?: {
|
|
||||||
providerId: string;
|
|
||||||
sessionId: string;
|
|
||||||
sourcePath: string;
|
|
||||||
}[];
|
|
||||||
}>(request);
|
|
||||||
|
|
||||||
return success(
|
|
||||||
items.map((item) => ({
|
|
||||||
providerId: item.providerId,
|
|
||||||
sessionId: item.sessionId,
|
|
||||||
sourcePath: item.sourcePath,
|
|
||||||
success: deleteSession(
|
|
||||||
item.providerId,
|
|
||||||
item.sessionId,
|
|
||||||
item.sourcePath,
|
|
||||||
),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
|
|
||||||
// MCP APIs
|
// MCP APIs
|
||||||
http.post(`${TAURI_ENDPOINT}/get_mcp_config`, async ({ request }) => {
|
http.post(`${TAURI_ENDPOINT}/get_mcp_config`, async ({ request }) => {
|
||||||
const { app } = await withJson<{ app: AppId }>(request);
|
const { app } = await withJson<{ app: AppId }>(request);
|
||||||
@@ -212,8 +174,6 @@ export const handlers = [
|
|||||||
|
|
||||||
http.post(`${TAURI_ENDPOINT}/get_settings`, () => success(getSettings())),
|
http.post(`${TAURI_ENDPOINT}/get_settings`, () => success(getSettings())),
|
||||||
|
|
||||||
http.post(`${TAURI_ENDPOINT}/check_env_conflicts`, () => success([])),
|
|
||||||
|
|
||||||
http.post(`${TAURI_ENDPOINT}/save_settings`, async ({ request }) => {
|
http.post(`${TAURI_ENDPOINT}/save_settings`, async ({ request }) => {
|
||||||
const { settings } = await withJson<{ settings: Settings }>(request);
|
const { settings } = await withJson<{ settings: Settings }>(request);
|
||||||
setSettings(settings);
|
setSettings(settings);
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import type {
|
|||||||
type ProvidersByApp = Record<AppId, Record<string, Provider>>;
|
type ProvidersByApp = Record<AppId, Record<string, Provider>>;
|
||||||
type CurrentProviderState = Record<AppId, string>;
|
type CurrentProviderState = Record<AppId, string>;
|
||||||
type McpConfigState = Record<AppId, Record<string, McpServer>>;
|
type McpConfigState = Record<AppId, Record<string, McpServer>>;
|
||||||
type LiveProviderIdsByApp = Record<"opencode" | "openclaw", string[]>;
|
|
||||||
|
|
||||||
const createDefaultProviders = (): ProvidersByApp => ({
|
const createDefaultProviders = (): ProvidersByApp => ({
|
||||||
claude: {
|
claude: {
|
||||||
@@ -78,10 +77,6 @@ const createDefaultCurrent = (): CurrentProviderState => ({
|
|||||||
|
|
||||||
let providers = createDefaultProviders();
|
let providers = createDefaultProviders();
|
||||||
let current = createDefaultCurrent();
|
let current = createDefaultCurrent();
|
||||||
let liveProviderIds: LiveProviderIdsByApp = {
|
|
||||||
opencode: [],
|
|
||||||
openclaw: [],
|
|
||||||
};
|
|
||||||
let settingsState: Settings = {
|
let settingsState: Settings = {
|
||||||
showInTray: true,
|
showInTray: true,
|
||||||
minimizeToTrayOnClose: true,
|
minimizeToTrayOnClose: true,
|
||||||
@@ -189,10 +184,6 @@ const cloneProviders = (value: ProvidersByApp) =>
|
|||||||
export const resetProviderState = () => {
|
export const resetProviderState = () => {
|
||||||
providers = createDefaultProviders();
|
providers = createDefaultProviders();
|
||||||
current = createDefaultCurrent();
|
current = createDefaultCurrent();
|
||||||
liveProviderIds = {
|
|
||||||
opencode: [],
|
|
||||||
openclaw: [],
|
|
||||||
};
|
|
||||||
sessionsState = createDefaultSessions();
|
sessionsState = createDefaultSessions();
|
||||||
sessionMessagesState = createDefaultSessionMessages();
|
sessionMessagesState = createDefaultSessionMessages();
|
||||||
settingsState = {
|
settingsState = {
|
||||||
@@ -252,17 +243,6 @@ export const getProviders = (appType: AppId) =>
|
|||||||
|
|
||||||
export const getCurrentProviderId = (appType: AppId) => current[appType] ?? "";
|
export const getCurrentProviderId = (appType: AppId) => current[appType] ?? "";
|
||||||
|
|
||||||
export const getLiveProviderIds = (appType: "opencode" | "openclaw") => [
|
|
||||||
...liveProviderIds[appType],
|
|
||||||
];
|
|
||||||
|
|
||||||
export const setLiveProviderIds = (
|
|
||||||
appType: "opencode" | "openclaw",
|
|
||||||
ids: string[],
|
|
||||||
) => {
|
|
||||||
liveProviderIds[appType] = [...ids];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const setCurrentProviderId = (appType: AppId, providerId: string) => {
|
export const setCurrentProviderId = (appType: AppId, providerId: string) => {
|
||||||
current[appType] = providerId;
|
current[appType] = providerId;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user