From 38e1f453594d87129f36ed04ed2f3fd0e5b43e3a Mon Sep 17 00:00:00 2001 From: cwj526 Date: Tue, 24 Mar 2026 11:58:25 +0800 Subject: [PATCH 1/3] feat: complete codex module and unify claude/codex install UI --- src-tauri/src/commands/claudecode.rs | 811 +++++++++++++ src-tauri/src/commands/codex.rs | 1024 +++++++++++++++++ src-tauri/src/commands/installer.rs | 2 +- src-tauri/src/commands/mod.rs | 2 + src-tauri/src/commands/process.rs | 114 ++ src-tauri/src/main.rs | 23 +- src/App.tsx | 362 +++--- src/components/ClaudeCode/index.tsx | 474 ++++++++ src/components/Codex/index.tsx | 492 ++++++++ src/components/Dashboard/index.tsx | 52 +- .../InstallUI/InstallActionCard.tsx | 41 + src/components/InstallUI/InstallToolbar.tsx | 16 + src/components/InstallUI/StatusHeaderCard.tsx | 54 + src/components/Layout/Header.tsx | 111 +- src/components/Layout/Sidebar.tsx | 283 ++++- src/components/ModuleDetail/index.tsx | 287 +++++ src/components/ModulePlaceholder/index.tsx | 41 + src/components/Modules/index.tsx | 151 +++ src/components/Settings/index.tsx | 4 +- src/components/Setup/index.tsx | 2 +- src/components/Skills/index.tsx | 2 +- src/lib/logger.ts | 2 +- src/lib/tauri.ts | 188 +++ src/main.tsx | 2 +- src/modules/registry.ts | 98 ++ src/styles/globals.css | 28 + src/types/modules.ts | 31 + 27 files changed, 4411 insertions(+), 286 deletions(-) create mode 100644 src-tauri/src/commands/claudecode.rs create mode 100644 src-tauri/src/commands/codex.rs create mode 100644 src/components/ClaudeCode/index.tsx create mode 100644 src/components/Codex/index.tsx create mode 100644 src/components/InstallUI/InstallActionCard.tsx create mode 100644 src/components/InstallUI/InstallToolbar.tsx create mode 100644 src/components/InstallUI/StatusHeaderCard.tsx create mode 100644 src/components/ModuleDetail/index.tsx create mode 100644 src/components/ModulePlaceholder/index.tsx create mode 100644 src/components/Modules/index.tsx create mode 100644 src/modules/registry.ts create mode 100644 src/types/modules.ts diff --git a/src-tauri/src/commands/claudecode.rs b/src-tauri/src/commands/claudecode.rs new file mode 100644 index 0000000..9955c84 --- /dev/null +++ b/src-tauri/src/commands/claudecode.rs @@ -0,0 +1,811 @@ +use crate::utils::{file, platform, shell}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::BTreeMap; +use std::path::Path; +use tauri::command; + +const CLAUDE_REFERENCE_DIR: &str = "/Users/shuidiyu06/tu/sh.tu-zi.com/sh/install_claude"; +const CLAUDE_MODIFIED_INSTALL_URL: &str = "https://gaccode.com/claudecode/install"; +const CLAUDE_ORIGINAL_PACKAGE: &str = "@anthropic-ai/claude-code"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClaudeRoute { + pub name: String, + pub base_url: Option, + pub has_key: bool, + pub is_current: bool, + pub api_key_masked: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClaudeEnvSummary { + pub anthropic_api_key_masked: Option, + pub anthropic_base_url: Option, + pub anthropic_api_token_set: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClaudeCodeStatus { + pub installed: bool, + pub version: Option, + pub current_route: Option, + pub route_file_exists: bool, + pub routes: Vec, + pub env_summary: ClaudeEnvSummary, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClaudeReferenceDocs { + pub readme_markdown: String, + pub flow_markdown: String, + pub updated_at: Option, + pub error: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClaudeActionResult { + pub success: bool, + pub message: String, + pub error: Option, + pub stdout: String, + pub stderr: String, + pub restart_required: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClaudeRoutesResponse { + pub current_route: Option, + pub routes: Vec, +} + +#[derive(Debug, Clone)] +struct RouteEntry { + api_key: Option, + base_url: Option, + api_token: Option, +} + +#[derive(Debug, Clone)] +struct RouteFileData { + current_route: Option, + routes: BTreeMap, +} + +fn success_result(message: &str, stdout: String, restart_required: bool) -> ClaudeActionResult { + ClaudeActionResult { + success: true, + message: message.to_string(), + error: None, + stdout, + stderr: String::new(), + restart_required, + } +} + +fn error_result(message: &str, error: String, stdout: String) -> ClaudeActionResult { + ClaudeActionResult { + success: false, + message: message.to_string(), + error: Some(error.clone()), + stdout, + stderr: error, + restart_required: false, + } +} + +fn get_claude_route_file_path() -> String { + if let Some(home) = dirs::home_dir() { + if platform::is_windows() { + format!("{}\\.config\\tuzi\\claude_route_status.txt", home.display()) + } else { + format!("{}/.config/tuzi/claude_route_status.txt", home.display()) + } + } else if platform::is_windows() { + String::from("%USERPROFILE%\\.config\\tuzi\\claude_route_status.txt") + } else { + String::from("~/.config/tuzi/claude_route_status.txt") + } +} + +fn get_shell_rc_candidates() -> Vec { + if platform::is_windows() { + return Vec::new(); + } + let mut result = Vec::new(); + if let Some(home) = dirs::home_dir() { + result.push(format!("{}/.zshrc", home.display())); + result.push(format!("{}/.bashrc", home.display())); + } + result +} + +fn parse_route_file(content: &str) -> RouteFileData { + let mut current_route: Option = None; + let mut routes: BTreeMap = BTreeMap::new(); + let mut active_section: Option = None; + + for raw_line in content.lines() { + let line = raw_line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + + if let Some(value) = line.strip_prefix("current_route=") { + let route = value.trim().to_string(); + if !route.is_empty() { + current_route = Some(route); + } + continue; + } + + if line.starts_with('[') && line.ends_with(']') { + let section = line.trim_start_matches('[').trim_end_matches(']').trim().to_string(); + if section.is_empty() { + active_section = None; + } else { + routes.entry(section.clone()).or_insert(RouteEntry { + api_key: None, + base_url: None, + api_token: None, + }); + active_section = Some(section); + } + continue; + } + + let section_name = match &active_section { + Some(value) => value, + None => continue, + }; + + if let Some((key, value)) = line.split_once('=') { + let entry = routes.entry(section_name.clone()).or_insert(RouteEntry { + api_key: None, + base_url: None, + api_token: None, + }); + let parsed_value = value.trim().to_string(); + match key.trim() { + "ANTHROPIC_API_KEY" => entry.api_key = if parsed_value.is_empty() { None } else { Some(parsed_value) }, + "ANTHROPIC_BASE_URL" => entry.base_url = if parsed_value.is_empty() { None } else { Some(parsed_value) }, + "ANTHROPIC_API_TOKEN" => entry.api_token = if parsed_value.is_empty() { None } else { Some(parsed_value) }, + _ => {} + } + } + } + + RouteFileData { + current_route, + routes, + } +} + +fn route_file_to_string(data: &RouteFileData) -> String { + let mut lines: Vec = Vec::new(); + if let Some(current) = &data.current_route { + lines.push(format!("current_route={}", current)); + lines.push(String::new()); + } + + for (name, route) in &data.routes { + lines.push(format!("[{}]", name)); + lines.push(format!( + "ANTHROPIC_API_TOKEN={}", + route.api_token.clone().unwrap_or_default() + )); + lines.push(format!( + "ANTHROPIC_API_KEY={}", + route.api_key.clone().unwrap_or_default() + )); + lines.push(format!( + "ANTHROPIC_BASE_URL={}", + route.base_url.clone().unwrap_or_default() + )); + lines.push(String::new()); + } + + lines.join("\n").trim().to_string() + "\n" +} + +fn read_route_file() -> RouteFileData { + let path = get_claude_route_file_path(); + let content = file::read_file(&path).unwrap_or_default(); + parse_route_file(&content) +} + +fn write_route_file(data: &RouteFileData) -> Result<(), String> { + let path = get_claude_route_file_path(); + let content = route_file_to_string(data); + file::write_file(&path, &content).map_err(|e| format!("写入路线配置失败: {}", e)) +} + +fn mask_key(value: &str) -> String { + if value.is_empty() { + return String::new(); + } + if value.len() <= 8 { + return "****".to_string(); + } + let head = &value[0..4]; + let tail = &value[value.len() - 4..]; + format!("{}****{}", head, tail) +} + +fn apply_env_to_rc(api_key: &str, base_url: &str, api_token: &str) -> Result, String> { + if platform::is_windows() { + return Ok(Vec::new()); + } + + let rc_paths = get_shell_rc_candidates(); + if rc_paths.is_empty() { + return Err("无法定位 shell 配置文件".to_string()); + } + + let mut updated: Vec = Vec::new(); + for rc_path in rc_paths { + let content = file::read_file(&rc_path).unwrap_or_default(); + let filtered_lines: Vec = content + .lines() + .filter(|line| { + let trimmed = line.trim_start(); + !(trimmed.starts_with("export ANTHROPIC_API_TOKEN=") + || trimmed.starts_with("export ANTHROPIC_API_KEY=") + || trimmed.starts_with("export ANTHROPIC_BASE_URL=")) + }) + .map(|line| line.to_string()) + .collect(); + + let mut lines = filtered_lines; + lines.push(format!("export ANTHROPIC_API_TOKEN=\"{}\"", api_token)); + lines.push(format!("export ANTHROPIC_API_KEY=\"{}\"", api_key)); + lines.push(format!("export ANTHROPIC_BASE_URL=\"{}\"", base_url)); + + file::write_file(&rc_path, &lines.join("\n")) + .map_err(|e| format!("写入 {} 失败: {}", rc_path, e))?; + updated.push(rc_path); + } + + Ok(updated) +} + +fn clear_env_in_rc() -> Result, String> { + if platform::is_windows() { + return Ok(Vec::new()); + } + + let rc_paths = get_shell_rc_candidates(); + if rc_paths.is_empty() { + return Err("无法定位 shell 配置文件".to_string()); + } + + let mut updated: Vec = Vec::new(); + for rc_path in rc_paths { + let content = file::read_file(&rc_path).unwrap_or_default(); + let filtered_lines: Vec = content + .lines() + .filter(|line| { + let trimmed = line.trim_start(); + !(trimmed.starts_with("export ANTHROPIC_API_TOKEN=") + || trimmed.starts_with("export ANTHROPIC_API_KEY=") + || trimmed.starts_with("export ANTHROPIC_BASE_URL=")) + }) + .map(|line| line.to_string()) + .collect(); + + file::write_file(&rc_path, &filtered_lines.join("\n")) + .map_err(|e| format!("清理 {} 中的 ANTHROPIC 变量失败: {}", rc_path, e))?; + updated.push(rc_path); + } + Ok(updated) +} + +fn ensure_claude_json_onboarding() -> Result<(), String> { + let home = dirs::home_dir().ok_or_else(|| "无法定位用户目录".to_string())?; + let claude_json_path = if platform::is_windows() { + format!("{}\\.claude.json", home.display()) + } else { + format!("{}/.claude.json", home.display()) + }; + + let existing = file::read_file(&claude_json_path).unwrap_or_else(|_| "{}".to_string()); + let mut parsed: Value = serde_json::from_str(&existing).unwrap_or_else(|_| json!({})); + if !parsed.is_object() { + parsed = json!({}); + } + parsed["hasCompletedOnboarding"] = Value::Bool(true); + let serialized = serde_json::to_string_pretty(&parsed) + .map_err(|e| format!("序列化 ~/.claude.json 失败: {}", e))?; + file::write_file(&claude_json_path, &serialized) + .map_err(|e| format!("写入 ~/.claude.json 失败: {}", e)) +} + +fn run_npm_global(command: &str) -> Result { + shell::run_script_output(command) +} + +fn resolve_install_api_key(route_data: &RouteFileData, route_name: &str, provided: Option) -> Option { + if let Some(value) = provided { + let trimmed = value.trim().to_string(); + if !trimmed.is_empty() { + return Some(trimmed); + } + } + + if let Some(route) = route_data.routes.get(route_name) { + if let Some(value) = &route.api_key { + if !value.trim().is_empty() { + return Some(value.trim().to_string()); + } + } + } + + if let Ok(value) = std::env::var("ANTHROPIC_API_KEY") { + let trimmed = value.trim().to_string(); + if !trimmed.is_empty() { + return Some(trimmed); + } + } + + None +} + +fn build_routes_response(data: &RouteFileData) -> ClaudeRoutesResponse { + let routes = data + .routes + .iter() + .map(|(name, route)| { + let api_key = route.api_key.clone().unwrap_or_default(); + ClaudeRoute { + name: name.clone(), + base_url: route.base_url.clone(), + has_key: !api_key.trim().is_empty(), + is_current: data.current_route.as_deref() == Some(name.as_str()), + api_key_masked: if api_key.trim().is_empty() { + None + } else { + Some(mask_key(&api_key)) + }, + } + }) + .collect::>(); + + ClaudeRoutesResponse { + current_route: data.current_route.clone(), + routes, + } +} + +#[command] +pub async fn get_claudecode_status() -> Result { + let installed = shell::command_exists("claude"); + let version = if installed { + shell::run_command_output("claude", &["--version"]).ok() + } else { + None + }; + + let route_file_path = get_claude_route_file_path(); + let route_file_exists = Path::new(&route_file_path).exists(); + let route_data = read_route_file(); + let routes_response = build_routes_response(&route_data); + + let env_api_key = std::env::var("ANTHROPIC_API_KEY").ok().unwrap_or_default(); + let env_base_url = std::env::var("ANTHROPIC_BASE_URL").ok().unwrap_or_default(); + let env_api_token = std::env::var("ANTHROPIC_API_TOKEN").ok().unwrap_or_default(); + + Ok(ClaudeCodeStatus { + installed, + version, + current_route: routes_response.current_route, + route_file_exists, + routes: routes_response.routes, + env_summary: ClaudeEnvSummary { + anthropic_api_key_masked: if env_api_key.trim().is_empty() { + None + } else { + Some(mask_key(env_api_key.trim())) + }, + anthropic_base_url: if env_base_url.trim().is_empty() { + None + } else { + Some(env_base_url) + }, + anthropic_api_token_set: !env_api_token.trim().is_empty(), + }, + }) +} + +#[command] +pub async fn get_claude_install_reference() -> Result { + let readme_path = format!("{}/README_INSTALL_CLAUDE.md", CLAUDE_REFERENCE_DIR); + let flow_path = format!("{}/install_claude_flow.md", CLAUDE_REFERENCE_DIR); + + let mut errors: Vec = Vec::new(); + let readme_markdown = match file::read_file(&readme_path) { + Ok(content) => content, + Err(e) => { + errors.push(format!("读取 README 失败: {}", e)); + String::new() + } + }; + let flow_markdown = match file::read_file(&flow_path) { + Ok(content) => content, + Err(e) => { + errors.push(format!("读取流程文档失败: {}", e)); + String::new() + } + }; + + let updated_at = std::fs::metadata(&flow_path) + .ok() + .and_then(|meta| meta.modified().ok()) + .map(|time| { + let datetime: chrono::DateTime = time.into(); + datetime.to_rfc3339() + }); + + Ok(ClaudeReferenceDocs { + readme_markdown, + flow_markdown, + updated_at, + error: if errors.is_empty() { + None + } else { + Some(errors.join(";")) + }, + }) +} + +#[command] +pub async fn list_claude_routes() -> Result { + let data = read_route_file(); + Ok(build_routes_response(&data)) +} + +#[command] +pub async fn switch_claude_route(route_name: String) -> Result { + let route_name = route_name.trim().to_string(); + if route_name.is_empty() { + return Ok(error_result( + "路线切换失败", + "路线名称不能为空".to_string(), + String::new(), + )); + } + + let mut data = read_route_file(); + let route = match data.routes.get(&route_name) { + Some(value) => value.clone(), + None => { + return Ok(error_result( + "路线切换失败", + format!("路线不存在: {}", route_name), + String::new(), + )) + } + }; + + data.current_route = Some(route_name.clone()); + write_route_file(&data)?; + + let mut op_logs = vec![format!("switch_claude_route route={}", route_name)]; + op_logs.push(format!("已写入路线配置: {}", get_claude_route_file_path())); + if route_name != "改版" { + let key = route.api_key.clone().unwrap_or_default(); + let base_url = route.base_url.clone().unwrap_or_default(); + let token = route.api_token.clone().unwrap_or_default(); + if !key.is_empty() && !base_url.is_empty() { + let rc_paths = apply_env_to_rc(&key, &base_url, &token)?; + for path in rc_paths { + op_logs.push(format!("已更新环境变量: {}", path)); + } + ensure_claude_json_onboarding()?; + op_logs.push("已更新 ~/.claude.json".to_string()); + } + } + + Ok(success_result( + "路线切换成功,请重新打开终端后执行 claude", + op_logs.join("\n"), + true, + )) +} + +#[command] +pub async fn add_claude_route( + route_name: String, + base_url: String, + api_key: String, +) -> Result { + let route_name = route_name.trim().to_string(); + let base_url = base_url.trim().to_string(); + let api_key = api_key.trim().to_string(); + + if route_name.is_empty() || base_url.is_empty() || api_key.is_empty() { + return Ok(error_result( + "添加路线失败", + "路线名、Base URL、API Key 不能为空".to_string(), + String::new(), + )); + } + + if route_name == "改版" { + return Ok(error_result( + "添加路线失败", + "路线名不能为“改版”".to_string(), + String::new(), + )); + } + + let mut data = read_route_file(); + if data.routes.contains_key(&route_name) { + return Ok(error_result( + "添加路线失败", + format!("路线已存在: {}", route_name), + String::new(), + )); + } + + data.routes.insert( + route_name.clone(), + RouteEntry { + api_key: Some(api_key.clone()), + base_url: Some(base_url.clone()), + api_token: Some(String::new()), + }, + ); + data.current_route = Some(route_name.clone()); + write_route_file(&data)?; + let rc_paths = apply_env_to_rc(&api_key, &base_url, "")?; + ensure_claude_json_onboarding()?; + let mut logs = vec![ + format!("add_claude_route route={}", route_name), + format!("已写入路线配置: {}", get_claude_route_file_path()), + "已更新 ~/.claude.json".to_string(), + ]; + for path in rc_paths { + logs.push(format!("已更新环境变量: {}", path)); + } + + Ok(success_result( + "路线已添加并切换,请重新打开终端生效", + logs.join("\n"), + true, + )) +} + +#[command] +pub async fn update_claude_route_key( + route_name: String, + api_key: String, +) -> Result { + let route_name = route_name.trim().to_string(); + let api_key = api_key.trim().to_string(); + if route_name.is_empty() || api_key.is_empty() { + return Ok(error_result( + "更新 API Key 失败", + "路线名和 API Key 不能为空".to_string(), + String::new(), + )); + } + + let mut data = read_route_file(); + let (base_url_for_env, token_for_env) = match data.routes.get_mut(&route_name) { + Some(value) => { + value.api_key = Some(api_key.clone()); + ( + value.base_url.clone().unwrap_or_default(), + value.api_token.clone().unwrap_or_default(), + ) + } + None => { + return Ok(error_result( + "更新 API Key 失败", + format!("路线不存在: {}", route_name), + String::new(), + )) + } + }; + + if route_name == "改版" { + return Ok(error_result( + "更新 API Key 失败", + "改版路线不支持设置 API Key".to_string(), + String::new(), + )); + } + + write_route_file(&data)?; + + if data.current_route.as_deref() == Some(route_name.as_str()) { + let rc_paths = apply_env_to_rc(&api_key, &base_url_for_env, &token_for_env)?; + let mut logs = vec![ + format!("update_claude_route_key route={}", route_name), + format!("已写入路线配置: {}", get_claude_route_file_path()), + ]; + for path in rc_paths { + logs.push(format!("已更新环境变量: {}", path)); + } + return Ok(success_result( + "API Key 更新成功,请重新打开终端生效", + logs.join("\n"), + true, + )); + } + + Ok(success_result( + "API Key 更新成功,请重新打开终端生效", + format!( + "update_claude_route_key route={}\n已写入路线配置: {}", + route_name, + get_claude_route_file_path() + ), + true, + )) +} + +#[command] +pub async fn install_claudecode( + scheme: String, + api_key: Option, +) -> Result { + let normalized = scheme.trim().to_uppercase(); + let mut data = read_route_file(); + + if normalized == "A" { + let command = format!("npm install -g {}", CLAUDE_MODIFIED_INSTALL_URL); + let output = match run_npm_global(&command) { + Ok(value) => value, + Err(e) => return Ok(error_result("ClaudeCode 安装失败", e, String::new())), + }; + + data.routes.insert( + "改版".to_string(), + RouteEntry { + api_key: None, + base_url: None, + api_token: None, + }, + ); + data.current_route = Some("改版".to_string()); + write_route_file(&data)?; + let logs = vec![ + format!("$ {}", command), + output, + format!("已写入路线配置: {}", get_claude_route_file_path()), + ]; + return Ok(success_result( + "改版 ClaudeCode 安装成功", + logs.join("\n"), + false, + )); + } + + if normalized != "B" && normalized != "C" { + return Ok(error_result( + "ClaudeCode 安装失败", + format!("未知安装方案: {}", scheme), + String::new(), + )); + } + + let route_name = if normalized == "B" { "gaccode" } else { "tu-zi" }; + let base_url = if normalized == "B" { + "https://gaccode.com/claudecode" + } else { + "https://api.tu-zi.com" + }; + let final_key = match resolve_install_api_key(&data, route_name, api_key) { + Some(value) => value, + None => { + return Ok(error_result( + "ClaudeCode 安装失败", + "该方案需要 API Key,请在页面输入后重试".to_string(), + String::new(), + )) + } + }; + + let command = format!("npm install -g {}", CLAUDE_ORIGINAL_PACKAGE); + let output = match run_npm_global(&command) { + Ok(value) => value, + Err(e) => return Ok(error_result("ClaudeCode 安装失败", e, String::new())), + }; + + data.routes.insert( + route_name.to_string(), + RouteEntry { + api_key: Some(final_key.clone()), + base_url: Some(base_url.to_string()), + api_token: Some(String::new()), + }, + ); + data.current_route = Some(route_name.to_string()); + write_route_file(&data)?; + let rc_paths = apply_env_to_rc(&final_key, base_url, "")?; + ensure_claude_json_onboarding()?; + let mut logs = vec![ + format!("$ {}", command), + output, + format!("已写入路线配置: {}", get_claude_route_file_path()), + "已更新 ~/.claude.json".to_string(), + ]; + for path in rc_paths { + logs.push(format!("已更新环境变量: {}", path)); + } + + Ok(success_result( + format!("方案 {} 安装成功,请重开终端后运行 claude", normalized).as_str(), + logs.join("\n"), + true, + )) +} + +#[command] +pub async fn upgrade_claudecode(target_variant: Option) -> Result { + let variant = if let Some(value) = target_variant { + value.trim().to_lowercase() + } else { + let route_data = read_route_file(); + let current_route = route_data.current_route.unwrap_or_default(); + if current_route == "改版" { + "modified".to_string() + } else { + "original".to_string() + } + }; + + let command = if variant == "modified" || variant == "a" || variant == "改版" { + format!("npm install -g {}", CLAUDE_MODIFIED_INSTALL_URL) + } else { + format!("npm install -g {}@latest", CLAUDE_ORIGINAL_PACKAGE) + }; + + let message = if variant == "modified" || variant == "a" || variant == "改版" { + "ClaudeCode 改版升级成功" + } else { + "ClaudeCode 原版升级成功" + }; + + match run_npm_global(&command) { + Ok(output) => Ok(success_result( + message, + format!("$ {}\n{}", command, output), + false, + )), + Err(e) => Ok(error_result("ClaudeCode 升级失败", e, String::new())), + } +} + +#[command] +pub async fn uninstall_claudecode(clear_config: bool) -> Result { + let command = format!("npm uninstall -g {}", CLAUDE_ORIGINAL_PACKAGE); + let output = match run_npm_global(&command) { + Ok(value) => value, + Err(e) => return Ok(error_result("ClaudeCode 卸载失败", e, String::new())), + }; + + if clear_config { + let route_file_path = get_claude_route_file_path(); + if Path::new(&route_file_path).exists() { + let _ = std::fs::remove_file(&route_file_path); + } + let _ = clear_env_in_rc(); + } + + let mut logs = vec![format!("$ {}", command), output]; + if clear_config { + logs.push(format!("已删除路线配置: {}", get_claude_route_file_path())); + } + + Ok(success_result( + if clear_config { + "ClaudeCode 已卸载,配置已清理" + } else { + "ClaudeCode 已卸载,配置已保留" + }, + logs.join("\n"), + false, + )) +} diff --git a/src-tauri/src/commands/codex.rs b/src-tauri/src/commands/codex.rs new file mode 100644 index 0000000..5bdb14f --- /dev/null +++ b/src-tauri/src/commands/codex.rs @@ -0,0 +1,1024 @@ +use crate::utils::{file, platform, shell}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::path::Path; +use tauri::command; + +const CODEX_OPENAI_PACKAGE: &str = "@openai/codex"; +const CODEX_GAC_INSTALL_URL: &str = "https://gaccode.com/codex/install"; +const CODEX_REFERENCE_SCRIPT_PATH: &str = "/Users/shuidiyu06/tu/sh.tu-zi.com/sh/setup_codex/install_codex.sh"; +const DEFAULT_MODEL: &str = "gpt-5.4"; +const DEFAULT_REASONING: &str = "medium"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CodexModelSettings { + pub model: String, + pub model_reasoning_effort: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CodexRoute { + pub name: String, + pub base_url: Option, + pub has_key: bool, + pub is_current: bool, + pub api_key_masked: Option, + pub model_settings: CodexModelSettings, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CodexEnvSummary { + pub codex_api_key_masked: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CodexStatus { + pub installed: bool, + pub version: Option, + pub install_type: Option, + pub current_route: Option, + pub state_file_exists: bool, + pub config_file_exists: bool, + pub routes: Vec, + pub env_summary: CodexEnvSummary, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CodexReferenceDocs { + pub script_markdown: String, + pub updated_at: Option, + pub error: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CodexActionResult { + pub success: bool, + pub message: String, + pub error: Option, + pub stdout: String, + pub stderr: String, + pub restart_required: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CodexRoutesResponse { + pub current_route: Option, + pub routes: Vec, +} + +#[derive(Debug, Clone)] +struct InstallState { + install_type: Option, + route: Option, +} + +#[derive(Debug, Clone, Default)] +struct ConfigRouteEntry { + base_url: Option, + model: Option, + model_reasoning_effort: Option, +} + +#[derive(Debug, Clone, Default)] +struct ParsedCodexConfig { + profile: Option, + routes: BTreeMap, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ConfigSection { + None, + ModelProvider, + Profile, +} + +fn success_result(message: &str, stdout: String, restart_required: bool) -> CodexActionResult { + CodexActionResult { + success: true, + message: message.to_string(), + error: None, + stdout, + stderr: String::new(), + restart_required, + } +} + +fn error_result(message: &str, error: String, stdout: String) -> CodexActionResult { + CodexActionResult { + success: false, + message: message.to_string(), + error: Some(error.clone()), + stdout, + stderr: error, + restart_required: false, + } +} + +fn get_codex_dir() -> String { + if let Some(home) = dirs::home_dir() { + if platform::is_windows() { + format!("{}\\.codex", home.display()) + } else { + format!("{}/.codex", home.display()) + } + } else if platform::is_windows() { + "%USERPROFILE%\\.codex".to_string() + } else { + "~/.codex".to_string() + } +} + +fn get_codex_config_file_path() -> String { + if platform::is_windows() { + format!("{}\\config.toml", get_codex_dir()) + } else { + format!("{}/config.toml", get_codex_dir()) + } +} + +fn get_codex_state_file_path() -> String { + if platform::is_windows() { + format!("{}\\install_state", get_codex_dir()) + } else { + format!("{}/install_state", get_codex_dir()) + } +} + +fn normalize_install_type(value: &str) -> Option { + let lower = value.trim().to_lowercase(); + match lower.as_str() { + "openai" | "gac" => Some(lower), + _ => None, + } +} + +fn normalize_route(value: &str) -> Option { + let lower = value.trim().to_lowercase(); + match lower.as_str() { + "gac" | "tuzi" => Some(lower), + "none" | "" => None, + _ => None, + } +} + +fn route_base_url(route: &str) -> Option<&'static str> { + match route { + "gac" => Some("https://gaccode.com/codex/v1"), + "tuzi" => Some("https://api.tu-zi.com/v1"), + _ => None, + } +} + +fn mask_key(value: &str) -> String { + if value.is_empty() { + return String::new(); + } + if value.len() <= 8 { + return "****".to_string(); + } + let head = &value[0..4]; + let tail = &value[value.len() - 4..]; + format!("{}****{}", head, tail) +} + +fn parse_install_state(content: &str) -> InstallState { + let mut install_type: Option = None; + let mut route: Option = None; + + for raw in content.lines() { + let line = raw.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + + if let Some(value) = line.strip_prefix("INSTALL_TYPE=") { + install_type = normalize_install_type(value); + continue; + } + + if let Some(value) = line.strip_prefix("ROUTE=") { + route = normalize_route(value); + } + } + + InstallState { install_type, route } +} + +fn load_install_state() -> InstallState { + let path = get_codex_state_file_path(); + let content = file::read_file(&path).unwrap_or_default(); + parse_install_state(&content) +} + +fn save_install_state(install_type: &str, route: Option<&str>) -> Result<(), String> { + let install_type = normalize_install_type(install_type) + .ok_or_else(|| format!("非法安装类型: {}", install_type))?; + let route_value = route + .and_then(normalize_route) + .unwrap_or_else(|| "none".to_string()); + + let content = format!( + "INSTALL_TYPE={}\nROUTE={}\nMANAGED_BY=sh.tu-zi.com\n", + install_type, route_value + ); + + file::write_file(&get_codex_state_file_path(), &content) + .map_err(|e| format!("写入安装状态失败: {}", e)) +} + +fn clear_install_state() { + let path = get_codex_state_file_path(); + if Path::new(&path).exists() { + let _ = std::fs::remove_file(path); + } +} + +fn parse_codex_config(content: &str) -> ParsedCodexConfig { + let mut parsed = ParsedCodexConfig::default(); + let mut section = ConfigSection::None; + let mut section_route: Option = None; + + for raw in content.lines() { + let line = raw.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + + if line.starts_with('[') && line.ends_with(']') { + section = ConfigSection::None; + section_route = None; + + let section_name = line.trim_start_matches('[').trim_end_matches(']'); + if let Some(route) = section_name.strip_prefix("model_providers.") { + if let Some(valid_route) = normalize_route(route) { + section = ConfigSection::ModelProvider; + section_route = Some(valid_route.clone()); + parsed.routes.entry(valid_route).or_default(); + } + } else if let Some(route) = section_name.strip_prefix("profiles.") { + if let Some(valid_route) = normalize_route(route) { + section = ConfigSection::Profile; + section_route = Some(valid_route.clone()); + parsed.routes.entry(valid_route).or_default(); + } + } + continue; + } + + if let Some((key, value_raw)) = line.split_once('=') { + let key = key.trim(); + let value = value_raw.trim().trim_matches('"').to_string(); + + if key == "profile" { + parsed.profile = normalize_route(&value); + continue; + } + + let Some(route) = §ion_route else { + continue; + }; + + let entry = parsed.routes.entry(route.clone()).or_default(); + match section { + ConfigSection::ModelProvider => { + if key == "base_url" && !value.is_empty() { + entry.base_url = Some(value); + } + } + ConfigSection::Profile => { + if key == "model" && !value.is_empty() { + entry.model = Some(value); + } else if key == "model_reasoning_effort" && !value.is_empty() { + entry.model_reasoning_effort = Some(value); + } + } + ConfigSection::None => {} + } + } + } + + parsed +} + +fn filter_codex_config(existing_content: &str) -> String { + let mut lines: Vec = Vec::new(); + let mut skipping_codex_sections = false; + + for raw in existing_content.lines() { + let trimmed = raw.trim(); + + if trimmed.starts_with('[') && trimmed.ends_with(']') { + let section = trimmed.trim_start_matches('[').trim_end_matches(']'); + let should_skip = matches!( + section, + "model_providers.gac" + | "model_providers.tuzi" + | "profiles.gac" + | "profiles.tuzi" + ); + skipping_codex_sections = should_skip; + if should_skip { + continue; + } + } + + if skipping_codex_sections { + continue; + } + + if trimmed.starts_with("profile") && trimmed.contains('=') { + continue; + } + + lines.push(raw.to_string()); + } + + lines.join("\n").trim().to_string() +} + +fn write_codex_config(route: &str, model: &str, model_reasoning_effort: &str) -> Result<(), String> { + let base_url = route_base_url(route).ok_or_else(|| format!("未知 route: {}", route))?; + let config_path = get_codex_config_file_path(); + let existing = file::read_file(&config_path).unwrap_or_default(); + let filtered = filter_codex_config(&existing); + + let mut output = format!("profile = \"{}\"\n\n", route); + if !filtered.is_empty() { + output.push_str(filtered.as_str()); + output.push_str("\n\n"); + } + + output.push_str(format!( + "[model_providers.{route}]\nname = \"{route}\"\nbase_url = \"{base_url}\"\nwire_api = \"responses\"\nenv_key = \"CODEX_API_KEY\"\n\n[profiles.{route}]\nmodel_provider = \"{route}\"\nmodel = \"{model}\"\nmodel_reasoning_effort = \"{reasoning}\"\napproval_policy = \"on-request\"\n", + route = route, + base_url = base_url, + model = model, + reasoning = model_reasoning_effort, + ).as_str()); + + file::write_file(&config_path, &output).map_err(|e| format!("写入 config.toml 失败: {}", e)) +} + +fn get_shell_rc_candidates() -> Vec { + if platform::is_windows() { + return Vec::new(); + } + + let mut paths = Vec::new(); + if let Some(home) = dirs::home_dir() { + paths.push(format!("{}/.zshrc", home.display())); + paths.push(format!("{}/.bashrc", home.display())); + } + paths +} + +fn apply_env_to_rc(api_key: &str) -> Result, String> { + if platform::is_windows() { + return Ok(Vec::new()); + } + + let rc_paths = get_shell_rc_candidates(); + if rc_paths.is_empty() { + return Err("无法定位 shell 配置文件".to_string()); + } + + let mut updated = Vec::new(); + for rc_path in rc_paths { + let content = file::read_file(&rc_path).unwrap_or_default(); + let filtered_lines: Vec = content + .lines() + .filter(|line| { + let trimmed = line.trim_start(); + !trimmed.starts_with("export CODEX_API_KEY=") + }) + .map(|line| line.to_string()) + .collect(); + + let mut lines = filtered_lines; + lines.push(format!("export CODEX_API_KEY=\"{}\"", api_key)); + + file::write_file(&rc_path, &lines.join("\n")) + .map_err(|e| format!("写入 {} 失败: {}", rc_path, e))?; + updated.push(rc_path); + } + + Ok(updated) +} + +fn clear_env_in_rc() -> Result, String> { + if platform::is_windows() { + return Ok(Vec::new()); + } + + let rc_paths = get_shell_rc_candidates(); + if rc_paths.is_empty() { + return Err("无法定位 shell 配置文件".to_string()); + } + + let mut updated = Vec::new(); + for rc_path in rc_paths { + let content = file::read_file(&rc_path).unwrap_or_default(); + let filtered_lines: Vec = content + .lines() + .filter(|line| { + let trimmed = line.trim_start(); + !trimmed.starts_with("export CODEX_API_KEY=") + }) + .map(|line| line.to_string()) + .collect(); + + file::write_file(&rc_path, &filtered_lines.join("\n")) + .map_err(|e| format!("清理 {} 中的 CODEX_API_KEY 失败: {}", rc_path, e))?; + updated.push(rc_path); + } + + Ok(updated) +} + +fn run_npm_global(command: &str) -> Result { + shell::run_script_output(command) +} + +fn resolve_model_settings( + model: Option, + model_reasoning_effort: Option, + fallback_model: Option<&str>, + fallback_reasoning: Option<&str>, +) -> CodexModelSettings { + let final_model = model + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + .or_else(|| fallback_model.map(|v| v.to_string())) + .unwrap_or_else(|| DEFAULT_MODEL.to_string()); + + let final_reasoning = model_reasoning_effort + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + .or_else(|| fallback_reasoning.map(|v| v.to_string())) + .unwrap_or_else(|| DEFAULT_REASONING.to_string()); + + CodexModelSettings { + model: final_model, + model_reasoning_effort: final_reasoning, + } +} + +fn configure_openai_route( + route: &str, + api_key: &str, + model: Option, + model_reasoning_effort: Option, +) -> Result, String> { + let normalized_route = normalize_route(route) + .ok_or_else(|| format!("非法路线: {}(仅支持 gac / tuzi)", route))?; + if api_key.trim().is_empty() { + return Err("切换路线需要提供 API Key".to_string()); + } + + let current_config = parse_codex_config(&file::read_file(&get_codex_config_file_path()).unwrap_or_default()); + let existing_entry = current_config.routes.get(&normalized_route); + let settings = resolve_model_settings( + model, + model_reasoning_effort, + existing_entry.and_then(|v| v.model.as_deref()), + existing_entry.and_then(|v| v.model_reasoning_effort.as_deref()), + ); + + write_codex_config( + &normalized_route, + &settings.model, + &settings.model_reasoning_effort, + )?; + let rc_paths = apply_env_to_rc(api_key.trim())?; + save_install_state("openai", Some(&normalized_route))?; + + let mut logs = vec![ + format!("已写入配置: {}", get_codex_config_file_path()), + format!("已写入状态: {}", get_codex_state_file_path()), + format!( + "路线={} model={} reasoning={}", + normalized_route, settings.model, settings.model_reasoning_effort + ), + ]; + for path in rc_paths { + logs.push(format!("已更新环境变量: {}", path)); + } + + Ok(logs) +} + +fn derive_current_route(state: &InstallState, config: &ParsedCodexConfig) -> Option { + if let Some(route) = &state.route { + return Some(route.clone()); + } + config.profile.clone() +} + +fn build_routes( + current_route: Option<&str>, + config: &ParsedCodexConfig, + env_api_key: &str, +) -> Vec { + ["gac", "tuzi"] + .iter() + .map(|route_name| { + let config_entry = config.routes.get(*route_name); + let settings = resolve_model_settings( + None, + None, + config_entry.and_then(|v| v.model.as_deref()), + config_entry.and_then(|v| v.model_reasoning_effort.as_deref()), + ); + let is_current = current_route == Some(*route_name); + CodexRoute { + name: (*route_name).to_string(), + base_url: config_entry + .and_then(|v| v.base_url.clone()) + .or_else(|| route_base_url(route_name).map(|v| v.to_string())), + has_key: is_current && !env_api_key.trim().is_empty(), + is_current, + api_key_masked: if is_current && !env_api_key.trim().is_empty() { + Some(mask_key(env_api_key.trim())) + } else { + None + }, + model_settings: settings, + } + }) + .collect() +} + +#[command] +pub async fn get_codex_status() -> Result { + let installed = shell::command_exists("codex"); + let version = if installed { + shell::run_command_output("codex", &["--version"]).ok() + } else { + None + }; + + let state_path = get_codex_state_file_path(); + let config_path = get_codex_config_file_path(); + let state_file_exists = Path::new(&state_path).exists(); + let config_file_exists = Path::new(&config_path).exists(); + + let state = load_install_state(); + let config = parse_codex_config(&file::read_file(&config_path).unwrap_or_default()); + let current_route = derive_current_route(&state, &config); + let env_api_key = std::env::var("CODEX_API_KEY").ok().unwrap_or_default(); + let routes = build_routes(current_route.as_deref(), &config, &env_api_key); + + let install_type = state.install_type.or_else(|| { + if installed { + Some("unknown".to_string()) + } else { + None + } + }); + + Ok(CodexStatus { + installed, + version, + install_type, + current_route, + state_file_exists, + config_file_exists, + routes, + env_summary: CodexEnvSummary { + codex_api_key_masked: if env_api_key.trim().is_empty() { + None + } else { + Some(mask_key(env_api_key.trim())) + }, + }, + }) +} + +#[command] +pub async fn get_codex_install_reference() -> Result { + let script_markdown = match file::read_file(CODEX_REFERENCE_SCRIPT_PATH) { + Ok(content) => content, + Err(e) => { + return Ok(CodexReferenceDocs { + script_markdown: String::new(), + updated_at: None, + error: Some(format!("读取 install_codex.sh 失败: {}", e)), + }) + } + }; + + let updated_at = std::fs::metadata(CODEX_REFERENCE_SCRIPT_PATH) + .ok() + .and_then(|meta| meta.modified().ok()) + .map(|time| { + let datetime: chrono::DateTime = time.into(); + datetime.to_rfc3339() + }); + + Ok(CodexReferenceDocs { + script_markdown, + updated_at, + error: None, + }) +} + +#[command] +pub async fn list_codex_routes() -> Result { + let status = get_codex_status().await?; + Ok(CodexRoutesResponse { + current_route: status.current_route, + routes: status.routes, + }) +} + +#[command] +pub async fn install_codex( + variant: String, + route: Option, + api_key: Option, + model: Option, + model_reasoning_effort: Option, +) -> Result { + let normalized_variant = variant.trim().to_lowercase(); + if normalized_variant != "openai" && normalized_variant != "gac" { + return Ok(error_result( + "Codex 安装失败", + format!("未知安装类型: {}", variant), + String::new(), + )); + } + + if normalized_variant == "gac" { + let command = format!("npm install -g {}", CODEX_GAC_INSTALL_URL); + match run_npm_global(&command) { + Ok(output) => { + save_install_state("gac", None)?; + return Ok(success_result( + "gac 改版 Codex 安装成功", + format!("$ {}\n{}", command, output), + true, + )); + } + Err(e) => { + return Ok(error_result("Codex 安装失败", e, String::new())); + } + } + } + + let install_command = format!("npm install -g {}", CODEX_OPENAI_PACKAGE); + let install_output = match run_npm_global(&install_command) { + Ok(value) => value, + Err(e) => return Ok(error_result("Codex 安装失败", e, String::new())), + }; + + let mut logs = vec![format!("$ {}", install_command), install_output]; + + if let Some(selected_route) = route { + let key = api_key.unwrap_or_default(); + match configure_openai_route(&selected_route, &key, model, model_reasoning_effort) { + Ok(route_logs) => { + logs.extend(route_logs); + return Ok(success_result( + "原版 Codex 安装并配置成功,请重开终端后执行 codex", + logs.join("\n"), + true, + )); + } + Err(e) => { + return Ok(error_result( + "Codex 安装成功,但路线配置失败", + e, + logs.join("\n"), + )); + } + } + } + + save_install_state("openai", None)?; + logs.push(format!("已写入状态: {}", get_codex_state_file_path())); + + Ok(success_result( + "原版 Codex 安装成功", + logs.join("\n"), + false, + )) +} + +#[command] +pub async fn switch_codex_route( + route_name: String, + api_key: String, + model: Option, + model_reasoning_effort: Option, +) -> Result { + let state = load_install_state(); + if state.install_type.as_deref() == Some("gac") { + return Ok(error_result( + "路线切换失败", + "只有原版 Codex 才支持路线切换".to_string(), + String::new(), + )); + } + + match configure_openai_route( + route_name.trim(), + api_key.trim(), + model, + model_reasoning_effort, + ) { + Ok(logs) => Ok(success_result( + "路线切换成功,请重开终端后执行 codex", + logs.join("\n"), + true, + )), + Err(e) => Ok(error_result("路线切换失败", e, String::new())), + } +} + +#[command] +pub async fn set_codex_route_model( + route_name: String, + model: String, + model_reasoning_effort: Option, +) -> Result { + let state = load_install_state(); + if state.install_type.as_deref() == Some("gac") { + return Ok(error_result( + "模型参数更新失败", + "只有原版 Codex 才支持路线模型设置".to_string(), + String::new(), + )); + } + + let normalized_route = match normalize_route(route_name.trim()) { + Some(v) => v, + None => { + return Ok(error_result( + "模型参数更新失败", + "仅支持 gac 或 tuzi 路线".to_string(), + String::new(), + )) + } + }; + + let config = parse_codex_config(&file::read_file(&get_codex_config_file_path()).unwrap_or_default()); + let existing = config.routes.get(&normalized_route); + let settings = resolve_model_settings( + Some(model), + model_reasoning_effort, + existing.and_then(|v| v.model.as_deref()), + existing.and_then(|v| v.model_reasoning_effort.as_deref()), + ); + + if settings.model.trim().is_empty() { + return Ok(error_result( + "模型参数更新失败", + "model 不能为空".to_string(), + String::new(), + )); + } + + if let Err(e) = write_codex_config( + &normalized_route, + &settings.model, + &settings.model_reasoning_effort, + ) { + return Ok(error_result("模型参数更新失败", e, String::new())); + } + + save_install_state("openai", Some(&normalized_route))?; + + Ok(success_result( + "模型参数更新成功", + format!( + "route={} model={} reasoning={}\n已写入: {}", + normalized_route, + settings.model, + settings.model_reasoning_effort, + get_codex_config_file_path() + ), + false, + )) +} + +#[command] +pub async fn upgrade_codex(target_variant: Option) -> Result { + let variant = target_variant + .map(|v| v.trim().to_lowercase()) + .filter(|v| !v.is_empty()) + .or_else(|| load_install_state().install_type) + .unwrap_or_else(|| "openai".to_string()); + + let command = if variant == "gac" { + format!("npm install -g {}", CODEX_GAC_INSTALL_URL) + } else { + format!("npm install -g {}@latest", CODEX_OPENAI_PACKAGE) + }; + + match run_npm_global(&command) { + Ok(output) => Ok(success_result( + "Codex 升级成功", + format!("$ {}\n{}", command, output), + false, + )), + Err(e) => Ok(error_result("Codex 升级失败", e, String::new())), + } +} + +fn try_uninstall_codex() -> (bool, Vec, Vec) { + let mut logs = Vec::new(); + let mut errors = Vec::new(); + + let commands = [ + format!("npm uninstall -g {}", CODEX_OPENAI_PACKAGE), + "npm uninstall -g codex".to_string(), + ]; + + for command in commands { + match run_npm_global(&command) { + Ok(output) => { + logs.push(format!("$ {}\n{}", command, output)); + return (true, logs, errors); + } + Err(e) => { + errors.push(format!("$ {}\n{}", command, e)); + } + } + } + + if !shell::command_exists("codex") { + return (true, logs, errors); + } + + (false, logs, errors) +} + +#[command] +pub async fn uninstall_codex(clear_config: bool) -> Result { + let (success, mut logs, errors) = try_uninstall_codex(); + + if !errors.is_empty() { + logs.extend(errors); + } + + if !success { + return Ok(error_result( + "Codex 卸载失败", + "执行 npm uninstall 后仍检测到 codex 命令".to_string(), + logs.join("\n\n"), + )); + } + + clear_install_state(); + logs.push(format!("已删除状态: {}", get_codex_state_file_path())); + + if clear_config { + let config_path = get_codex_config_file_path(); + if Path::new(&config_path).exists() { + let _ = std::fs::remove_file(&config_path); + logs.push(format!("已删除配置: {}", config_path)); + } + + match clear_env_in_rc() { + Ok(paths) => { + for path in paths { + logs.push(format!("已清理环境变量: {}", path)); + } + } + Err(e) => { + logs.push(format!("清理环境变量失败: {}", e)); + } + } + } + + Ok(success_result( + if clear_config { + "Codex 已卸载,配置已清理" + } else { + "Codex 已卸载,配置已保留" + }, + logs.join("\n\n"), + clear_config, + )) +} + +#[command] +pub async fn reinstall_codex( + variant: String, + route: Option, + api_key: Option, + model: Option, + model_reasoning_effort: Option, + clear_config: Option, +) -> Result { + let clear = clear_config.unwrap_or(false); + + let uninstall = uninstall_codex(clear).await?; + if !uninstall.success { + return Ok(uninstall); + } + + let install = install_codex( + variant, + route, + api_key, + model, + model_reasoning_effort, + ) + .await?; + + let combined_output = [uninstall.stdout, install.stdout] + .into_iter() + .filter(|v| !v.trim().is_empty()) + .collect::>() + .join("\n\n"); + + if install.success { + Ok(success_result( + "Codex 重装成功", + combined_output, + install.restart_required, + )) + } else { + Ok(error_result( + "Codex 重装失败", + install + .error + .unwrap_or_else(|| "安装阶段发生未知错误".to_string()), + combined_output, + )) + } +} + +#[cfg(test)] +mod tests { + use super::{filter_codex_config, normalize_route, parse_codex_config, parse_install_state}; + + #[test] + fn parse_install_state_works() { + let state = parse_install_state( + "INSTALL_TYPE=openai\nROUTE=gac\nMANAGED_BY=sh.tu-zi.com\n", + ); + assert_eq!(state.install_type.as_deref(), Some("openai")); + assert_eq!(state.route.as_deref(), Some("gac")); + + let unknown = parse_install_state("INSTALL_TYPE=other\nROUTE=xxx\n"); + assert!(unknown.install_type.is_none()); + assert!(unknown.route.is_none()); + } + + #[test] + fn normalize_route_works() { + assert_eq!(normalize_route("gac").as_deref(), Some("gac")); + assert_eq!(normalize_route("tuzi").as_deref(), Some("tuzi")); + assert!(normalize_route("abc").is_none()); + } + + #[test] + fn filter_codex_sections_and_profile() { + let raw = r#"profile = "gac" + +[foo] +a = 1 + +[model_providers.gac] +name = "gac" +base_url = "https://gaccode.com/codex/v1" + +[profiles.gac] +model_provider = "gac" + +[bar] +b = 2 +"#; + let filtered = filter_codex_config(raw); + assert!(filtered.contains("[foo]")); + assert!(filtered.contains("[bar]")); + assert!(!filtered.contains("[model_providers.gac]")); + assert!(!filtered.contains("[profiles.gac]")); + assert!(!filtered.contains("profile =")); + } + + #[test] + fn parse_codex_config_reads_sections() { + let raw = r#"profile = "tuzi" + +[model_providers.tuzi] +base_url = "https://api.tu-zi.com/v1" + +[profiles.tuzi] +model = "gpt-5.5" +model_reasoning_effort = "high" +"#; + + let parsed = parse_codex_config(raw); + assert_eq!(parsed.profile.as_deref(), Some("tuzi")); + let route = parsed.routes.get("tuzi").expect("tuzi route missing"); + assert_eq!( + route.base_url.as_deref(), + Some("https://api.tu-zi.com/v1") + ); + assert_eq!(route.model.as_deref(), Some("gpt-5.5")); + assert_eq!(route.model_reasoning_effort.as_deref(), Some("high")); + } +} diff --git a/src-tauri/src/commands/installer.rs b/src-tauri/src/commands/installer.rs index f6eabf1..ab479c2 100644 --- a/src-tauri/src/commands/installer.rs +++ b/src-tauri/src/commands/installer.rs @@ -105,7 +105,7 @@ pub async fn check_environment() -> Result { tuzi_configured }; - let ready = node_installed && node_version_ok && openclaw_installed; + let ready = node_installed && node_version_ok; info!("[环境检查] 环境就绪状态: ready={}", ready); Ok(EnvironmentStatus { diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 4491dca..21ead24 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,3 +1,5 @@ +pub mod claudecode; +pub mod codex; pub mod config; pub mod diagnostics; pub mod installer; diff --git a/src-tauri/src/commands/process.rs b/src-tauri/src/commands/process.rs index a043be4..dea9b8a 100644 --- a/src-tauri/src/commands/process.rs +++ b/src-tauri/src/commands/process.rs @@ -1,7 +1,24 @@ use crate::utils::shell; use log::{debug, info}; +use serde::{Deserialize, Serialize}; +use std::path::Path; use tauri::command; +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModuleStatus { + pub module_id: String, + pub installed: bool, + pub version: Option, + pub message: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModuleStatusOverview { + pub node_installed: bool, + pub node_version: Option, + pub modules: Vec, +} + /// 检查 OpenClaw 是否已安装 #[command] pub async fn check_openclaw_installed() -> Result { @@ -33,6 +50,103 @@ pub async fn get_openclaw_version() -> Result, String> { } } +#[command] +pub async fn get_module_statuses() -> Result { + let openclaw_installed = shell::get_openclaw_path().is_some(); + let openclaw_version = if openclaw_installed { + shell::run_openclaw(&["--version"]) + .ok() + .map(|value| value.trim().to_string()) + } else { + None + }; + + let codex_installed = shell::command_exists("codex"); + let codex_version = if codex_installed { + shell::run_command_output("codex", &["--version"]) + .ok() + .map(|value| value.trim().to_string()) + } else { + None + }; + + let claude_installed = shell::command_exists("claude"); + let claude_version = if claude_installed { + shell::run_command_output("claude", &["--version"]) + .ok() + .map(|value| value.trim().to_string()) + } else { + None + }; + let claude_route = get_claude_current_route(); + + let node_installed = shell::command_exists("node"); + let node_version = if node_installed { + shell::run_command_output("node", &["--version"]).ok() + } else { + None + }; + + Ok(ModuleStatusOverview { + node_installed, + node_version, + modules: vec![ + ModuleStatus { + module_id: "openclaw".to_string(), + installed: openclaw_installed, + version: openclaw_version, + message: if openclaw_installed { + "OpenClaw CLI 已可用".to_string() + } else { + "未检测到 OpenClaw CLI".to_string() + }, + }, + ModuleStatus { + module_id: "codex".to_string(), + installed: codex_installed, + version: codex_version, + message: if codex_installed { + "Codex CLI 已可用".to_string() + } else { + "未检测到 Codex CLI".to_string() + }, + }, + ModuleStatus { + module_id: "claudecode".to_string(), + installed: claude_installed, + version: claude_version, + message: if claude_installed { + if let Some(route) = claude_route { + format!("Claude Code CLI 已可用(当前路线: {})", route) + } else { + "Claude Code CLI 已可用".to_string() + } + } else { + "未检测到 Claude Code CLI(claude)".to_string() + }, + }, + ], + }) +} + +fn get_claude_current_route() -> Option { + let home = dirs::home_dir()?; + let route_path = if crate::utils::platform::is_windows() { + format!("{}\\.config\\tuzi\\claude_route_status.txt", home.display()) + } else { + format!("{}/.config/tuzi/claude_route_status.txt", home.display()) + }; + + if !Path::new(&route_path).exists() { + return None; + } + + let content = std::fs::read_to_string(&route_path).ok()?; + content + .lines() + .find_map(|line| line.trim().strip_prefix("current_route=").map(|v| v.trim().to_string())) +} + /// 检查端口是否被占用(通过尝试连接 openclaw gateway) #[command] pub async fn check_port_in_use(port: u16) -> Result { diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 9f458f7..3fa5164 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -8,7 +8,7 @@ mod commands; mod models; mod utils; -use commands::{config, diagnostics, installer, process, service, skills}; +use commands::{claudecode, codex, config, diagnostics, installer, process, service, skills}; fn main() { // 初始化日志 - 默认显示 info 级别日志 @@ -35,6 +35,7 @@ fn main() { // 进程管理 process::check_openclaw_installed, process::get_openclaw_version, + process::get_module_statuses, process::check_port_in_use, // 配置管理 config::get_config, @@ -88,6 +89,26 @@ fn main() { skills::remove_tuzi_skills_group, skills::check_tuzi_skills_requirements, skills::refresh_tuzi_skills, + // ClaudeCode 管理 + claudecode::get_claudecode_status, + claudecode::get_claude_install_reference, + claudecode::install_claudecode, + claudecode::upgrade_claudecode, + claudecode::uninstall_claudecode, + claudecode::list_claude_routes, + claudecode::switch_claude_route, + claudecode::add_claude_route, + claudecode::update_claude_route_key, + // Codex 管理 + codex::get_codex_status, + codex::get_codex_install_reference, + codex::install_codex, + codex::upgrade_codex, + codex::uninstall_codex, + codex::reinstall_codex, + codex::list_codex_routes, + codex::switch_codex_route, + codex::set_codex_route_model, ]) .run(tauri::generate_context!()) .expect("运行 Tauri 应用时发生错误"); diff --git a/src/App.tsx b/src/App.tsx index 05bd2a0..38ddce9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,9 @@ import { invoke } from '@tauri-apps/api/core'; import { Sidebar } from './components/Layout/Sidebar'; import { Header } from './components/Layout/Header'; import { Dashboard } from './components/Dashboard'; +import { Modules } from './components/Modules'; +import { ClaudeCode } from './components/ClaudeCode'; +import { Codex } from './components/Codex'; import { AIConfig } from './components/AIConfig'; import { Skills } from './components/Skills'; import { Channels } from './components/Channels'; @@ -12,9 +15,27 @@ import { Testing } from './components/Testing'; import { Logs } from './components/Logs'; import { appLogger } from './lib/logger'; import { EnvironmentStatus, isTauri } from './lib/tauri'; -import { Download, X, Loader2, CheckCircle, AlertCircle } from 'lucide-react'; +import { ModuleType } from './types/modules'; -export type PageType = 'dashboard' | 'ai' | 'skills' | 'channels' | 'testing' | 'logs' | 'settings'; +export type TopModuleType = 'openclaw' | 'claudecode' | 'codex'; + +export type OpenclawSubPageType = + | 'dashboard' + | 'ai' + | 'skills' + | 'channels' + | 'testing' + | 'logs' + | 'settings'; + +export type AppViewType = + | 'module_center' + | 'openclaw_page' + | 'claudecode_page' + | 'codex_page'; + +export type ClaudeCodeSubPageType = 'overview' | 'install' | 'routes' | 'faq'; +export type CodexSubPageType = 'overview' | 'install' | 'routes' | 'faq'; interface ServiceStatus { running: boolean; @@ -22,115 +43,47 @@ interface ServiceStatus { port: number; } -interface UpdateInfo { - update_available: boolean; - current_version: string | null; - latest_version: string | null; - source: string; - error: string | null; -} - -interface UpdateResult { - success: boolean; - message: string; - error?: string; -} - function App() { - const [currentPage, setCurrentPage] = useState('dashboard'); + const [view, setView] = useState('module_center'); + const [activeTopModule, setActiveTopModule] = useState('openclaw'); + const [openclawExpanded, setOpenclawExpanded] = useState(true); + const [activeOpenclawSubPage, setActiveOpenclawSubPage] = useState('dashboard'); + const [claudecodeExpanded, setClaudecodeExpanded] = useState(true); + const [activeClaudeCodeSubPage, setActiveClaudeCodeSubPage] = useState('overview'); + const [codexExpanded, setCodexExpanded] = useState(true); + const [activeCodexSubPage, setActiveCodexSubPage] = useState('overview'); + const [isReady, setIsReady] = useState(null); const [envStatus, setEnvStatus] = useState(null); const [serviceStatus, setServiceStatus] = useState(null); - - // 更新相关状态 - const [updateInfo, setUpdateInfo] = useState(null); - const [showUpdateBanner, setShowUpdateBanner] = useState(false); - const [updating, setUpdating] = useState(false); - const [updateResult, setUpdateResult] = useState(null); - - // 检查环境 + const checkEnvironment = useCallback(async () => { if (!isTauri()) { appLogger.warn('不在 Tauri 环境中,跳过环境检查'); setIsReady(true); return; } - + appLogger.info('开始检查系统环境...'); try { const status = await invoke('check_environment'); appLogger.info('环境检查完成', status); setEnvStatus(status); - setIsReady(true); // 总是显示主界面 + setIsReady(true); } catch (e) { appLogger.error('环境检查失败', e); setIsReady(true); } }, []); - // 检查更新 - const checkUpdate = useCallback(async () => { - if (!isTauri()) return; - - appLogger.info('检查 OpenClaw 更新...'); - try { - const info = await invoke('check_openclaw_update'); - appLogger.info('更新检查结果', info); - setUpdateInfo(info); - if (info.update_available) { - setShowUpdateBanner(true); - } - } catch (e) { - appLogger.error('检查更新失败', e); - } - }, []); - - // 执行更新 - const handleUpdate = async () => { - setUpdating(true); - setUpdateResult(null); - try { - const result = await invoke('update_openclaw'); - setUpdateResult(result); - if (result.success) { - // 更新成功后重新检查环境 - await checkEnvironment(); - // 3秒后关闭提示 - setTimeout(() => { - setShowUpdateBanner(false); - setUpdateResult(null); - }, 3000); - } - } catch (e) { - setUpdateResult({ - success: false, - message: '更新过程中发生错误', - error: String(e), - }); - } finally { - setUpdating(false); - } - }; - useEffect(() => { - appLogger.info('🦞 App 组件已挂载'); + appLogger.info('🧩 App 组件已挂载'); checkEnvironment(); }, [checkEnvironment]); - // 启动后延迟检查更新(避免阻塞启动) useEffect(() => { if (!isTauri()) return; - const timer = setTimeout(() => { - checkUpdate(); - }, 2000); - return () => clearTimeout(timer); - }, [checkUpdate]); - // 定期获取服务状态 - useEffect(() => { - // 不在 Tauri 环境中则不轮询 - if (!isTauri()) return; - const fetchServiceStatus = async () => { try { const status = await invoke('get_service_status'); @@ -139,37 +92,91 @@ function App() { // 静默处理轮询错误 } }; + fetchServiceStatus(); const interval = setInterval(fetchServiceStatus, 3000); return () => clearInterval(interval); }, []); - const handleSetupComplete = useCallback(() => { - appLogger.info('安装向导完成'); - checkEnvironment(); // 重新检查环境 - }, [checkEnvironment]); + const handleOpenOverview = () => { + appLogger.action('页面切换', { to: 'module_center' }); + setView('module_center'); + }; - // 页面切换处理 - const handleNavigate = (page: PageType) => { - appLogger.action('页面切换', { from: currentPage, to: page }); - setCurrentPage(page); + const handleToggleOpenclaw = () => { + setActiveTopModule('openclaw'); + setOpenclawExpanded((prev) => !prev); }; - const renderPage = () => { - const pageVariants = { - initial: { opacity: 0, x: 20 }, - animate: { opacity: 1, x: 0 }, - exit: { opacity: 0, x: -20 }, - }; + const handleOpenclawSubPage = (subPage: OpenclawSubPageType) => { + appLogger.action('页面切换', { to: `openclaw/${subPage}` }); + setActiveTopModule('openclaw'); + setOpenclawExpanded(true); + setActiveOpenclawSubPage(subPage); + setView('openclaw_page'); + }; + + const handleToggleClaudecode = () => { + setActiveTopModule('claudecode'); + setClaudecodeExpanded((prev) => { + const next = !prev; + if (next) { + setView('claudecode_page'); + } + return next; + }); + }; + + const handleOpenClaudecodeSubPage = (subPage: ClaudeCodeSubPageType) => { + appLogger.action('页面切换', { to: `claudecode/${subPage}` }); + setActiveTopModule('claudecode'); + setClaudecodeExpanded(true); + setActiveClaudeCodeSubPage(subPage); + setView('claudecode_page'); + }; + + const handleToggleCodex = () => { + setActiveTopModule('codex'); + setCodexExpanded((prev) => { + const next = !prev; + if (next) { + setView('codex_page'); + } + return next; + }); + }; - const pages: Record = { - dashboard: , + const handleOpenCodexSubPage = (subPage: CodexSubPageType) => { + appLogger.action('页面切换', { to: `codex/${subPage}` }); + setActiveTopModule('codex'); + setCodexExpanded(true); + setActiveCodexSubPage(subPage); + setView('codex_page'); + }; + + const handleOpenFromModuleCenter = (moduleId: ModuleType) => { + if (moduleId === 'openclaw') { + handleOpenclawSubPage('dashboard'); + return; + } + + if (moduleId === 'claudecode') { + handleOpenClaudecodeSubPage('overview'); + return; + } + + handleOpenCodexSubPage('overview'); + }; + + const renderOpenclawPage = () => { + const pages: Record = { + dashboard: , ai: , skills: ( setCurrentPage('settings')} - onNavigateToSetup={() => setCurrentPage('dashboard')} + onNavigateToSettings={() => handleOpenclawSubPage('settings')} + onNavigateToSetup={handleOpenOverview} /> ), channels: , @@ -178,7 +185,34 @@ function App() { settings: ( + ), + }; + + return pages[activeOpenclawSubPage]; + }; + + const renderPage = () => { + const pageVariants = { + initial: { opacity: 0, x: 20 }, + animate: { opacity: 1, x: 0 }, + exit: { opacity: 0, x: -20 }, + }; + + const pageMap: Record = { + module_center: , + openclaw_page: renderOpenclawPage(), + claudecode_page: ( + + ), + codex_page: ( + ), }; @@ -186,7 +220,7 @@ function App() { return ( - {pages[currentPage]} + {pageMap[view]} ); }; - // 正在检查环境 if (isReady === null) { return (
- 🦞 + 🧩

正在启动...

@@ -215,98 +248,39 @@ function App() { ); } - // 主界面 return (
- {/* 背景装饰 */}
- - {/* 更新提示横幅 */} - - {showUpdateBanner && updateInfo?.update_available && ( - -
-
- {updateResult?.success ? ( - - ) : updateResult && !updateResult.success ? ( - - ) : ( - - )} -
- {updateResult ? ( -

- {updateResult.message} -

- ) : ( - <> -

- 发现新版本 OpenClaw {updateInfo.latest_version} -

-

- 当前版本: {updateInfo.current_version} -

-

- 更新来源: {updateInfo.source} -

- - )} -
-
- -
- {!updateResult && ( - - )} - -
-
-
- )} -
- - {/* 侧边栏 */} - - - {/* 主内容区 */} + + +
- {/* 标题栏(macOS 拖拽区域) */} -
- - {/* 页面内容 */} -
- {renderPage()} -
+
+ +
{renderPage()}
); diff --git a/src/components/ClaudeCode/index.tsx b/src/components/ClaudeCode/index.tsx new file mode 100644 index 0000000..06b2ce0 --- /dev/null +++ b/src/components/ClaudeCode/index.tsx @@ -0,0 +1,474 @@ +import { useEffect, useMemo, useState } from 'react'; +import { + AlertTriangle, + Loader2, + Route, + Upload, +} from 'lucide-react'; +import clsx from 'clsx'; +import { + api, + ClaudeActionResult, + ClaudeCodeStatus, + ClaudeInstallScheme, + ClaudeReferenceDocs, +} from '../../lib/tauri'; +import { ClaudeCodeSubPageType } from '../../App'; +import { InstallActionCard } from '../InstallUI/InstallActionCard'; +import { InstallToolbar } from '../InstallUI/InstallToolbar'; +import { StatusHeaderCard } from '../InstallUI/StatusHeaderCard'; + +interface ClaudeCodeProps { + section: ClaudeCodeSubPageType; + onNavigateSection: (section: ClaudeCodeSubPageType) => void; +} + +const schemeDescriptions: Record = { + A: '改版 Claude(无需手动输入 Key,首次登录走网页授权)', + B: '原版 ClaudeCode + gaccode Key', + C: '原版 ClaudeCode + tu-zi Key(请使用 ClaudeCode 分组 API Key)', +}; + +export function ClaudeCode({ section, onNavigateSection }: ClaudeCodeProps) { + const [status, setStatus] = useState(null); + const [referenceDocs, setReferenceDocs] = useState(null); + const [loadingStatus, setLoadingStatus] = useState(true); + const [loadingDocs, setLoadingDocs] = useState(false); + const [pageError, setPageError] = useState(null); + const [actionResult, setActionResult] = useState(null); + const [runningAction, setRunningAction] = useState(null); + + const [gacApiKeyInput, setGacApiKeyInput] = useState(''); + const [tuziApiKeyInput, setTuziApiKeyInput] = useState(''); + const [newRouteName, setNewRouteName] = useState(''); + const [newRouteBaseUrl, setNewRouteBaseUrl] = useState(''); + const [newRouteApiKey, setNewRouteApiKey] = useState(''); + const [routeKeyUpdates, setRouteKeyUpdates] = useState>({}); + + const loadStatus = async () => { + try { + const nextStatus = await api.getClaudeCodeStatus(); + setStatus(nextStatus); + } catch (e) { + setPageError(`加载 ClaudeCode 状态失败: ${String(e)}`); + } + }; + + useEffect(() => { + const init = async () => { + setLoadingStatus(true); + await loadStatus(); + setLoadingStatus(false); + }; + init(); + }, []); + + useEffect(() => { + if (section !== 'faq') return; + const loadReference = async () => { + setLoadingDocs(true); + try { + setReferenceDocs(await api.getClaudeInstallReference()); + } catch (e) { + setPageError(`加载参考文档失败: ${String(e)}`); + } finally { + setLoadingDocs(false); + } + }; + loadReference(); + }, [section]); + + const statusChips = useMemo(() => { + if (!status) return []; + return [ + { + label: 'CLI 状态', + value: status.installed ? '已安装' : '未安装', + className: status.installed ? 'text-green-300 bg-green-500/15' : 'text-red-300 bg-red-500/15', + }, + { + label: '版本', + value: status.version || '--', + className: 'text-gray-200 bg-dark-600', + }, + { + label: '当前路线', + value: status.current_route || '--', + className: 'text-gray-200 bg-dark-600', + }, + { + label: '路线文件', + value: status.route_file_exists ? '存在' : '不存在', + className: status.route_file_exists ? 'text-green-300 bg-green-500/15' : 'text-yellow-300 bg-yellow-500/15', + }, + ]; + }, [status]); + + const runAction = async (id: string, action: () => Promise) => { + setRunningAction(id); + setPageError(null); + setActionResult(null); + try { + const result = await action(); + setActionResult(result); + await loadStatus(); + } catch (e) { + setPageError(String(e)); + } finally { + setRunningAction(null); + } + }; + + const handleInstall = (scheme: ClaudeInstallScheme) => { + if (scheme === 'B' && !gacApiKeyInput.trim()) { + setPageError('安装原版 ClaudeCode + gaccode Key 需要先填写 API Key'); + return; + } + if (scheme === 'C' && !tuziApiKeyInput.trim()) { + setPageError('安装原版 ClaudeCode + Tuzi Key 需要先填写 API Key'); + return; + } + void runAction(`install-${scheme}`, () => + api.installClaudeCode( + scheme, + scheme === 'A' + ? undefined + : scheme === 'B' + ? gacApiKeyInput.trim() || undefined + : tuziApiKeyInput.trim() || undefined + ) + ); + }; + + const handleAddRoute = () => { + const routeName = newRouteName.trim(); + const baseUrl = newRouteBaseUrl.trim(); + const apiKey = newRouteApiKey.trim(); + if (!routeName || !baseUrl || !apiKey) { + setPageError('新增路线前请填写路线名、Base URL 和 API Key'); + return; + } + void runAction('route-add', () => api.addClaudeRoute(routeName, baseUrl, apiKey)).then(() => { + setNewRouteName(''); + setNewRouteBaseUrl(''); + setNewRouteApiKey(''); + }); + }; + + if (loadingStatus) { + return ( +
+
+ +

正在加载 ClaudeCode 模块...

+
+
+ ); + } + + return ( +
+
+ void loadStatus()} + refreshing={!!runningAction} + /> + + {pageError && ( +
+ {pageError} +
+ )} + + {actionResult && ( + <> +
+

{actionResult.message}

+ {actionResult.error &&

{actionResult.error}

} + {actionResult.restart_required && ( +

提示:请重开终端后执行 `claude`。

+ )} +
+ +
+

终端输出

+
+                {[actionResult.stdout, actionResult.stderr]
+                  .filter((value) => value && value.trim().length > 0)
+                  .join('\n\n')
+                  || '(无输出)'}
+              
+
+ + )} + + {section === 'overview' && ( +
+
+

环境概览

+
+

CLI: {status?.installed ? '已安装' : '未安装'}

+

版本: {status?.version || '--'}

+

当前路线: {status?.current_route || '--'}

+

ANTHROPIC_BASE_URL: {status?.env_summary.anthropic_base_url || '--'}

+

+ ANTHROPIC_API_KEY: {status?.env_summary.anthropic_api_key_masked || '未读取到'} +

+
+
+
+

快捷操作

+
+ + +
+
+
+ )} + + {section === 'install' && ( +
+
+

安装方案

+
+ handleInstall('A')} + disabled={!!runningAction} + loading={runningAction === 'install-A'} + /> + + handleInstall('B')} + disabled={!!runningAction} + loading={runningAction === 'install-B'} + > + setGacApiKeyInput(e.target.value)} + placeholder="请输入 gaccode API Key(必填)" + className="input-base" + /> + + + handleInstall('C')} + disabled={!!runningAction} + loading={runningAction === 'install-C'} + > + setTuziApiKeyInput(e.target.value)} + placeholder="请输入 Tuzi ClaudeCode 分组 API Key(必填)" + className="input-base" + /> + +
+
+ + + + + + + +
+ )} + + {section === 'routes' && ( +
+
+

路线列表

+ {status?.routes.length ? ( +
+ {status.routes.map((route) => ( +
+
+
+

+ + {route.name} + {route.is_current && ( + + 当前 + + )} +

+

Base URL: {route.base_url || '--'}

+

+ API Key: {route.api_key_masked || (route.has_key ? '已配置' : '未配置')} +

+
+
+ +
+
+
+ + setRouteKeyUpdates((prev) => ({ ...prev, [route.name]: e.target.value })) + } + placeholder="输入新 API Key" + className="input-base text-sm" + /> + +
+
+ ))} +
+ ) : ( +

暂无路线配置。

+ )} +
+ +
+

新增路线

+
+ setNewRouteName(e.target.value)} + className="input-base" + placeholder="路线名称(例如 my-route)" + /> + setNewRouteBaseUrl(e.target.value)} + className="input-base" + placeholder="ANTHROPIC_BASE_URL" + /> + setNewRouteApiKey(e.target.value)} + className="input-base" + placeholder="ANTHROPIC_API_KEY" + /> + +
+
+
+ )} + + {section === 'faq' && ( +
+
+

参考文档读取状态

+ {loadingDocs ? ( +

+ + 正在加载本地参考文档... +

+ ) : ( +
+

更新时间:{referenceDocs?.updated_at || '--'}

+ {referenceDocs?.error && ( +

+ + {referenceDocs.error} +

+ )} +
+ )} +
+ +
+

README_INSTALL_CLAUDE.md

+
+                {referenceDocs?.readme_markdown || '暂无内容'}
+              
+
+ +
+

install_claude_flow.md

+
+                {referenceDocs?.flow_markdown || '暂无内容'}
+              
+
+
+ )} +
+
+ ); +} diff --git a/src/components/Codex/index.tsx b/src/components/Codex/index.tsx new file mode 100644 index 0000000..6ce3b23 --- /dev/null +++ b/src/components/Codex/index.tsx @@ -0,0 +1,492 @@ +import { useEffect, useMemo, useState } from 'react'; +import { + AlertTriangle, + Loader2, + Route, + Upload, +} from 'lucide-react'; +import clsx from 'clsx'; +import { + api, + CodexActionResult, + CodexInstallVariant, + CodexReferenceDocs, + CodexRoute, + CodexStatus, +} from '../../lib/tauri'; +import { CodexSubPageType } from '../../App'; +import { InstallActionCard } from '../InstallUI/InstallActionCard'; +import { InstallToolbar } from '../InstallUI/InstallToolbar'; +import { StatusHeaderCard } from '../InstallUI/StatusHeaderCard'; + +interface CodexProps { + section: CodexSubPageType; + onNavigateSection: (section: CodexSubPageType) => void; +} + +const installDescriptions: Record = { + openai: '原版 Codex CLI(可配置 gac/tuzi 路线)', + gac: 'gac 改版 Codex CLI(无需写入 route 配置)', +}; + +export function Codex({ section, onNavigateSection }: CodexProps) { + const [status, setStatus] = useState(null); + const [referenceDocs, setReferenceDocs] = useState(null); + const [loadingStatus, setLoadingStatus] = useState(true); + const [loadingDocs, setLoadingDocs] = useState(false); + const [pageError, setPageError] = useState(null); + const [actionResult, setActionResult] = useState(null); + const [runningAction, setRunningAction] = useState(null); + + const [installRoute, setInstallRoute] = useState<'gac' | 'tuzi'>('gac'); + const [installApiKey, setInstallApiKey] = useState(''); + const [installModel, setInstallModel] = useState('gpt-5.4'); + const [installReasoning, setInstallReasoning] = useState('medium'); + + const [routeSwitchInputs, setRouteSwitchInputs] = useState>({}); + const [routeModelInputs, setRouteModelInputs] = useState>({}); + const [routeReasoningInputs, setRouteReasoningInputs] = useState>({}); + + const loadStatus = async () => { + try { + const next = await api.getCodexStatus(); + setStatus(next); + const nextModelInputs: Record = {}; + const nextReasoningInputs: Record = {}; + next.routes.forEach((route) => { + nextModelInputs[route.name] = route.model_settings.model; + nextReasoningInputs[route.name] = route.model_settings.model_reasoning_effort; + }); + setRouteModelInputs(nextModelInputs); + setRouteReasoningInputs(nextReasoningInputs); + } catch (e) { + setPageError(`加载 Codex 状态失败: ${String(e)}`); + } + }; + + useEffect(() => { + const init = async () => { + setLoadingStatus(true); + await loadStatus(); + setLoadingStatus(false); + }; + init(); + }, []); + + useEffect(() => { + if (section !== 'faq') return; + const loadReference = async () => { + setLoadingDocs(true); + try { + setReferenceDocs(await api.getCodexInstallReference()); + } catch (e) { + setPageError(`加载 install_codex.sh 失败: ${String(e)}`); + } finally { + setLoadingDocs(false); + } + }; + loadReference(); + }, [section]); + + const statusChips = useMemo(() => { + if (!status) return []; + return [ + { + label: 'CLI 状态', + value: status.installed ? '已安装' : '未安装', + className: status.installed ? 'text-green-300 bg-green-500/15' : 'text-red-300 bg-red-500/15', + }, + { + label: '版本', + value: status.version || '--', + className: 'text-gray-200 bg-dark-600', + }, + { + label: '安装类型', + value: status.install_type || '--', + className: 'text-gray-200 bg-dark-600', + }, + { + label: '当前路线', + value: status.current_route || '--', + className: 'text-gray-200 bg-dark-600', + }, + ]; + }, [status]); + + const runAction = async (id: string, action: () => Promise) => { + setRunningAction(id); + setPageError(null); + setActionResult(null); + try { + const result = await action(); + setActionResult(result); + await loadStatus(); + } catch (e) { + setPageError(String(e)); + } finally { + setRunningAction(null); + } + }; + + const openaiRouteEditable = status?.install_type === 'openai'; + + const handleInstallOpenai = () => { + if (!installApiKey.trim()) { + setPageError('安装原版 Codex 并配置路线时,需要输入 CODEX_API_KEY'); + return; + } + void runAction('install-openai', () => + api.installCodex('openai', installRoute, installApiKey.trim(), installModel.trim(), installReasoning.trim()) + ); + }; + + const handleSwitchRoute = (route: CodexRoute) => { + const key = (routeSwitchInputs[route.name] || '').trim(); + if (!key) { + setPageError('路线切换需要重新输入 API Key'); + return; + } + void runAction(`switch-${route.name}`, () => + api.switchCodexRoute( + route.name, + key, + (routeModelInputs[route.name] || route.model_settings.model).trim(), + (routeReasoningInputs[route.name] || route.model_settings.model_reasoning_effort).trim() + ) + ); + }; + + const handleSetRouteModel = (route: CodexRoute) => { + const model = (routeModelInputs[route.name] || '').trim(); + const reasoning = (routeReasoningInputs[route.name] || '').trim(); + if (!model) { + setPageError('model 不能为空'); + return; + } + void runAction(`set-model-${route.name}`, () => + api.setCodexRouteModel(route.name, model, reasoning) + ); + }; + + if (loadingStatus) { + return ( +
+
+ +

正在加载 Codex 模块...

+
+
+ ); + } + + return ( +
+
+ void loadStatus()} + refreshing={!!runningAction} + /> + + {pageError && ( +
+ {pageError} +
+ )} + + {actionResult && ( + <> +
+

{actionResult.message}

+ {actionResult.error &&

{actionResult.error}

} + {actionResult.restart_required && ( +

提示:请重开终端后再执行 `codex`。

+ )} +
+ +
+

终端输出

+
+                {[actionResult.stdout, actionResult.stderr]
+                  .filter((value) => value && value.trim().length > 0)
+                  .join('\n\n') || '(无输出)'}
+              
+
+ + )} + + {section === 'overview' && ( +
+
+

环境概览

+
+

CLI: {status?.installed ? '已安装' : '未安装'}

+

版本: {status?.version || '--'}

+

安装类型: {status?.install_type || '--'}

+

当前路线: {status?.current_route || '--'}

+

状态文件: {status?.state_file_exists ? '存在' : '不存在'}

+

配置文件: {status?.config_file_exists ? '存在' : '不存在'}

+

CODEX_API_KEY: {status?.env_summary.codex_api_key_masked || '未读取到'}

+
+
+
+

快捷操作

+
+ + +
+
+
+ )} + + {section === 'install' && ( +
+
+

安装方案

+
+ +
+ + +
+ setInstallApiKey(e.target.value)} + placeholder="请输入 CODEX_API_KEY(必填)" + className="input-base" + /> + setInstallModel(e.target.value)} + placeholder="model(默认 gpt-5.4)" + className="input-base" + /> + setInstallReasoning(e.target.value)} + placeholder="reasoning(默认 medium)" + className="input-base" + /> +
+ + void runAction('install-gac', () => api.installCodex('gac'))} + disabled={!!runningAction} + loading={runningAction === 'install-gac'} + /> +
+
+ + + + + + + + +
+ )} + + {section === 'routes' && ( +
+ {!openaiRouteEditable && ( +
+ 当前安装类型不是 openai。路线切换与模型设置仅对原版 Codex 可用。 +
+ )} + +
+

路线与模型

+ {status?.routes.length ? ( +
+ {status.routes.map((route) => ( +
+
+
+

+ + {route.name} + {route.is_current && ( + + 当前 + + )} +

+

Base URL: {route.base_url || '--'}

+

API Key: {route.api_key_masked || '未展示'}

+
+ +
+ +
+ + setRouteSwitchInputs((prev) => ({ ...prev, [route.name]: e.target.value })) + } + placeholder="切换时输入新的 CODEX_API_KEY" + className="input-base text-sm" + /> + + setRouteModelInputs((prev) => ({ ...prev, [route.name]: e.target.value })) + } + placeholder="model" + className="input-base text-sm" + /> +
+ + setRouteReasoningInputs((prev) => ({ ...prev, [route.name]: e.target.value })) + } + placeholder="reasoning" + className="input-base text-sm" + /> + +
+
+
+ ))} +
+ ) : ( +

暂无路线配置。

+ )} +
+
+ )} + + {section === 'faq' && ( +
+
+

install_codex.sh 读取状态

+ {loadingDocs ? ( +

+ + 正在加载脚本... +

+ ) : ( +
+

更新时间:{referenceDocs?.updated_at || '--'}

+ {referenceDocs?.error && ( +

+ + {referenceDocs.error} +

+ )} +
+ )} +
+ +
+

install_codex.sh

+
+                {referenceDocs?.script_markdown || '暂无内容'}
+              
+
+
+ )} +
+
+ ); +} diff --git a/src/components/Dashboard/index.tsx b/src/components/Dashboard/index.tsx index 04a537c..11eeeed 100644 --- a/src/components/Dashboard/index.tsx +++ b/src/components/Dashboard/index.tsx @@ -4,17 +4,16 @@ import { invoke } from '@tauri-apps/api/core'; import { StatusCard } from './StatusCard'; import { QuickActions } from './QuickActions'; import { SystemInfo } from './SystemInfo'; -import { Setup } from '../Setup'; import { api, EnvironmentStatus, ServiceStatus, isTauri } from '../../lib/tauri'; import { Terminal, RefreshCw, ChevronDown, ChevronUp } from 'lucide-react'; import clsx from 'clsx'; interface DashboardProps { envStatus: EnvironmentStatus | null; - onSetupComplete: () => void; + onNavigateToModules: () => void; } -export function Dashboard({ envStatus, onSetupComplete }: DashboardProps) { +export function Dashboard({ envStatus, onNavigateToModules }: DashboardProps) { const [status, setStatus] = useState(null); const [loading, setLoading] = useState(true); const [actionLoading, setActionLoading] = useState(false); @@ -148,9 +147,6 @@ export function Dashboard({ envStatus, onSetupComplete }: DashboardProps) { show: { opacity: 1, y: 0 }, }; - // 检查环境是否就绪 - const needsSetup = envStatus && (!envStatus.ready || !envStatus.ai_configured); - return (
- {/* 环境安装向导(仅在需要时显示) */} - {needsSetup && ( - - - - )} + +
+
+
+

模块状态总览

+

+ 安装配置入口已升级为模块中心,OpenClaw/Codex/Claude Code 独立管理。 +

+
+ + Node.js: {envStatus?.node_version || '未安装'} + + + OpenClaw: {envStatus?.openclaw_installed ? (envStatus.openclaw_version || '已安装') : '未安装'} + +
+
+
+ +
+
+
+
{/* 服务状态卡片 */} diff --git a/src/components/InstallUI/InstallActionCard.tsx b/src/components/InstallUI/InstallActionCard.tsx new file mode 100644 index 0000000..63a9af0 --- /dev/null +++ b/src/components/InstallUI/InstallActionCard.tsx @@ -0,0 +1,41 @@ +import { ReactNode } from 'react'; +import { Terminal, Loader2 } from 'lucide-react'; +import clsx from 'clsx'; + +interface InstallActionCardProps { + title: string; + description: string; + onAction: () => void; + actionLabel?: string; + disabled?: boolean; + loading?: boolean; + helperText?: string; + children?: ReactNode; +} + +export function InstallActionCard({ + title, + description, + onAction, + actionLabel = '立即执行', + disabled = false, + loading = false, + helperText, + children, +}: InstallActionCardProps) { + return ( +
+ + {children &&
{children}
} +
+ ); +} + diff --git a/src/components/InstallUI/InstallToolbar.tsx b/src/components/InstallUI/InstallToolbar.tsx new file mode 100644 index 0000000..c42b3f9 --- /dev/null +++ b/src/components/InstallUI/InstallToolbar.tsx @@ -0,0 +1,16 @@ +import { ReactNode } from 'react'; + +interface InstallToolbarProps { + title: string; + children: ReactNode; +} + +export function InstallToolbar({ title, children }: InstallToolbarProps) { + return ( +
+

{title}

+
{children}
+
+ ); +} + diff --git a/src/components/InstallUI/StatusHeaderCard.tsx b/src/components/InstallUI/StatusHeaderCard.tsx new file mode 100644 index 0000000..226ceaa --- /dev/null +++ b/src/components/InstallUI/StatusHeaderCard.tsx @@ -0,0 +1,54 @@ +import { RefreshCw } from 'lucide-react'; +import clsx from 'clsx'; + +interface StatusChip { + label: string; + value: string; + className?: string; +} + +interface StatusHeaderCardProps { + title: string; + description: string; + chips: StatusChip[]; + onRefresh: () => void; + refreshing?: boolean; +} + +export function StatusHeaderCard({ + title, + description, + chips, + onRefresh, + refreshing = false, +}: StatusHeaderCardProps) { + return ( +
+
+
+

{title}

+

{description}

+
+ {chips.map((chip) => ( + + {chip.label}: {chip.value} + + ))} +
+
+ +
+
+ ); +} + diff --git a/src/components/Layout/Header.tsx b/src/components/Layout/Header.tsx index 27af133..96a5c61 100644 --- a/src/components/Layout/Header.tsx +++ b/src/components/Layout/Header.tsx @@ -1,36 +1,97 @@ import { useState } from 'react'; -import { PageType } from '../../App'; import { RefreshCw, ExternalLink, Loader2 } from 'lucide-react'; import { open } from '@tauri-apps/plugin-shell'; import { invoke } from '@tauri-apps/api/core'; +import { + AppViewType, + ClaudeCodeSubPageType, + CodexSubPageType, + OpenclawSubPageType, + TopModuleType +} from '../../App'; interface HeaderProps { - currentPage: PageType; + view: AppViewType; + activeTopModule: TopModuleType; + activeOpenclawSubPage: OpenclawSubPageType; + activeClaudeCodeSubPage: ClaudeCodeSubPageType; + activeCodexSubPage: CodexSubPageType; } -const pageTitles: Record = { - dashboard: { title: '概览', description: '服务状态、日志与快捷操作' }, - ai: { title: 'AI 模型配置', description: '配置 AI 提供商和模型' }, - skills: { title: 'Skills 管理', description: '安装和维护 tuzi-skills 技能集' }, - channels: { title: '消息渠道', description: '配置 Telegram、Discord、飞书等' }, - testing: { title: '测试诊断', description: '系统诊断与问题排查' }, - logs: { title: '应用日志', description: '查看 Manager 应用的控制台日志' }, - settings: { title: '设置', description: '身份配置与高级选项' }, +const openclawPageTitles: Record = { + dashboard: { title: 'OpenClaw / 服务概览', description: '服务状态、日志与快捷操作' }, + ai: { title: 'OpenClaw / AI 配置', description: '配置 AI 提供商和模型' }, + skills: { title: 'OpenClaw / Skills', description: '安装和维护 tuzi-skills 技能集' }, + channels: { title: 'OpenClaw / 消息渠道', description: '配置 Telegram、Discord、飞书等' }, + testing: { title: 'OpenClaw / 测试诊断', description: '系统诊断与问题排查' }, + logs: { title: 'OpenClaw / 应用日志', description: '查看 Manager 应用的控制台日志' }, + settings: { title: 'OpenClaw / 设置', description: '身份配置与高级选项' }, }; -export function Header({ currentPage }: HeaderProps) { - const { title, description } = pageTitles[currentPage]; +const claudecodePageTitles: Record = { + overview: { title: 'ClaudeCode / 概览', description: '查看安装状态、当前路线与关键环境信息' }, + install: { title: 'ClaudeCode / 安装与升级', description: '执行 A/B/C 安装方案,或升级/卸载 ClaudeCode' }, + routes: { title: 'ClaudeCode / 路线管理', description: '切换、添加和更新路线配置' }, + faq: { title: 'ClaudeCode / FAQ', description: '读取 install_claude 参考文档并查看常见问题' }, +}; + +const codexPageTitles: Record = { + overview: { title: 'Codex / 概览', description: '查看安装状态、安装类型、路线与环境变量摘要' }, + install: { title: 'Codex / 安装与升级', description: '执行原版/改版安装、升级、卸载与重装' }, + routes: { title: 'Codex / 路线管理', description: '切换 gac/tuzi 路线并管理 model/reasoning 参数' }, + faq: { title: 'Codex / FAQ', description: '读取 install_codex.sh 原文并查看更新时间' }, +}; + +function getHeaderMeta( + view: AppViewType, + activeTopModule: TopModuleType, + activeOpenclawSubPage: OpenclawSubPageType, + activeClaudeCodeSubPage: ClaudeCodeSubPageType, + activeCodexSubPage: CodexSubPageType +) { + if (view === 'module_center') { + return { title: '模块总览', description: '查看并进入 OpenClaw、ClaudeCode、Codex 模块' }; + } + + if (view === 'openclaw_page') { + return openclawPageTitles[activeOpenclawSubPage]; + } + + if (view === 'claudecode_page') { + return claudecodePageTitles[activeClaudeCodeSubPage]; + } + + if (view === 'codex_page') { + return codexPageTitles[activeCodexSubPage]; + } + + return { title: activeTopModule, description: '' }; +} + +export function Header({ + view, + activeTopModule, + activeOpenclawSubPage, + activeClaudeCodeSubPage, + activeCodexSubPage, +}: HeaderProps) { + const { title, description } = getHeaderMeta( + view, + activeTopModule, + activeOpenclawSubPage, + activeClaudeCodeSubPage, + activeCodexSubPage + ); const [opening, setOpening] = useState(false); + const showDashboardButton = view === 'openclaw_page' && activeTopModule === 'openclaw'; const handleOpenDashboard = async () => { setOpening(true); try { - // 获取带 token 的 Dashboard URL(如果没有 token 会自动生成) const url = await invoke('get_dashboard_url'); await open(url); } catch (e) { console.error('打开 Dashboard 失败:', e); - // 降级方案:使用 window.open(不带 token) window.open('http://localhost:18789', '_blank'); } finally { setOpening(false); @@ -39,13 +100,11 @@ export function Header({ currentPage }: HeaderProps) { return (
- {/* 左侧:页面标题 */}

{title}

{description}

- {/* 右侧:操作按钮 */}
- + {showDashboardButton && ( + + )}
); diff --git a/src/components/Layout/Sidebar.tsx b/src/components/Layout/Sidebar.tsx index c05b80c..b53ab6d 100644 --- a/src/components/Layout/Sidebar.tsx +++ b/src/components/Layout/Sidebar.tsx @@ -1,15 +1,27 @@ import { motion } from 'framer-motion'; import { - LayoutDashboard, + Boxes, Bot, - Sparkles, - MessageSquare, + ChevronDown, + ChevronRight, FlaskConical, + HelpCircle, + LayoutDashboard, + MessageSquare, + Route, ScrollText, Settings, + Sparkles, + Wrench, } from 'lucide-react'; -import { PageType } from '../../App'; import clsx from 'clsx'; +import { + AppViewType, + ClaudeCodeSubPageType, + CodexSubPageType, + OpenclawSubPageType, + TopModuleType +} from '../../App'; interface ServiceStatus { running: boolean; @@ -18,13 +30,26 @@ interface ServiceStatus { } interface SidebarProps { - currentPage: PageType; - onNavigate: (page: PageType) => void; + view: AppViewType; + activeTopModule: TopModuleType; + openclawExpanded: boolean; + activeOpenclawSubPage: OpenclawSubPageType; + claudecodeExpanded: boolean; + activeClaudeCodeSubPage: ClaudeCodeSubPageType; + codexExpanded: boolean; + activeCodexSubPage: CodexSubPageType; + onOpenOverview: () => void; + onToggleOpenclaw: () => void; + onOpenclawSubPage: (page: OpenclawSubPageType) => void; + onToggleClaudecode: () => void; + onOpenClaudecodeSubPage: (page: ClaudeCodeSubPageType) => void; + onToggleCodex: () => void; + onOpenCodexSubPage: (page: CodexSubPageType) => void; serviceStatus: ServiceStatus | null; } -const menuItems: { id: PageType; label: string; icon: React.ElementType }[] = [ - { id: 'dashboard', label: '概览', icon: LayoutDashboard }, +const openclawSubItems: { id: OpenclawSubPageType; label: string; icon: React.ElementType }[] = [ + { id: 'dashboard', label: '服务概览', icon: LayoutDashboard }, { id: 'ai', label: 'AI 配置', icon: Bot }, { id: 'skills', label: 'Skills', icon: Sparkles }, { id: 'channels', label: '消息渠道', icon: MessageSquare }, @@ -33,65 +58,229 @@ const menuItems: { id: PageType; label: string; icon: React.ElementType }[] = [ { id: 'settings', label: '设置', icon: Settings }, ]; -export function Sidebar({ currentPage, onNavigate, serviceStatus }: SidebarProps) { +const claudecodeSubItems: { id: ClaudeCodeSubPageType; label: string; icon: React.ElementType }[] = [ + { id: 'overview', label: '概览', icon: LayoutDashboard }, + { id: 'install', label: '安装/升级', icon: Wrench }, + { id: 'routes', label: '路线管理', icon: Route }, + { id: 'faq', label: 'FAQ', icon: HelpCircle }, +]; + +const codexSubItems: { id: CodexSubPageType; label: string; icon: React.ElementType }[] = [ + { id: 'overview', label: '概览', icon: LayoutDashboard }, + { id: 'install', label: '安装/升级', icon: Wrench }, + { id: 'routes', label: '路线管理', icon: Route }, + { id: 'faq', label: 'FAQ', icon: HelpCircle }, +]; + +export function Sidebar({ + view, + activeTopModule, + openclawExpanded, + activeOpenclawSubPage, + claudecodeExpanded, + activeClaudeCodeSubPage, + codexExpanded, + activeCodexSubPage, + onOpenOverview, + onToggleOpenclaw, + onOpenclawSubPage, + onToggleClaudecode, + onOpenClaudecodeSubPage, + onToggleCodex, + onOpenCodexSubPage, + serviceStatus, +}: SidebarProps) { const isRunning = serviceStatus?.running ?? false; + const openclawPageActive = view === 'openclaw_page'; + const claudecodePageActive = view === 'claudecode_page'; + const codexPageActive = view === 'codex_page'; + const overviewActive = view === 'module_center'; + return ( -