Compare commits

..

5 Commits

Author SHA1 Message Date
YoVinchen 907734c542 fix(terminal): escape paths for PowerShell and cmd.exe in Windows launcher 2026-03-31 15:41:09 +08:00
YoVinchen 9ab54c93ef fix(terminal): use pushd for UNC paths in Windows batch launcher
`cmd.exe` cannot set a UNC path (e.g. `\\wsl$\...`) as the current
directory via `cd /d`; it errors with "CMD does not support UNC paths
as current directories". Switch to `pushd` which temporarily maps the
UNC share to a drive letter.

Rename `build_windows_cd_command` → `build_windows_cwd_command` to
reflect the broader semantics. Extract `build_windows_cwd_command_str`
and `is_windows_unc_path` helpers for testability, and add unit tests
covering drive paths, UNC paths, and batch metacharacter escaping.

Also fix minor style issues: sort mod declarations alphabetically,
add missing EOF newline in lightweight.rs, add explicit type annotation
in streaming_responses test, and reformat tray menu builder chain.
2026-03-30 23:21:27 +08:00
YoVinchen f5320dbdd2 fix(terminal): restore UNC paths when stripping Windows verbatim prefix
Handle \\?\UNC\server\share form separately from regular \\?\ prefix,
converting it back to \\server\share so network/WSL directory paths
remain valid in batch cd commands.
2026-03-30 10:24:48 +08:00
YoVinchen 9701de354f fix(terminal): preserve cwd path and strip Windows verbatim prefix
- Stop trimming non-empty paths so directories with leading/trailing
  spaces on Unix are handled correctly
- Strip \\?\ extended-length prefix from canonicalized paths on Windows
  to prevent batch script cd failures
2026-03-30 09:45:42 +08:00
YoVinchen 02fd639dfc Add directory picker before launching Claude terminal 2026-03-30 09:04:51 +08:00
33 changed files with 432 additions and 1641 deletions
+2 -7
View File
@@ -4,10 +4,10 @@
### The All-in-One Manager for Claude Code, Codex, Gemini CLI, OpenCode & OpenClaw
[![Version](https://img.shields.io/github/v/release/farion1231/cc-switch?color=blue&label=version)](https://github.com/farion1231/cc-switch/releases)
[![Version](https://img.shields.io/badge/version-3.12.3-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
[![Downloads](https://img.shields.io/github/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)
[![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
@@ -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>
</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>
</details>
+2 -7
View File
@@ -4,10 +4,10 @@
### Claude Code、Codex、Gemini CLI、OpenCode、OpenClaw のオールインワン管理ツール
[![Version](https://img.shields.io/github/v/release/farion1231/cc-switch?color=blue&label=version)](https://github.com/farion1231/cc-switch/releases)
[![Version](https://img.shields.io/badge/version-3.12.3-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
[![Downloads](https://img.shields.io/github/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)
[![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
@@ -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>
</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>
</details>
+2 -7
View File
@@ -4,10 +4,10 @@
### Claude Code、Codex、Gemini CLI、OpenCode 和 OpenClaw 的全方位管理工具
[![Version](https://img.shields.io/github/v/release/farion1231/cc-switch?color=blue&label=version)](https://github.com/farion1231/cc-switch/releases)
[![Version](https://img.shields.io/badge/version-3.12.3-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
[![Downloads](https://img.shields.io/github/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)
[![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
@@ -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>
</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>
</details>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 280 KiB

+41 -4
View File
@@ -1192,7 +1192,8 @@ del \"%~f0\" >nul 2>&1
std::fs::write(&bat_file, &content).map_err(|e| format!("写入批处理文件失败: {e}"))?;
let bat_path = bat_file.to_string_lossy();
let ps_cmd = format!("& '{}'", bat_path);
let bat_path_for_cmd = build_windows_cmd_command_str(&bat_path);
let ps_cmd = format!("& '{}'", escape_powershell_single_quoted(&bat_path));
// Try the preferred terminal first
let result = match terminal {
@@ -1200,8 +1201,10 @@ del \"%~f0\" >nul 2>&1
&["powershell", "-NoExit", "-Command", &ps_cmd],
"PowerShell",
),
"wt" => run_windows_start_command(&["wt", "cmd", "/K", &bat_path], "Windows Terminal"),
_ => run_windows_start_command(&["cmd", "/K", &bat_path], "cmd"), // "cmd" or default
"wt" => {
run_windows_start_command(&["wt", "cmd", "/K", &bat_path_for_cmd], "Windows Terminal")
}
_ => run_windows_start_command(&["cmd", "/K", &bat_path_for_cmd], "cmd"), // "cmd" or default
};
// If preferred terminal fails and it's not the default, try cmd as fallback
@@ -1211,7 +1214,7 @@ del \"%~f0\" >nul 2>&1
terminal,
result.as_ref().err()
);
return run_windows_start_command(&["cmd", "/K", &bat_path], "cmd");
return run_windows_start_command(&["cmd", "/K", &bat_path_for_cmd], "cmd");
}
result
@@ -1231,6 +1234,26 @@ fn shell_single_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\"'\"'"))
}
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
fn escape_powershell_single_quoted(value: &str) -> String {
value.replace('\'', "''")
}
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
fn build_windows_cmd_command_str(path: &str) -> String {
// Avoid handing `cmd /K` a string that starts with a single quoted path:
// per cmd.exe parsing rules, those outer quotes may be stripped when the
// quoted text contains shell metacharacters. An explicit `call "..."` form
// keeps the command from starting with a quote while still protecting
// spaces and other special characters in the batch path.
format!("call \"{}\"", escape_windows_cmd_quoted_path(path))
}
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
fn escape_windows_cmd_quoted_path(value: &str) -> String {
value.replace('%', "%%")
}
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
fn is_windows_unc_path(path: &str) -> bool {
path.starts_with(r"\\")
@@ -1472,6 +1495,20 @@ mod tests {
assert_eq!(command, "cd '/tmp/project O'\"'\"'Brien' || exit 1\n");
}
#[test]
fn escape_powershell_single_quoted_doubles_embedded_quotes() {
let escaped = escape_powershell_single_quoted(r"C:\Users\O'Brien\AppData\Local\Temp");
assert_eq!(escaped, r"C:\Users\O''Brien\AppData\Local\Temp");
}
#[test]
fn build_windows_cmd_command_str_quotes_and_escapes_metacharacters() {
let command = build_windows_cmd_command_str(r"C:\Users\100%&(test)\cc switch.bat");
assert_eq!(command, "call \"C:\\Users\\100%%&(test)\\cc switch.bat\"");
}
#[test]
fn build_windows_cwd_command_str_uses_cd_for_drive_paths() {
let command = build_windows_cwd_command_str(r"C:\work\repo");
@@ -74,12 +74,3 @@ pub async fn delete_session(
.await
.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}"))
}
-1
View File
@@ -1021,7 +1021,6 @@ pub fn run() {
commands::list_sessions,
commands::get_session_messages,
commands::delete_session,
commands::delete_sessions,
commands::launch_session_terminal,
commands::get_tool_versions,
// Provider terminal
+19 -43
View File
@@ -6,32 +6,6 @@ use indexmap::IndexMap;
use serde_json::{json, Map, Value};
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 {
if let Some(override_dir) = get_opencode_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> {
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());
match plugins {
Some(arr) => {
// 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| {
v.as_str()
.map(|s| {
!matches_any_plugin_prefix(s, &STANDARD_OMO_PLUGIN_PREFIXES)
&& !matches_any_plugin_prefix(s, &SLIM_OMO_PLUGIN_PREFIXES)
})
.map(|s| !s.starts_with("oh-my-opencode-slim"))
.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| {
v.as_str()
.map(|s| {
!matches_any_plugin_prefix(s, &STANDARD_OMO_PLUGIN_PREFIXES)
&& !matches_any_plugin_prefix(s, &SLIM_OMO_PLUGIN_PREFIXES)
!s.starts_with("oh-my-opencode") || s.starts_with("oh-my-opencode-slim")
})
.unwrap_or(true)
});
}
let already_exists = arr
.iter()
.any(|v| v.as_str() == Some(normalized_plugin_name.as_str()));
let already_exists = arr.iter().any(|v| v.as_str() == Some(plugin_name));
if !already_exists {
arr.push(Value::String(normalized_plugin_name));
arr.push(Value::String(plugin_name.to_string()));
}
}
None => {
config["plugin"] = json!([normalized_plugin_name]);
config["plugin"] = json!([plugin_name]);
}
}
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()?;
if let Some(arr) = config.get_mut("plugin").and_then(|v| v.as_array_mut()) {
arr.retain(|v| {
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)
});
+23 -11
View File
@@ -2,12 +2,15 @@
//!
//! 处理故障转移成功后的供应商切换逻辑,包括:
//! - 去重控制(避免多个请求同时触发)
//! - 数据库更新
//! - 托盘菜单更新
//! - 前端事件发射
//! - Live 备份更新
use crate::database::Database;
use crate::error::AppError;
use std::collections::HashSet;
use std::str::FromStr;
use std::sync::Arc;
use tauri::{Emitter, Manager};
use tokio::sync::RwLock;
@@ -95,21 +98,30 @@ impl FailoverSwitchManager {
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_state) = app.try_state::<crate::store::AppState>() {
switched = app_state
.proxy_service
.hot_switch_provider(app_type, provider_id)
.await
.map_err(AppError::Message)?
.logical_target_changed;
if !switched {
return Ok(false);
// 更新 Live 备份(确保代理停止时恢复正确配置)
if let Ok(Some(provider)) = self.db.get_provider_by_id(provider_id, app_type) {
if let Err(e) = app_state
.proxy_service
.update_live_backup_from_provider(app_type, &provider)
.await
{
log::warn!("[FO-003] Live 备份更新失败: {e}");
}
}
// 重建托盘菜单
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 Err(e) = tray.set_menu(Some(new_menu)) {
@@ -130,6 +142,6 @@ impl FailoverSwitchManager {
}
}
Ok(switched)
Ok(true)
}
}
-1
View File
@@ -24,7 +24,6 @@ pub mod response_processor;
pub(crate) mod server;
pub mod session;
pub(crate) mod sse;
pub(crate) mod switch_lock;
pub mod thinking_budget_rectifier;
pub mod thinking_optimizer;
pub mod thinking_rectifier;
-42
View File
@@ -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
}
}
+24 -77
View File
@@ -21,41 +21,33 @@ type OmoProfileData = (Option<Value>, Option<Value>, Option<Value>);
// ── Variant descriptor ─────────────────────────────────────────
pub struct OmoVariant {
pub preferred_filename: &'static str,
pub config_candidates: &'static [&'static str],
pub filename: &'static str,
pub category: &'static str,
pub provider_prefix: &'static str,
pub plugin_name: &'static str,
pub plugin_prefixes: &'static [&'static str],
pub plugin_prefix: &'static str,
pub has_categories: bool,
pub label: &'static str,
pub import_label: &'static str,
}
pub const STANDARD: OmoVariant = OmoVariant {
preferred_filename: "oh-my-openagent.jsonc",
config_candidates: &[
"oh-my-openagent.jsonc",
"oh-my-openagent.json",
"oh-my-opencode.jsonc",
"oh-my-opencode.json",
],
filename: "oh-my-opencode.jsonc",
category: "omo",
provider_prefix: "omo-",
plugin_name: "oh-my-openagent@latest",
plugin_prefixes: &["oh-my-openagent", "oh-my-opencode"],
plugin_name: "oh-my-opencode@latest",
plugin_prefix: "oh-my-opencode",
has_categories: true,
label: "OMO",
import_label: "Imported",
};
pub const SLIM: OmoVariant = OmoVariant {
preferred_filename: "oh-my-opencode-slim.jsonc",
config_candidates: &["oh-my-opencode-slim.jsonc", "oh-my-opencode-slim.json"],
filename: "oh-my-opencode-slim.jsonc",
category: "omo-slim",
provider_prefix: "omo-slim-",
plugin_name: "oh-my-opencode-slim@latest",
plugin_prefixes: &["oh-my-opencode-slim"],
plugin_prefix: "oh-my-opencode-slim",
has_categories: false,
label: "OMO Slim",
import_label: "Imported Slim",
@@ -68,27 +60,22 @@ pub struct OmoService;
impl OmoService {
// ── 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 {
let base_dir = get_opencode_dir();
Self::find_existing_config_path(v, &base_dir)
.unwrap_or_else(|| base_dir.join(v.preferred_filename))
get_opencode_dir().join(v.filename)
}
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> {
@@ -136,18 +123,12 @@ impl OmoService {
// ── 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);
}
let config_path = Self::config_path(v);
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);
}
if !deleted_paths.is_empty() {
log::info!("{} config files deleted: {deleted_paths:?}", v.label);
}
crate::opencode_config::remove_plugins_by_prefixes(v.plugin_prefixes)?;
crate::opencode_config::remove_plugin_by_prefix(v.plugin_prefix)?;
Ok(())
}
@@ -470,38 +451,4 @@ mod tests {
assert!(obj.contains_key("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"
);
}
}
+22 -2
View File
@@ -480,12 +480,32 @@ impl ProviderService {
id
);
// 获取新供应商的完整配置(用于更新备份)
let provider = providers
.get(id)
.ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?;
// Update database is_current
state.db.set_current_provider(app_type.as_str(), id)?;
// Update local settings for consistency
crate::settings::set_current_provider(&app_type, Some(id))?;
// 更新 Live 备份(确保代理关闭时恢复正确的供应商配置)
futures::executor::block_on(
state
.proxy_service
.hot_switch_provider(app_type.as_str(), id),
.update_live_backup_from_provider(app_type.as_str(), provider),
)
.map_err(|e| AppError::Message(format!("热切换失败: {e}")))?;
.map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?;
// 关键修复:接管模式下切换供应商不会写回 Live 配置,
// 需要主动清理 Claude Live 中的“模型覆盖”字段,避免仍以旧模型名发起请求。
if matches!(app_type, AppType::Claude) {
if let Err(e) = state.proxy_service.cleanup_claude_model_overrides_in_live() {
log::warn!("清理 Claude Live 模型字段失败(不影响切换结果): {e}");
}
}
// Note: No Live config write, no MCP sync
// The proxy server will route requests to the new provider via is_current
+39 -280
View File
@@ -7,7 +7,6 @@ use crate::config::{get_claude_settings_path, read_json_file, write_json_file};
use crate::database::Database;
use crate::provider::Provider;
use crate::proxy::server::ProxyServer;
use crate::proxy::switch_lock::SwitchLockManager;
use crate::proxy::types::*;
use crate::services::provider::{
build_effective_settings_with_common_config, write_live_with_common_config,
@@ -40,12 +39,6 @@ pub struct ProxyService {
server: Arc<RwLock<Option<ProxyServer>>>,
/// AppHandle,用于传递给 ProxyServer 以支持故障转移时的 UI 更新
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 {
@@ -54,7 +47,6 @@ impl ProxyService {
db,
server: Arc::new(RwLock::new(None)),
app_handle: Arc::new(RwLock::new(None)),
switch_locks: SwitchLockManager::new(),
}
}
@@ -1108,11 +1100,6 @@ impl ProxyService {
/// 恢复指定应用的 Live 配置(若无备份则不做任何操作)
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 {
AppType::Claude => {
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(
&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_with_fallback_inner(app_type)
.await
}
async fn restore_live_config_for_app_with_fallback_inner(
&self,
app_type: &AppType,
) -> Result<(), String> {
let app_type_str = app_type.as_str();
@@ -1509,17 +1487,6 @@ impl ProxyService {
&self,
app_type: &str,
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> {
let app_type_enum =
AppType::from_str(app_type).map_err(|_| format!("未知的应用类型: {app_type}"))?;
@@ -1573,69 +1540,6 @@ impl ProxyService {
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(
target_settings: &mut Value,
existing_backup: &Value,
@@ -1703,13 +1607,47 @@ impl ProxyService {
app_type: &str,
provider_id: &str,
) -> 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 {
log::info!("代理模式:已切换 {app_type} 的目标供应商为 {provider_id}");
} else {
log::debug!("代理模式:{app_type} 已对齐到目标供应商 {provider_id}");
self.db
.set_current_provider(app_type_enum.as_str(), provider_id)
.map_err(|e| format!("更新当前供应商失败: {e}"))?;
// 同步本地 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(())
}
@@ -2255,185 +2193,6 @@ model = "gpt-5.1-codex"
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]
#[serial]
async fn update_live_backup_from_provider_applies_claude_common_config() {
+3 -4
View File
@@ -204,11 +204,10 @@ pub async fn ensure_remote_directories(
s if s == StatusCode::CREATED || s.is_success() => {
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
s if s == StatusCode::METHOD_NOT_ALLOWED
|| s == StatusCode::CONFLICT
|| s.is_redirection() =>
{
s if s == StatusCode::CONFLICT || s.is_redirection() => {
if !propfind_exists(&client, &dir_url, auth).await? {
return Err(webdav_status_error("MKCOL", status, &dir_url));
}
+1 -105
View File
@@ -1,7 +1,7 @@
pub mod providers;
pub mod terminal;
use serde::{Deserialize, Serialize};
use serde::Serialize;
use std::path::{Path, PathBuf};
use providers::{claude, codex, gemini, openclaw, opencode};
@@ -36,25 +36,6 @@ pub struct SessionMessage {
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> {
let (r1, r2, r3, r4, r5) = std::thread::scope(|s| {
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)
}
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(
provider_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()))
}
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)]
mod tests {
use super::*;
@@ -239,44 +175,4 @@ mod tests {
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")
);
}
}
+11 -8
View File
@@ -584,14 +584,17 @@ pub fn get_current_provider(app_type: &AppType) -> Option<String> {
/// 这是设备级别的设置,不随数据库同步。
/// 传入 `None` 会清除当前供应商设置。
pub fn set_current_provider(app_type: &AppType, id: Option<&str>) -> Result<(), AppError> {
let id_owned = id.map(|s| s.to_string());
mutate_settings(|settings| match app_type {
AppType::Claude => settings.current_provider_claude = id_owned.clone(),
AppType::Codex => settings.current_provider_codex = id_owned.clone(),
AppType::Gemini => settings.current_provider_gemini = id_owned.clone(),
AppType::OpenCode => settings.current_provider_opencode = id_owned.clone(),
AppType::OpenClaw => settings.current_provider_openclaw = id_owned.clone(),
})
let mut settings = get_settings();
match app_type {
AppType::Claude => settings.current_provider_claude = id.map(|s| s.to_string()),
AppType::Codex => settings.current_provider_codex = id.map(|s| s.to_string()),
AppType::Gemini => settings.current_provider_gemini = id.map(|s| s.to_string()),
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(验证存在性)
+4 -11
View File
@@ -123,7 +123,6 @@ export function ProviderCard({
// OMO and OMO Slim share the same card behavior
const isAnyOmo = isOmo || isOmoSlim;
const handleDisableAnyOmo = isOmoSlim ? onDisableOmoSlim : onDisableOmo;
const isAdditiveMode = appId === "opencode" && !isAnyOmo;
const { data: health } = useProviderHealth(provider.id, appId);
@@ -210,12 +209,9 @@ export function ProviderCard({
: isCurrent;
const shouldUseGreen = !isAnyOmo && isProxyTakeover && isActiveProvider;
const hasPersistentConfigHighlight = isAdditiveMode && isInConfig;
const shouldUseBlue =
(isAnyOmo && isActiveProvider) ||
(!isAnyOmo &&
!isProxyTakeover &&
(isActiveProvider || hasPersistentConfigHighlight));
(!isAnyOmo && !isProxyTakeover && isActiveProvider);
return (
<div
@@ -228,8 +224,7 @@ export function ProviderCard({
shouldUseGreen &&
"border-emerald-500/60 shadow-sm shadow-emerald-500/10",
shouldUseBlue && "border-blue-500/60 shadow-sm shadow-blue-500/10",
!(isActiveProvider || hasPersistentConfigHighlight) &&
"hover:shadow-sm",
!isActiveProvider && "hover:shadow-sm",
dragHandleProps?.isDragging &&
"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",
shouldUseGreen && "from-emerald-500/10",
shouldUseBlue && "from-blue-500/10",
!shouldUseGreen && !shouldUseBlue && "from-primary/10",
isActiveProvider || hasPersistentConfigHighlight
? "opacity-100"
: "opacity-0",
!isActiveProvider && "from-primary/10",
isActiveProvider ? "opacity-100" : "opacity-0",
)}
/>
<div className="relative flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
@@ -65,7 +65,7 @@ export function ProviderPresetSelector({
case "omo":
return t("providerForm.omoHint", {
defaultValue:
"💡 OMO 配置管理 Agent 模型分配,兼容 oh-my-openagent.jsonc / oh-my-opencode.jsonc",
"💡 OMO 配置管理 Agent 模型分配,写入 oh-my-opencode.jsonc",
});
default:
return t("providerPreset.hint", {
+34 -61
View File
@@ -1,6 +1,5 @@
import { ChevronRight, Clock } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Checkbox } from "@/components/ui/checkbox";
import {
Tooltip,
TooltipContent,
@@ -20,21 +19,13 @@ import {
interface SessionItemProps {
session: SessionMeta;
isSelected: boolean;
selectionMode: boolean;
isChecked: boolean;
isCheckDisabled?: boolean;
onSelect: (key: string) => void;
onToggleChecked: (checked: boolean) => void;
}
export function SessionItem({
session,
isSelected,
selectionMode,
isChecked,
isCheckDisabled = false,
onSelect,
onToggleChecked,
}: SessionItemProps) {
const { t } = useTranslation();
const title = formatSessionTitle(session);
@@ -42,64 +33,46 @@ export function SessionItem({
const sessionKey = getSessionKey(session);
return (
<div
<button
type="button"
onClick={() => onSelect(sessionKey)}
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
? "bg-primary/10 border border-primary/30"
: "hover:bg-muted/60 border border-transparent",
)}
>
{selectionMode && (
<div className="shrink-0 pt-0.5">
<Checkbox
checked={isChecked}
disabled={isCheckDisabled}
aria-label={t("sessionManager.selectForBatch", {
defaultValue: "选择会话",
})}
onCheckedChange={(checked) => onToggleChecked(Boolean(checked))}
/>
</div>
)}
<button
type="button"
onClick={() => onSelect(sessionKey)}
className="min-w-0 flex-1 text-left"
>
<div className="flex items-center gap-2 mb-1">
<Tooltip>
<TooltipTrigger asChild>
<span className="shrink-0">
<ProviderIcon
icon={getProviderIconName(session.providerId)}
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-2 mb-1">
<Tooltip>
<TooltipTrigger asChild>
<span className="shrink-0">
<ProviderIcon
icon={getProviderIconName(session.providerId)}
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">
<Clock className="size-3" />
<span>
{lastActive
? formatRelativeTime(lastActive, t)
: t("common.unknown")}
</span>
</div>
</button>
</div>
<div className="flex items-center gap-1 text-[11px] text-muted-foreground">
<Clock className="size-3" />
<span>
{lastActive ? formatRelativeTime(lastActive, t) : t("common.unknown")}
</span>
</div>
</button>
);
}
+171 -547
View File
@@ -2,7 +2,6 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { useSessionSearch } from "@/hooks/useSessionSearch";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useQueryClient } from "@tanstack/react-query";
import {
Copy,
RefreshCw,
@@ -13,7 +12,6 @@ import {
Clock,
FolderOpen,
X,
CheckSquare,
} from "lucide-react";
import {
useDeleteSessionMutation,
@@ -65,7 +63,6 @@ type ProviderFilter =
export function SessionManagerPage({ appId }: { appId: string }) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { data, isLoading, refetch } = useSessionsQuery();
const sessions = data ?? [];
const detailRef = useRef<HTMLDivElement | null>(null);
@@ -76,14 +73,7 @@ export function SessionManagerPage({ appId }: { appId: string }) {
);
const [tocDialogOpen, setTocDialogOpen] = useState(false);
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [deleteTargets, setDeleteTargets] = useState<SessionMeta[] | null>(
null,
);
const [selectedSessionKeys, setSelectedSessionKeys] = useState<Set<string>>(
() => new Set(),
);
const [isBatchDeleting, setIsBatchDeleting] = useState(false);
const [selectionMode, setSelectionMode] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<SessionMeta | null>(null);
const searchInputRef = useRef<HTMLInputElement | null>(null);
const [search, setSearch] = useState("");
@@ -132,25 +122,6 @@ export function SessionManagerPage({ appId }: { appId: string }) {
selectedSession?.sourcePath,
);
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(() => {
@@ -223,195 +194,16 @@ export function SessionManagerPage({ appId }: { appId: string }) {
};
const handleDeleteConfirm = async () => {
if (!deleteTargets || deleteTargets.length === 0 || isDeleting) {
if (!deleteTarget?.sourcePath || deleteSessionMutation.isPending) {
return;
}
const targets = deleteTargets.filter((session) => session.sourcePath);
setDeleteTargets(null);
if (targets.length === 0) {
return;
}
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;
setDeleteTarget(null);
await deleteSessionMutation.mutateAsync({
providerId: deleteTarget.providerId,
sessionId: deleteTarget.sessionId,
sourcePath: deleteTarget.sourcePath,
});
}, [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 (
@@ -427,315 +219,174 @@ export function SessionManagerPage({ appId }: { appId: string }) {
<Card className="flex flex-col flex-1 min-h-0 overflow-hidden">
<CardHeader className="py-2 px-3 border-b">
{isSearchOpen ? (
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
<Input
ref={searchInputRef}
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder={t("sessionManager.searchPlaceholder")}
className="h-8 pl-8 pr-8 text-sm"
autoFocus
onKeyDown={(e) => {
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={() => {
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
<Input
ref={searchInputRef}
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder={t("sessionManager.searchPlaceholder")}
className="h-8 pl-8 pr-8 text-sm"
autoFocus
onKeyDown={(e) => {
if (e.key === "Escape") {
setIsSearchOpen(false);
setSearch("");
}}
>
<X className="size-3" />
</Button>
}
}}
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);
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>
{selectionMode && (
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
variant="ghost"
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"
aria-label={t("sessionManager.exitBatchModeTooltip", {
defaultValue: "退出批量管理",
})}
onClick={exitSelectionMode}
className="size-7"
onClick={() => {
setIsSearchOpen(true);
setTimeout(
() => searchInputRef.current?.focus(),
0,
);
}}
>
<CheckSquare className="size-3.5" />
<Search className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
{t("sessionManager.exitBatchModeTooltip", {
defaultValue: "退出批量管理",
})}
{t("sessionManager.searchSessions")}
</TooltipContent>
</Tooltip>
)}
</div>
) : (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2">
<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>
)}
<Select
value={providerFilter}
onValueChange={(value) =>
setProviderFilter(value as ProviderFilter)
}
>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={() => {
setIsSearchOpen(true);
setTimeout(
() => searchInputRef.current?.focus(),
0,
);
}}
>
<Search className="size-3.5" />
</Button>
<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>
{t("sessionManager.searchSessions")}
{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>
<Select
value={providerFilter}
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>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="destructive"
size="sm"
className="h-7 gap-1.5 px-2.5 whitespace-nowrap justify-self-start min-[520px]:justify-self-end"
onClick={openBatchDeleteDialog}
disabled={
isDeleting ||
selectedDeletableSessions.length === 0
}
variant="ghost"
size="icon"
className="size-7"
onClick={() => void refetch()}
>
<Trash2 className="size-3.5" />
<span className="text-xs">
{isBatchDeleting
? t("sessionManager.batchDeleting", {
defaultValue: "删除中...",
})
: t("sessionManager.deleteSelected", {
defaultValue: "批量删除",
})}
</span>
<RefreshCw className="size-3.5" />
</Button>
</div>
</div>
)}
</TooltipTrigger>
<TooltipContent>{t("common.refresh")}</TooltipContent>
</Tooltip>
</div>
</div>
)}
</CardHeader>
@@ -765,15 +416,7 @@ export function SessionManagerPage({ appId }: { appId: string }) {
key={getSessionKey(session)}
session={session}
isSelected={isSelected}
selectionMode={selectionMode}
isChecked={selectedSessionKeys.has(
getSessionKey(session),
)}
isCheckDisabled={!session.sourcePath}
onSelect={setSelectedKey}
onToggleChecked={(checked) =>
toggleSessionChecked(session, checked)
}
/>
);
})}
@@ -905,16 +548,15 @@ export function SessionManagerPage({ appId }: { appId: string }) {
size="sm"
variant="destructive"
className="gap-1.5"
onClick={() =>
setDeleteTargets([selectedSession])
}
onClick={() => setDeleteTarget(selectedSession)}
disabled={
!selectedSession.sourcePath || isDeleting
!selectedSession.sourcePath ||
deleteSessionMutation.isPending
}
>
<Trash2 className="size-3.5" />
<span className="hidden sm:inline">
{isDeleting
{deleteSessionMutation.isPending
? t("sessionManager.deleting", {
defaultValue: "删除中...",
})
@@ -1043,47 +685,29 @@ export function SessionManagerPage({ appId }: { appId: string }) {
</div>
</div>
<ConfirmDialog
isOpen={Boolean(deleteTargets)}
title={
deleteTargets && deleteTargets.length > 1
? t("sessionManager.batchDeleteConfirmTitle", {
defaultValue: "批量删除会话",
})
: t("sessionManager.deleteConfirmTitle", {
defaultValue: "删除会话",
})
}
isOpen={Boolean(deleteTarget)}
title={t("sessionManager.deleteConfirmTitle", {
defaultValue: "删除会话",
})}
message={
deleteTargets && deleteTargets.length > 1
? t("sessionManager.batchDeleteConfirmMessage", {
deleteTarget
? t("sessionManager.deleteConfirmMessage", {
defaultValue:
"将永久删除已选中的 {{count}} 个本地会话记录。\n\n此操作不可恢复。",
count: deleteTargets.length,
})
: 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: "删除会话",
"将永久删除本地会话“{{title}}”\nSession ID: {{sessionId}}\n\n此操作不可恢复。",
title: formatSessionTitle(deleteTarget),
sessionId: deleteTarget.sessionId,
})
: ""
}
confirmText={t("sessionManager.deleteConfirmAction", {
defaultValue: "删除会话",
})}
cancelText={t("common.cancel", { defaultValue: "取消" })}
variant="destructive"
onConfirm={() => void handleDeleteConfirm()}
onCancel={() => {
if (!isDeleting) {
setDeleteTargets(null);
if (!deleteSessionMutation.isPending) {
setDeleteTarget(null);
}
}}
/>
+10 -59
View File
@@ -98,20 +98,6 @@ function formatDbCompatVersion(version?: number | null): string | 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 ──────────────────────────────────────────────────
type ActionState =
@@ -181,10 +167,6 @@ export function WebdavSyncSection({
const [passwordTouched, setPasswordTouched] = useState(false);
const [justSaved, setJustSaved] = useState(false);
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".
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
useEffect(() => {
if (!config || dirty) return;
setForm(() => {
const nextBaseUrl = config.baseUrl ?? "";
const nextUsername = config.username ?? "";
const nextRemoteRoot = config.remoteRoot ?? "cc-switch-sync";
const nextProfile = config.profile ?? "default";
const nextKey = buildPasswordPreservationKey({
baseUrl: nextBaseUrl,
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,
};
setForm({
baseUrl: config.baseUrl ?? "",
username: config.username ?? "",
password: config.password ?? "",
remoteRoot: config.remoteRoot ?? "cc-switch-sync",
profile: config.profile ?? "default",
autoSync: config.autoSync ?? false,
});
setPasswordTouched(false);
setPresetId(detectPreset(config.baseUrl ?? ""));
@@ -330,13 +289,12 @@ export function WebdavSyncSection({
enabled: true,
baseUrl,
username: form.username.trim(),
// 未重新触碰密码时,提交空值让后端沿用已保存密码,表单里的值仅用于 UI 显示
password: passwordTouched ? form.password : "",
password: form.password,
remoteRoot: form.remoteRoot.trim() || "cc-switch-sync",
profile: form.profile.trim() || "default",
autoSync: form.autoSync,
};
}, [form, passwordTouched]);
}, [form]);
// ─── Handlers ───────────────────────────────────────────
@@ -368,12 +326,6 @@ export function WebdavSyncSection({
return;
}
setActionState("saving");
pendingPasswordPreservationRef.current = form.password
? {
key: buildPasswordPreservationKey(settings),
password: form.password,
}
: null;
try {
await settingsApi.webdavSyncSaveSettings(settings, passwordTouched);
setDirty(false);
@@ -387,7 +339,6 @@ export function WebdavSyncSection({
}, 2000);
await queryClient.invalidateQueries();
} catch (error) {
pendingPasswordPreservationRef.current = null;
toast.error(
t("settings.webdavSync.saveFailed", {
error: (error as Error)?.message ?? String(error),
@@ -411,7 +362,7 @@ export function WebdavSyncSection({
} finally {
setActionState("idle");
}
}, [buildSettings, form.password, passwordTouched, queryClient, t]);
}, [buildSettings, passwordTouched, queryClient, t]);
/** Fetch remote info, then open upload confirmation dialog. */
const handleUploadClick = useCallback(async () => {
+2 -2
View File
@@ -192,7 +192,7 @@ export const providerPresets: ProviderPreset[] = [
apiKeyUrl: "https://platform.stepfun.ai/interface-key",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://api.stepfun.com/v1",
ANTHROPIC_BASE_URL: "https://api.stepfun.ai/v1",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_MODEL: "step-3.5-flash",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "step-3.5-flash",
@@ -201,7 +201,7 @@ export const providerPresets: ProviderPreset[] = [
},
},
category: "cn_official",
endpointCandidates: ["https://api.stepfun.com/v1"],
endpointCandidates: ["https://api.stepfun.ai/v1"],
apiFormat: "openai_chat",
icon: "stepfun",
iconColor: "#005AFF",
+3 -3
View File
@@ -298,7 +298,7 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
websiteUrl: "https://platform.stepfun.ai",
apiKeyUrl: "https://platform.stepfun.ai/interface-key",
settingsConfig: {
baseUrl: "https://api.stepfun.com/v1",
baseUrl: "https://api.stepfun.ai/v1",
apiKey: "",
api: "openai-completions",
models: [
@@ -315,8 +315,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
templateValues: {
baseUrl: {
label: "Base URL",
placeholder: "https://api.stepfun.com/v1",
defaultValue: "https://api.stepfun.com/v1",
placeholder: "https://api.stepfun.ai/v1",
defaultValue: "https://api.stepfun.ai/v1",
editorValue: "",
},
apiKey: {
+4 -4
View File
@@ -489,7 +489,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
npm: "@ai-sdk/openai-compatible",
name: "StepFun",
options: {
baseURL: "https://api.stepfun.com/v1",
baseURL: "https://api.stepfun.ai/v1",
apiKey: "",
setCacheKey: true,
},
@@ -503,8 +503,8 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
templateValues: {
baseURL: {
label: "Base URL",
placeholder: "https://api.stepfun.com/v1",
defaultValue: "https://api.stepfun.com/v1",
placeholder: "https://api.stepfun.ai/v1",
defaultValue: "https://api.stepfun.ai/v1",
editorValue: "",
},
apiKey: {
@@ -1343,7 +1343,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
{
name: "Oh My OpenCode",
websiteUrl: "https://github.com/code-yeongyu/oh-my-openagent",
websiteUrl: "https://github.com/code-yeongyu/oh-my-opencode",
settingsConfig: {
npm: "",
options: {},
+1 -17
View File
@@ -613,16 +613,6 @@
"searchSessions": "Search sessions",
"providerFilterAll": "All",
"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...",
"noSessions": "No sessions found",
"selectSession": "Select a session to view details",
@@ -651,12 +641,6 @@
"deleteConfirmAction": "Delete session",
"sessionDeleted": "Session deleted",
"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...",
"emptySession": "No messages available",
"clickToCopyPath": "Click to copy path",
@@ -714,7 +698,7 @@
"aggregatorApiKeyHint": "💡 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",
"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",
"getApiKey": "Get API Key",
"partnerPromotion": {
+1 -17
View File
@@ -613,16 +613,6 @@
"searchSessions": "セッションを検索",
"providerFilterAll": "すべて",
"sessionList": "セッション一覧",
"manageBatchTooltip": "一括管理に入る",
"exitBatchModeTooltip": "一括管理を終了",
"batchModeHint": "削除するセッションを選択",
"selectForBatch": "セッションを選択",
"selectedCount": "{{count}} 件を選択中",
"selectAllFiltered": "一覧を全選択",
"clearFilteredSelection": "全選択を解除",
"clearSelection": "クリア",
"deleteSelected": "削除",
"batchDeleting": "削除中...",
"loadingSessions": "セッションを読み込み中...",
"noSessions": "セッションが見つかりません",
"selectSession": "セッションを選択してください",
@@ -651,12 +641,6 @@
"deleteConfirmAction": "セッションを削除",
"sessionDeleted": "セッションを削除しました",
"deleteFailed": "セッションの削除に失敗しました: {{error}}",
"batchDeleteConfirmTitle": "選択したセッションを削除",
"batchDeleteConfirmMessage": "選択した {{count}} 件のローカルセッションを完全に削除します。\n\nこの操作は元に戻せません。",
"batchDeleteConfirmAction": "選択した項目を削除",
"batchDeleteSuccess": "{{count}} 件のセッションを削除しました",
"batchDeleteFailed": "{{failed}} 件のセッションを削除できませんでした",
"batchDeleteRequestFailed": "一括削除に失敗しました。しばらくしてから再試行してください。",
"loadingMessages": "内容を読み込み中...",
"emptySession": "表示できる内容がありません",
"clickToCopyPath": "クリックしてパスをコピー",
@@ -714,7 +698,7 @@
"aggregatorApiKeyHint": "💡 API Key のみ入力すれば OK。エンドポイントはプリセット済みです",
"thirdPartyApiKeyHint": "💡 API Key のみ入力すれば OK。エンドポイントはプリセット済みです",
"customApiKeyHint": "💡 カスタム設定では必要な項目をすべて手動で入力してください",
"omoHint": "💡 OMO 設定は Agent のモデル割り当てを管理し、oh-my-openagent.jsonc / oh-my-opencode.jsonc の両方に対応します",
"omoHint": "💡 OMO 設定は Agent のモデル割り当てを管理し、oh-my-opencode.jsonc に書き込みます",
"officialHint": "💡 公式プロバイダーはブラウザログインで、API Key は不要です",
"getApiKey": "API Key を取得",
"partnerPromotion": {
+1 -17
View File
@@ -613,16 +613,6 @@
"searchSessions": "搜索会话",
"providerFilterAll": "全部",
"sessionList": "会话列表",
"manageBatchTooltip": "进入批量管理",
"exitBatchModeTooltip": "退出批量管理",
"batchModeHint": "勾选要删除的会话",
"selectForBatch": "选择会话",
"selectedCount": "已选 {{count}} 项",
"selectAllFiltered": "全选当前",
"clearFilteredSelection": "取消全选",
"clearSelection": "清空已选",
"deleteSelected": "批量删除",
"batchDeleting": "删除中...",
"loadingSessions": "加载会话中...",
"noSessions": "未发现会话",
"selectSession": "请选择会话查看详情",
@@ -651,12 +641,6 @@
"deleteConfirmAction": "删除会话",
"sessionDeleted": "会话已删除",
"deleteFailed": "删除会话失败: {{error}}",
"batchDeleteConfirmTitle": "批量删除会话",
"batchDeleteConfirmMessage": "将永久删除已选中的 {{count}} 个本地会话记录。\n\n此操作不可恢复。",
"batchDeleteConfirmAction": "删除所选会话",
"batchDeleteSuccess": "已删除 {{count}} 个会话",
"batchDeleteFailed": "{{failed}} 个会话删除失败",
"batchDeleteRequestFailed": "批量删除失败,请稍后重试",
"loadingMessages": "加载会话内容中...",
"emptySession": "该会话暂无可展示内容",
"clickToCopyPath": "点击复制路径",
@@ -714,7 +698,7 @@
"aggregatorApiKeyHint": "💡 只需填写 API Key,请求地址已预设",
"thirdPartyApiKeyHint": "💡 只需填写 API Key,请求地址已预设",
"customApiKeyHint": "💡 自定义配置需手动填写所有必要字段",
"omoHint": "💡 OMO 配置管理 Agent 模型分配,兼容 oh-my-openagent.jsonc / oh-my-opencode.jsonc",
"omoHint": "💡 OMO 配置管理 Agent 模型分配,写入 oh-my-opencode.jsonc",
"officialHint": "💡 官方供应商使用浏览器登录,无需配置 API Key",
"getApiKey": "获取 API Key",
"partnerPromotion": {
-11
View File
@@ -7,11 +7,6 @@ export interface DeleteSessionOptions {
sourcePath: string;
}
export interface DeleteSessionResult extends DeleteSessionOptions {
success: boolean;
error?: string;
}
export const sessionsApi = {
async list(): Promise<SessionMeta[]> {
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: {
command: string;
cwd?: string | null;
+1 -1
View File
@@ -246,7 +246,7 @@ export const OMO_DISABLEABLE_SKILLS = [
] as const;
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 = `{
"disabled": false,
+8 -151
View File
@@ -1,6 +1,5 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
act,
fireEvent,
render,
screen,
@@ -9,7 +8,6 @@ import {
} from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SessionManagerPage } from "@/components/sessions/SessionManagerPage";
import { sessionsApi } from "@/lib/api/sessions";
import type { SessionMessage, SessionMeta } from "@/types";
import { setSessionFixtures } from "../msw/state";
@@ -64,19 +62,16 @@ const renderPage = () => {
},
});
return {
client,
...render(
<QueryClientProvider client={client}>
<SessionManagerPage appId="codex" />
</QueryClientProvider>,
),
};
return render(
<QueryClientProvider client={client}>
<SessionManagerPage appId="codex" />
</QueryClientProvider>,
);
};
const openSearch = () => {
const searchButton = Array.from(screen.getAllByRole("button")).find(
(button) => button.querySelector(".lucide-search"),
const searchButton = Array.from(screen.getAllByRole("button")).find((button) =>
button.querySelector(".lucide-search"),
);
if (!searchButton) {
@@ -86,23 +81,10 @@ const openSearch = () => {
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", () => {
beforeEach(() => {
toastSuccessMock.mockReset();
toastErrorMock.mockReset();
Element.prototype.scrollIntoView = vi.fn();
const sessions: SessionMeta[] = [
{
@@ -196,136 +178,11 @@ describe("SessionManagerPage", () => {
expect(screen.queryByText("Alpha Session")).not.toBeInTheDocument(),
);
expect(
screen.getByText("sessionManager.selectSession"),
).toBeInTheDocument();
expect(screen.getByText("sessionManager.selectSession")).toBeInTheDocument();
expect(
screen.queryByText("sessionManager.emptySession"),
).not.toBeInTheDocument();
expect(toastErrorMock).not.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();
});
});
+2 -108
View File
@@ -104,12 +104,11 @@ function renderSection(config?: WebDavSyncSettings) {
mutations: { retry: false },
},
});
const view = render(
return render(
<QueryClientProvider client={client}>
<WebdavSyncSection config={config} />
</QueryClientProvider>,
);
return { ...view, client };
}
describe("WebdavSyncSection", () => {
@@ -205,7 +204,7 @@ describe("WebdavSyncSection", () => {
expect.objectContaining({
baseUrl: "https://dav.example.com/dav/",
username: "alice",
password: "",
password: "secret",
autoSync: 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 () => {
renderSection(baseConfig);
-23
View File
@@ -129,29 +129,6 @@ export const handlers = [
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
http.post(`${TAURI_ENDPOINT}/get_mcp_config`, async ({ request }) => {
const { app } = await withJson<{ app: AppId }>(request);