From 7e6f8030356ea84ec6dcd461738119be2d32f718 Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 6 Mar 2026 18:35:23 +0800 Subject: [PATCH] feat: overhaul OpenClaw config panels with JSON5 round-trip write engine - Add json-five crate for JSON5 serialization preserving comments and formatting - Rewrite openclaw_config.rs with comment-preserving JSON5 read/write engine - Add Tauri commands: get_openclaw_live_provider, write_openclaw_config_section - Redesign EnvPanel as full JSON editor with structured error handling - Add tools.profile selection (minimal/coding/messaging/full) to ToolsPanel - Add legacy timeout migration support to AgentsDefaultsPanel - Add OpenClawHealthBanner component for config validation warnings - Add supporting hooks, mutations, utility functions, and unit tests --- src-tauri/Cargo.lock | 17 + src-tauri/Cargo.toml | 1 + src-tauri/src/commands/openclaw.rs | 29 +- src-tauri/src/lib.rs | 2 + src-tauri/src/openclaw_config.rs | 870 +++++++++++++----- src/App.tsx | 20 +- .../openclaw/AgentsDefaultsPanel.tsx | 51 +- src/components/openclaw/EnvPanel.tsx | 194 ++-- .../openclaw/OpenClawHealthBanner.tsx | 89 ++ src/components/openclaw/ToolsPanel.tsx | 114 ++- src/components/openclaw/utils.ts | 71 ++ .../providers/EditProviderDialog.tsx | 22 +- src/hooks/useOpenClaw.ts | 13 + src/hooks/useProviderActions.ts | 23 +- src/lib/api/openclaw.ts | 24 +- src/lib/query/mutations.ts | 20 + src/types.ts | 21 +- tests/components/openclaw.utils.test.ts | 46 + 18 files changed, 1242 insertions(+), 385 deletions(-) create mode 100644 src/components/openclaw/OpenClawHealthBanner.tsx create mode 100644 src/components/openclaw/utils.ts create mode 100644 tests/components/openclaw.utils.test.ts diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 311bd646..9f1a4de8 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -714,6 +714,7 @@ dependencies = [ "futures", "hyper", "indexmap 2.11.4", + "json-five", "json5", "log", "objc2 0.5.2", @@ -2510,6 +2511,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "json-five" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "865f2d01a4549c1fd8c60640c03ae5249eb374cd8cde8b905628d4b1af95c87c" +dependencies = [ + "serde", + "unicode-general-category", +] + [[package]] name = "json-patch" version = "3.0.1" @@ -6001,6 +6012,12 @@ dependencies = [ "unic-common", ] +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" version = "1.0.19" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3d708ca0..07584b3c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -63,6 +63,7 @@ rust_decimal = "1.33" uuid = { version = "1.11", features = ["v4"] } sha2 = "0.10" json5 = "0.4" +json-five = "0.3.1" [target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies] tauri-plugin-single-instance = "2" diff --git a/src-tauri/src/commands/openclaw.rs b/src-tauri/src/commands/openclaw.rs index f9d0c9f7..6555bd29 100644 --- a/src-tauri/src/commands/openclaw.rs +++ b/src-tauri/src/commands/openclaw.rs @@ -26,6 +26,21 @@ pub fn get_openclaw_live_provider_ids() -> Result, String> { .map_err(|e| e.to_string()) } +/// Get a single OpenClaw provider fragment from live config. +#[tauri::command] +pub fn get_openclaw_live_provider( + #[allow(non_snake_case)] providerId: String, +) -> Result, String> { + openclaw_config::get_provider(&providerId).map_err(|e| e.to_string()) +} + +/// Scan openclaw.json for known configuration hazards. +#[tauri::command] +pub fn scan_openclaw_config_health( +) -> Result, String> { + openclaw_config::scan_openclaw_config_health().map_err(|e| e.to_string()) +} + // ============================================================================ // Agents Configuration Commands // ============================================================================ @@ -41,7 +56,7 @@ pub fn get_openclaw_default_model() -> Result Result<(), String> { +) -> Result { openclaw_config::set_default_model(&model).map_err(|e| e.to_string()) } @@ -56,7 +71,7 @@ pub fn get_openclaw_model_catalog( #[tauri::command] pub fn set_openclaw_model_catalog( catalog: HashMap, -) -> Result<(), String> { +) -> Result { openclaw_config::set_model_catalog(&catalog).map_err(|e| e.to_string()) } @@ -71,7 +86,7 @@ pub fn get_openclaw_agents_defaults( #[tauri::command] pub fn set_openclaw_agents_defaults( defaults: openclaw_config::OpenClawAgentsDefaults, -) -> Result<(), String> { +) -> Result { openclaw_config::set_agents_defaults(&defaults).map_err(|e| e.to_string()) } @@ -87,7 +102,9 @@ pub fn get_openclaw_env() -> Result /// Set OpenClaw env config (env section of openclaw.json) #[tauri::command] -pub fn set_openclaw_env(env: openclaw_config::OpenClawEnvConfig) -> Result<(), String> { +pub fn set_openclaw_env( + env: openclaw_config::OpenClawEnvConfig, +) -> Result { openclaw_config::set_env_config(&env).map_err(|e| e.to_string()) } @@ -103,6 +120,8 @@ pub fn get_openclaw_tools() -> Result Result<(), String> { +pub fn set_openclaw_tools( + tools: openclaw_config::OpenClawToolsConfig, +) -> Result { openclaw_config::set_tools_config(&tools).map_err(|e| e.to_string()) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5715d739..29254fb6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1054,6 +1054,8 @@ pub fn run() { // OpenClaw specific commands::import_openclaw_providers_from_live, commands::get_openclaw_live_provider_ids, + commands::get_openclaw_live_provider, + commands::scan_openclaw_config_health, commands::get_openclaw_default_model, commands::set_openclaw_default_model, commands::get_openclaw_model_catalog, diff --git a/src-tauri/src/openclaw_config.rs b/src-tauri/src/openclaw_config.rs index 20b18ccf..5be4c93d 100644 --- a/src-tauri/src/openclaw_config.rs +++ b/src-tauri/src/openclaw_config.rs @@ -2,48 +2,27 @@ //! //! 处理 `~/.openclaw/openclaw.json` 配置文件的读写操作(JSON5 格式)。 //! OpenClaw 使用累加式供应商管理,所有供应商配置共存于同一配置文件中。 -//! -//! ## 配置文件格式 -//! -//! ```json5 -//! { -//! // 模型供应商配置(映射为 CC Switch 的"供应商") -//! models: { -//! mode: "merge", -//! providers: { -//! "custom-provider": { -//! baseUrl: "https://api.example.com/v1", -//! apiKey: "${API_KEY}", -//! api: "openai-completions", -//! models: [{ id: "model-id", name: "Model Name" }] -//! } -//! } -//! }, -//! // 环境变量配置 -//! env: { -//! ANTHROPIC_API_KEY: "sk-...", -//! vars: { ... } -//! }, -//! // Agent 默认模型配置 -//! agents: { -//! defaults: { -//! model: { -//! primary: "provider/model", -//! fallbacks: ["provider2/model2"] -//! } -//! } -//! } -//! } -//! ``` -use crate::config::write_json_file; +use crate::config::{atomic_write, get_app_config_dir}; use crate::error::AppError; -use crate::settings::get_openclaw_override_dir; +use crate::settings::{effective_backup_retain_count, get_openclaw_override_dir}; +use chrono::Local; use indexmap::IndexMap; +use json_five::parser::{FormatConfiguration, TrailingComma}; +use json_five::rt::parser::{ + from_str as rt_from_str, JSONKeyValuePair as RtJSONKeyValuePair, JSONText as RtJSONText, + JSONValue as RtJSONValue, KeyValuePairContext as RtKeyValuePairContext, + JSONObjectContext as RtJSONObjectContext, +}; use serde::{Deserialize, Serialize}; use serde_json::{json, Map, Value}; use std::collections::HashMap; -use std::path::PathBuf; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; + +const OPENCLAW_DEFAULT_SOURCE: &str = "{\n models: {\n mode: 'merge',\n providers: {},\n },\n}\n"; +const OPENCLAW_TOOLS_PROFILES: &[&str] = &["minimal", "coding", "messaging", "full"]; // ============================================================================ // Path Functions @@ -58,7 +37,6 @@ pub fn get_openclaw_dir() -> PathBuf { return override_dir; } - // 所有平台统一使用 ~/.openclaw dirs::home_dir() .map(|h| h.join(".openclaw")) .unwrap_or_else(|| PathBuf::from(".openclaw")) @@ -71,31 +49,56 @@ pub fn get_openclaw_config_path() -> PathBuf { get_openclaw_dir().join("openclaw.json") } +fn default_openclaw_config_value() -> Value { + json!({ + "models": { + "mode": "merge", + "providers": {} + } + }) +} + +fn openclaw_write_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) +} + // ============================================================================ // Type Definitions // ============================================================================ +/// OpenClaw 健康检查警告 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct OpenClawHealthWarning { + pub code: String, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option, +} + +/// OpenClaw 写入结果 +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct OpenClawWriteOutcome { + #[serde(skip_serializing_if = "Option::is_none")] + pub backup_path: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub warnings: Vec, +} + /// OpenClaw 供应商配置(对应 models.providers 中的条目) #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct OpenClawProviderConfig { - /// API 基础 URL #[serde(skip_serializing_if = "Option::is_none")] pub base_url: Option, - - /// API Key(支持环境变量引用 ${VAR_NAME}) #[serde(skip_serializing_if = "Option::is_none")] pub api_key: Option, - - /// API 类型(如 "openai-completions", "anthropic" 等) #[serde(skip_serializing_if = "Option::is_none")] pub api: Option, - - /// 支持的模型列表 #[serde(default, skip_serializing_if = "Vec::is_empty")] pub models: Vec, - - /// Other custom fields (preserve unknown fields) #[serde(flatten)] pub extra: HashMap, } @@ -104,26 +107,15 @@ pub struct OpenClawProviderConfig { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct OpenClawModelEntry { - /// 模型 ID pub id: String, - - /// 模型显示名称 #[serde(skip_serializing_if = "Option::is_none")] pub name: Option, - - /// 模型别名(用于快捷引用) #[serde(skip_serializing_if = "Option::is_none")] pub alias: Option, - - /// 模型成本(输入/输出价格) #[serde(skip_serializing_if = "Option::is_none")] pub cost: Option, - - /// 上下文窗口大小 #[serde(skip_serializing_if = "Option::is_none")] pub context_window: Option, - - /// Other custom fields (preserve unknown fields) #[serde(flatten)] pub extra: HashMap, } @@ -131,13 +123,8 @@ pub struct OpenClawModelEntry { /// OpenClaw 模型成本配置 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OpenClawModelCost { - /// 输入价格(每百万 token) pub input: f64, - - /// 输出价格(每百万 token) pub output: f64, - - /// Other custom fields (preserve unknown fields) #[serde(flatten)] pub extra: HashMap, } @@ -145,14 +132,9 @@ pub struct OpenClawModelCost { /// OpenClaw 默认模型配置(agents.defaults.model) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OpenClawDefaultModel { - /// 主模型 ID(格式:provider/model) pub primary: String, - - /// 回退模型列表 #[serde(default, skip_serializing_if = "Vec::is_empty")] pub fallbacks: Vec, - - /// Other custom fields (preserve unknown fields) #[serde(flatten)] pub extra: HashMap, } @@ -160,11 +142,8 @@ pub struct OpenClawDefaultModel { /// OpenClaw 模型目录条目(agents.defaults.models 中的值) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OpenClawModelCatalogEntry { - /// 模型别名(用于 UI 显示) #[serde(skip_serializing_if = "Option::is_none")] pub alias: Option, - - /// Other custom fields (preserve unknown fields) #[serde(flatten)] pub extra: HashMap, } @@ -172,15 +151,10 @@ pub struct OpenClawModelCatalogEntry { /// OpenClaw agents.defaults 配置 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OpenClawAgentsDefaults { - /// 默认模型配置 #[serde(skip_serializing_if = "Option::is_none")] pub model: Option, - - /// 模型目录/允许列表(键为 provider/model 格式) #[serde(skip_serializing_if = "Option::is_none")] pub models: Option>, - - /// Other custom fields (preserve unknown fields) #[serde(flatten)] pub extra: HashMap, } @@ -189,11 +163,28 @@ pub struct OpenClawAgentsDefaults { #[derive(Debug, Clone, Serialize, Deserialize)] #[allow(dead_code)] pub struct OpenClawAgents { - /// 默认配置 #[serde(skip_serializing_if = "Option::is_none")] pub defaults: Option, + #[serde(flatten)] + pub extra: HashMap, +} - /// Other custom fields (preserve unknown fields) +/// OpenClaw env 配置(openclaw.json 的 env 节点) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OpenClawEnvConfig { + #[serde(flatten)] + pub vars: HashMap, +} + +/// OpenClaw tools 配置(openclaw.json 的 tools 节点) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OpenClawToolsConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub profile: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub allow: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub deny: Vec, #[serde(flatten)] pub extra: HashMap, } @@ -207,42 +198,411 @@ pub struct OpenClawAgents { /// 支持 JSON5 格式,返回完整的配置 JSON 对象 pub fn read_openclaw_config() -> Result { let path = get_openclaw_config_path(); - if !path.exists() { - // Return empty config structure - return Ok(json!({ - "models": { - "mode": "merge", - "providers": {} - } - })); + return Ok(default_openclaw_config_value()); } - let content = std::fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?; - - // 尝试 JSON5 解析(支持注释和尾随逗号) + let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?; json5::from_str(&content) - .map_err(|e| AppError::Config(format!("Failed to parse OpenClaw config as JSON5: {}", e))) + .map_err(|e| AppError::Config(format!("Failed to parse OpenClaw config as JSON5: {e}"))) } -/// 写入 OpenClaw 配置文件(原子写入) +/// 对现有 OpenClaw 配置做健康检查。 /// -/// 使用标准 JSON 格式写入(JSON5 是 JSON 的超集) -pub fn write_openclaw_config(config: &Value) -> Result<(), AppError> { +/// 解析失败时返回单条 parse 警告,不抛出错误。 +pub fn scan_openclaw_config_health() -> Result, AppError> { let path = get_openclaw_config_path(); - - // 确保目录存在 - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; + if !path.exists() { + return Ok(Vec::new()); } - // 复用统一的原子写入逻辑 - write_json_file(&path, config)?; + let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?; + match json5::from_str::(&content) { + Ok(config) => Ok(scan_openclaw_health_from_value(&config)), + Err(err) => Ok(vec![OpenClawHealthWarning { + code: "config_parse_failed".to_string(), + message: format!("OpenClaw config could not be parsed as JSON5: {err}"), + path: Some(path.display().to_string()), + }]), + } +} + +struct OpenClawConfigDocument { + path: PathBuf, + original_source: Option, + text: RtJSONText, +} + +impl OpenClawConfigDocument { + fn load() -> Result { + let path = get_openclaw_config_path(); + let original_source = if path.exists() { + Some(fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?) + } else { + None + }; + + let source = original_source + .clone() + .unwrap_or_else(|| OPENCLAW_DEFAULT_SOURCE.to_string()); + let text = rt_from_str(&source).map_err(|e| { + AppError::Config(format!( + "Failed to parse OpenClaw config as round-trip JSON5 document: {}", + e.message + )) + })?; + + Ok(Self { + path, + original_source, + text, + }) + } + + fn set_root_section(&mut self, key: &str, value: &Value) -> Result<(), AppError> { + let RtJSONValue::JSONObject { + key_value_pairs, + context, + } = &mut self.text.value + else { + return Err(AppError::Config( + "OpenClaw config root must be a JSON5 object".to_string(), + )); + }; + + if key_value_pairs.is_empty() && context.as_ref().map(|ctx| ctx.wsc.0.is_empty()).unwrap_or(true) { + *context = Some(RtJSONObjectContext { + wsc: ("\n ".to_string(),), + }); + } + + let leading_ws = context + .as_ref() + .map(|ctx| ctx.wsc.0.clone()) + .unwrap_or_default(); + let entry_separator_ws = derive_entry_separator(&leading_ws); + let child_indent = extract_trailing_indent(&leading_ws); + let new_value = value_to_rt_value(value, &child_indent)?; + + if let Some(existing) = key_value_pairs + .iter_mut() + .find(|pair| json5_key_name(&pair.key).as_deref() == Some(key)) + { + existing.value = new_value; + return Ok(()); + } + + let new_pair = if let Some(last_pair) = key_value_pairs.last_mut() { + let last_ctx = ensure_kvp_context(last_pair); + let closing_ws = if let Some(after_comma) = last_ctx.wsc.3.clone() { + last_ctx.wsc.3 = Some(entry_separator_ws.clone()); + after_comma + } else { + let closing_ws = std::mem::take(&mut last_ctx.wsc.2); + last_ctx.wsc.3 = Some(entry_separator_ws.clone()); + closing_ws + }; + + make_root_pair(key, new_value, closing_ws) + } else { + make_root_pair(key, new_value, derive_closing_ws_from_separator(&leading_ws)) + }; + + key_value_pairs.push(new_pair); + Ok(()) + } + + fn save(self) -> Result { + let _guard = openclaw_write_lock().lock()?; + + let current_source = if self.path.exists() { + Some(fs::read_to_string(&self.path).map_err(|e| AppError::io(&self.path, e))?) + } else { + None + }; + + if current_source != self.original_source { + return Err(AppError::Config( + "OpenClaw config changed on disk. Please reload and try again.".to_string(), + )); + } + + let backup_path = current_source + .as_ref() + .map(|source| create_openclaw_backup(source)) + .transpose()? + .map(|path| path.display().to_string()); + + let next_source = self.text.to_string(); + atomic_write(&self.path, next_source.as_bytes())?; + + let warnings = scan_openclaw_health_from_value( + &json5::from_str::(&next_source).map_err(|e| { + AppError::Config(format!( + "Failed to parse newly written OpenClaw config as JSON5: {e}" + )) + })?, + ); + + log::debug!("OpenClaw config written to {:?}", self.path); + Ok(OpenClawWriteOutcome { + backup_path, + warnings, + }) + } +} + +fn write_root_section(section: &str, value: &Value) -> Result { + let mut document = OpenClawConfigDocument::load()?; + document.set_root_section(section, value)?; + document.save() +} + +fn create_openclaw_backup(source: &str) -> Result { + let backup_dir = get_app_config_dir().join("backups").join("openclaw"); + fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?; + + let base_id = format!("openclaw_{}", Local::now().format("%Y%m%d_%H%M%S")); + let mut filename = format!("{base_id}.json5"); + let mut backup_path = backup_dir.join(&filename); + let mut counter = 1; + + while backup_path.exists() { + filename = format!("{base_id}_{counter}.json5"); + backup_path = backup_dir.join(&filename); + counter += 1; + } + + atomic_write(&backup_path, source.as_bytes())?; + cleanup_openclaw_backups(&backup_dir)?; + Ok(backup_path) +} + +fn cleanup_openclaw_backups(dir: &Path) -> Result<(), AppError> { + let retain = effective_backup_retain_count(); + let mut entries = fs::read_dir(dir) + .map_err(|e| AppError::io(dir, e))? + .filter_map(|entry| entry.ok()) + .filter(|entry| { + entry + .path() + .extension() + .map(|ext| ext == "json5" || ext == "json") + .unwrap_or(false) + }) + .collect::>(); + + if entries.len() <= retain { + return Ok(()); + } + + entries.sort_by_key(|entry| entry.metadata().and_then(|m| m.modified()).ok()); + let remove_count = entries.len().saturating_sub(retain); + for entry in entries.into_iter().take(remove_count) { + if let Err(err) = fs::remove_file(entry.path()) { + log::warn!( + "Failed to remove old OpenClaw config backup {}: {err}", + entry.path().display() + ); + } + } - log::debug!("OpenClaw config written to {path:?}"); Ok(()) } +fn ensure_object(value: &mut Value) -> &mut Map { + if !value.is_object() { + *value = Value::Object(Map::new()); + } + value + .as_object_mut() + .expect("value should be object after normalization") +} + +fn ensure_kvp_context(pair: &mut RtJSONKeyValuePair) -> &mut RtKeyValuePairContext { + pair.context.get_or_insert_with(|| RtKeyValuePairContext { + wsc: (String::new(), " ".to_string(), String::new(), None), + }) +} + +fn extract_trailing_indent(separator_ws: &str) -> String { + separator_ws + .rsplit_once('\n') + .map(|(_, tail)| tail.to_string()) + .unwrap_or_default() +} + +fn derive_closing_ws_from_separator(separator_ws: &str) -> String { + let Some((prefix, indent)) = separator_ws.rsplit_once('\n') else { + return String::new(); + }; + + let reduced_indent = if indent.ends_with('\t') { + &indent[..indent.len().saturating_sub(1)] + } else if indent.ends_with(" ") { + &indent[..indent.len().saturating_sub(2)] + } else if indent.ends_with(' ') { + &indent[..indent.len().saturating_sub(1)] + } else { + indent + }; + + format!("{prefix}\n{reduced_indent}") +} + +fn derive_entry_separator(leading_ws: &str) -> String { + if leading_ws.is_empty() { + return String::new(); + } + + if leading_ws.contains('\n') { + return format!("\n{}", extract_trailing_indent(leading_ws)); + } + + String::new() +} + +fn value_to_rt_value(value: &Value, parent_indent: &str) -> Result { + let source = json_five::to_string_formatted( + value, + FormatConfiguration::with_indent(2, TrailingComma::NONE), + ) + .map_err(|e| AppError::Config(format!("Failed to serialize JSON5 section: {e}")))?; + + let adjusted = reindent_json5_block(&source, parent_indent); + let text = rt_from_str(&adjusted) + .map_err(|e| AppError::Config(format!("Failed to parse generated JSON5 section: {}", e.message)))?; + Ok(text.value) +} + +fn reindent_json5_block(source: &str, parent_indent: &str) -> String { + let normalized = normalize_json_five_output(source); + if parent_indent.is_empty() || !normalized.contains('\n') { + return normalized; + } + + let mut lines = normalized.lines(); + let Some(first_line) = lines.next() else { + return String::new(); + }; + + let mut result = String::from(first_line); + for line in lines { + result.push('\n'); + result.push_str(parent_indent); + result.push_str(line); + } + result +} + +fn normalize_json_five_output(source: &str) -> String { + source.replace("\\/", "/") +} + +fn make_root_pair(key: &str, value: RtJSONValue, closing_ws: String) -> RtJSONKeyValuePair { + RtJSONKeyValuePair { + key: make_json5_key(key), + value, + context: Some(RtKeyValuePairContext { + wsc: (String::new(), " ".to_string(), closing_ws, None), + }), + } +} + +fn make_json5_key(key: &str) -> RtJSONValue { + if is_identifier_key(key) { + RtJSONValue::Identifier(key.to_string()) + } else { + RtJSONValue::DoubleQuotedString(key.to_string()) + } +} + +fn is_identifier_key(key: &str) -> bool { + let mut chars = key.chars(); + let Some(first) = chars.next() else { + return false; + }; + + matches!(first, 'a'..='z' | 'A'..='Z' | '_' | '$') + && chars.all(|ch| matches!(ch, 'a'..='z' | 'A'..='Z' | '0'..='9' | '_' | '$')) +} + +fn json5_key_name(key: &RtJSONValue) -> Option<&str> { + match key { + RtJSONValue::Identifier(name) + | RtJSONValue::DoubleQuotedString(name) + | RtJSONValue::SingleQuotedString(name) => Some(name), + _ => None, + } +} + +fn warning(code: &str, message: impl Into, path: Option<&str>) -> OpenClawHealthWarning { + OpenClawHealthWarning { + code: code.to_string(), + message: message.into(), + path: path.map(|value| value.to_string()), + } +} + +fn scan_openclaw_health_from_value(config: &Value) -> Vec { + let mut warnings = Vec::new(); + + if let Some(profile) = config + .get("tools") + .and_then(|tools| tools.get("profile")) + .and_then(Value::as_str) + { + if !OPENCLAW_TOOLS_PROFILES.contains(&profile) { + warnings.push(warning( + "invalid_tools_profile", + format!("tools.profile uses unsupported value '{profile}'."), + Some("tools.profile"), + )); + } + } + + if config + .get("agents") + .and_then(|agents| agents.get("defaults")) + .and_then(|defaults| defaults.get("timeout")) + .is_some() + { + warnings.push(warning( + "legacy_agents_timeout", + "agents.defaults.timeout is deprecated; use agents.defaults.timeoutSeconds.", + Some("agents.defaults.timeout"), + )); + } + + if let Some(value) = config.get("env").and_then(|env| env.get("vars")) { + if !value.is_object() { + warnings.push(warning( + "stringified_env_vars", + "env.vars should be an object. The current value looks stringified or malformed.", + Some("env.vars"), + )); + } + } + + if let Some(value) = config.get("env").and_then(|env| env.get("shellEnv")) { + if !value.is_object() { + warnings.push(warning( + "stringified_env_shell_env", + "env.shellEnv should be an object. The current value looks stringified or malformed.", + Some("env.shellEnv"), + )); + } + } + + warnings +} + +fn remove_legacy_timeout(defaults_value: &mut Value) { + if let Some(defaults_obj) = defaults_value.as_object_mut() { + defaults_obj.remove("timeout"); + } +} + // ============================================================================ // Provider Functions (Untyped - for raw JSON operations) // ============================================================================ @@ -255,54 +615,72 @@ pub fn get_providers() -> Result, AppError> { Ok(config .get("models") .and_then(|m| m.get("providers")) - .and_then(|v| v.as_object()) + .and_then(Value::as_object) .cloned() .unwrap_or_default()) } +/// 获取单个供应商配置(原始 JSON) +pub fn get_provider(id: &str) -> Result, AppError> { + Ok(get_providers()?.get(id).cloned()) +} + /// 设置供应商配置(原始 JSON) /// /// 写入到 `models.providers` -pub fn set_provider(id: &str, provider_config: Value) -> Result<(), AppError> { +pub fn set_provider(id: &str, provider_config: Value) -> Result { let mut full_config = read_openclaw_config()?; - - // 确保 models 结构存在 - if full_config.get("models").is_none() { - full_config["models"] = json!({ + let root = ensure_object(&mut full_config); + let models = root.entry("models".to_string()).or_insert_with(|| { + json!({ "mode": "merge", "providers": {} + }) + }); + let providers = ensure_object(models) + .entry("providers".to_string()) + .or_insert_with(|| Value::Object(Map::new())); + ensure_object(providers).insert(id.to_string(), provider_config); + + let models_value = root + .get("models") + .cloned() + .unwrap_or_else(|| { + json!({ + "mode": "merge", + "providers": {} + }) }); - } - - // 确保 providers 对象存在 - if full_config["models"].get("providers").is_none() { - full_config["models"]["providers"] = json!({}); - } - - // 设置供应商 - if let Some(providers) = full_config["models"] - .get_mut("providers") - .and_then(|v| v.as_object_mut()) - { - providers.insert(id.to_string(), provider_config); - } - - write_openclaw_config(&full_config) + write_root_section("models", &models_value) } /// 删除供应商配置 -pub fn remove_provider(id: &str) -> Result<(), AppError> { +pub fn remove_provider(id: &str) -> Result { let mut config = read_openclaw_config()?; + let mut removed = false; if let Some(providers) = config .get_mut("models") - .and_then(|m| m.get_mut("providers")) - .and_then(|v| v.as_object_mut()) + .and_then(|models| models.get_mut("providers")) + .and_then(Value::as_object_mut) { - providers.remove(id); + removed = providers.remove(id).is_some(); } - write_openclaw_config(&config) + if !removed { + return Ok(OpenClawWriteOutcome::default()); + } + + let models_value = config + .get("models") + .cloned() + .unwrap_or_else(|| { + json!({ + "mode": "merge", + "providers": {} + }) + }); + write_root_section("models", &models_value) } // ============================================================================ @@ -321,7 +699,6 @@ pub fn get_typed_providers() -> Result, } Err(e) => { log::warn!("Failed to parse OpenClaw provider '{id}': {e}"); - // Skip invalid providers but continue } } } @@ -330,7 +707,10 @@ pub fn get_typed_providers() -> Result, } /// 设置供应商配置(类型化) -pub fn set_typed_provider(id: &str, config: &OpenClawProviderConfig) -> Result<(), AppError> { +pub fn set_typed_provider( + id: &str, + config: &OpenClawProviderConfig, +) -> Result { let value = serde_json::to_value(config).map_err(|e| AppError::JsonSerialize { source: e })?; set_provider(id, value) } @@ -353,23 +733,29 @@ pub fn get_default_model() -> Result, AppError> { let model = serde_json::from_value(model_value.clone()) .map_err(|e| AppError::Config(format!("Failed to parse agents.defaults.model: {e}")))?; - Ok(Some(model)) } /// 设置默认模型配置(agents.defaults.model) -pub fn set_default_model(model: &OpenClawDefaultModel) -> Result<(), AppError> { +pub fn set_default_model(model: &OpenClawDefaultModel) -> Result { let mut config = read_openclaw_config()?; - - // Ensure agents.defaults path exists, preserving unknown fields - ensure_agents_defaults_path(&mut config); + let root = ensure_object(&mut config); + let agents = root + .entry("agents".to_string()) + .or_insert_with(|| Value::Object(Map::new())); + let defaults = ensure_object(agents) + .entry("defaults".to_string()) + .or_insert_with(|| Value::Object(Map::new())); let model_value = serde_json::to_value(model).map_err(|e| AppError::JsonSerialize { source: e })?; + ensure_object(defaults).insert("model".to_string(), model_value); - config["agents"]["defaults"]["model"] = model_value; - - write_openclaw_config(&config) + let agents_value = root + .get("agents") + .cloned() + .unwrap_or_else(|| Value::Object(Map::new())); + write_root_section("agents", &agents_value) } /// 读取模型目录/允许列表(agents.defaults.models) @@ -386,36 +772,31 @@ pub fn get_model_catalog() -> Result, -) -> Result<(), AppError> { +) -> Result { let mut config = read_openclaw_config()?; - - // Ensure agents.defaults path exists, preserving unknown fields - ensure_agents_defaults_path(&mut config); + let root = ensure_object(&mut config); + let agents = root + .entry("agents".to_string()) + .or_insert_with(|| Value::Object(Map::new())); + let defaults = ensure_object(agents) + .entry("defaults".to_string()) + .or_insert_with(|| Value::Object(Map::new())); let catalog_value = serde_json::to_value(catalog).map_err(|e| AppError::JsonSerialize { source: e })?; + ensure_object(defaults).insert("models".to_string(), catalog_value); - config["agents"]["defaults"]["models"] = catalog_value; - - write_openclaw_config(&config) -} - -/// Ensure the `agents.defaults` path exists in the config, -/// preserving any existing unknown fields. -fn ensure_agents_defaults_path(config: &mut Value) { - if config.get("agents").is_none() { - config["agents"] = json!({}); - } - if config["agents"].get("defaults").is_none() { - config["agents"]["defaults"] = json!({}); - } + let agents_value = root + .get("agents") + .cloned() + .unwrap_or_else(|| Value::Object(Map::new())); + write_root_section("agents", &agents_value) } // ============================================================================ @@ -432,40 +813,35 @@ pub fn get_agents_defaults() -> Result, AppError> let defaults = serde_json::from_value(defaults_value.clone()) .map_err(|e| AppError::Config(format!("Failed to parse agents.defaults: {e}")))?; - Ok(Some(defaults)) } /// Write the full agents.defaults config -pub fn set_agents_defaults(defaults: &OpenClawAgentsDefaults) -> Result<(), AppError> { +pub fn set_agents_defaults( + defaults: &OpenClawAgentsDefaults, +) -> Result { let mut config = read_openclaw_config()?; + let root = ensure_object(&mut config); + let agents = root + .entry("agents".to_string()) + .or_insert_with(|| Value::Object(Map::new())); - if config.get("agents").is_none() { - config["agents"] = json!({}); - } - - let value = + let mut defaults_value = serde_json::to_value(defaults).map_err(|e| AppError::JsonSerialize { source: e })?; + remove_legacy_timeout(&mut defaults_value); + ensure_object(agents).insert("defaults".to_string(), defaults_value); - config["agents"]["defaults"] = value; - - write_openclaw_config(&config) + let agents_value = root + .get("agents") + .cloned() + .unwrap_or_else(|| Value::Object(Map::new())); + write_root_section("agents", &agents_value) } // ============================================================================ // Env Configuration // ============================================================================ -/// OpenClaw env configuration (env section of openclaw.json) -/// -/// Stores environment variables like API keys and custom vars. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct OpenClawEnvConfig { - /// All environment variable key-value pairs - #[serde(flatten)] - pub vars: HashMap, -} - /// Read the env config section pub fn get_env_config() -> Result { let config = read_openclaw_config()?; @@ -481,42 +857,15 @@ pub fn get_env_config() -> Result { } /// Write the env config section -pub fn set_env_config(env: &OpenClawEnvConfig) -> Result<(), AppError> { - let mut config = read_openclaw_config()?; - +pub fn set_env_config(env: &OpenClawEnvConfig) -> Result { let value = serde_json::to_value(env).map_err(|e| AppError::JsonSerialize { source: e })?; - - config["env"] = value; - - write_openclaw_config(&config) + write_root_section("env", &value) } // ============================================================================ // Tools Configuration // ============================================================================ -/// OpenClaw tools configuration (tools section of openclaw.json) -/// -/// Controls tool permissions with profile-based allow/deny lists. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct OpenClawToolsConfig { - /// Active permission profile (e.g. "default", "strict", "permissive") - #[serde(skip_serializing_if = "Option::is_none")] - pub profile: Option, - - /// Allowed tool patterns - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub allow: Vec, - - /// Denied tool patterns - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub deny: Vec, - - /// Other custom fields (preserve unknown fields) - #[serde(flatten)] - pub extra: HashMap, -} - /// Read the tools config section pub fn get_tools_config() -> Result { let config = read_openclaw_config()?; @@ -535,12 +884,111 @@ pub fn get_tools_config() -> Result { } /// Write the tools config section -pub fn set_tools_config(tools: &OpenClawToolsConfig) -> Result<(), AppError> { - let mut config = read_openclaw_config()?; - +pub fn set_tools_config(tools: &OpenClawToolsConfig) -> Result { let value = serde_json::to_value(tools).map_err(|e| AppError::JsonSerialize { source: e })?; - - config["tools"] = value; - - write_openclaw_config(&config) + write_root_section("tools", &value) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Mutex, OnceLock}; + + fn test_guard() -> std::sync::MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|err| err.into_inner()) + } + + fn with_test_paths(source: &str, test: impl FnOnce(&Path) -> T) -> T { + let _guard = test_guard(); + let temp = tempfile::tempdir().unwrap(); + let openclaw_dir = temp.path().join(".openclaw"); + fs::create_dir_all(&openclaw_dir).unwrap(); + let config_path = openclaw_dir.join("openclaw.json"); + fs::write(&config_path, source).unwrap(); + let old_test_home = std::env::var_os("CC_SWITCH_TEST_HOME"); + let old_home = std::env::var_os("HOME"); + std::env::set_var("CC_SWITCH_TEST_HOME", temp.path()); + std::env::set_var("HOME", temp.path()); + let result = test(&config_path); + match old_test_home { + Some(value) => std::env::set_var("CC_SWITCH_TEST_HOME", value), + None => std::env::remove_var("CC_SWITCH_TEST_HOME"), + } + match old_home { + Some(value) => std::env::set_var("HOME", value), + None => std::env::remove_var("HOME"), + } + result + } + + #[test] + fn scan_health_detects_known_openclaw_issues() { + let config = json!({ + "tools": { "profile": "default" }, + "agents": { "defaults": { "timeout": 30 } }, + "env": { "vars": "[object Object]", "shellEnv": "oops" } + }); + + let warnings = scan_openclaw_health_from_value(&config); + let codes = warnings.into_iter().map(|warning| warning.code).collect::>(); + assert!(codes.contains(&"invalid_tools_profile".to_string())); + assert!(codes.contains(&"legacy_agents_timeout".to_string())); + assert!(codes.contains(&"stringified_env_vars".to_string())); + assert!(codes.contains(&"stringified_env_shell_env".to_string())); + } + + #[test] + fn default_model_write_preserves_top_level_comments() { + let source = r#"{ + // top-level comment + models: { + mode: 'merge', + providers: {}, + }, +} +"#; + + with_test_paths(source, |_| { + let outcome = set_default_model(&OpenClawDefaultModel { + primary: "provider/model".to_string(), + fallbacks: Vec::new(), + extra: HashMap::new(), + }) + .unwrap(); + + assert!(outcome.backup_path.is_some()); + + let written = fs::read_to_string(get_openclaw_config_path()).unwrap(); + assert!(written.contains("// top-level comment")); + assert!(written.contains("agents: {")); + assert!(written.contains("provider/model")); + }); + } + + #[test] + fn save_detects_external_conflict() { + let source = r#"{ + models: { + mode: 'merge', + providers: {}, + }, +} +"#; + + with_test_paths(source, |config_path| { + let mut document = OpenClawConfigDocument::load().unwrap(); + document + .set_root_section("env", &json!({ "TOKEN": "value" })) + .unwrap(); + + fs::write(config_path, "{ changedExternally: true }\n").unwrap(); + let err = document.save().unwrap_err(); + assert!(err + .to_string() + .contains("OpenClaw config changed on disk")); + }); + } } diff --git a/src/App.tsx b/src/App.tsx index 8207a9cc..988f3daf 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -33,7 +33,7 @@ import { } from "@/lib/api"; import { checkAllEnvConflicts, checkEnvConflicts } from "@/lib/api/env"; import { useProviderActions } from "@/hooks/useProviderActions"; -import { openclawKeys } from "@/hooks/useOpenClaw"; +import { openclawKeys, useOpenClawHealth } from "@/hooks/useOpenClaw"; import { useProxyStatus } from "@/hooks/useProxyStatus"; import { useAutoCompact } from "@/hooks/useAutoCompact"; import { useLastValidValue } from "@/hooks/useLastValidValue"; @@ -70,6 +70,7 @@ import WorkspaceFilesPanel from "@/components/workspace/WorkspaceFilesPanel"; import EnvPanel from "@/components/openclaw/EnvPanel"; import ToolsPanel from "@/components/openclaw/ToolsPanel"; import AgentsDefaultsPanel from "@/components/openclaw/AgentsDefaultsPanel"; +import OpenClawHealthBanner from "@/components/openclaw/OpenClawHealthBanner"; type View = | "providers" @@ -229,6 +230,17 @@ function App() { }); const providers = useMemo(() => data?.providers ?? {}, [data]); const currentProviderId = data?.currentProviderId ?? ""; + const isOpenClawView = + activeApp === "openclaw" && + (currentView === "providers" || + currentView === "workspace" || + currentView === "sessions" || + currentView === "openclawEnv" || + currentView === "openclawTools" || + currentView === "openclawAgents"); + const { data: openclawHealthWarnings = [] } = useOpenClawHealth( + isOpenClawView, + ); const hasSkillsSupport = true; const hasSessionSupport = activeApp === "claude" || @@ -544,6 +556,9 @@ function App() { await queryClient.invalidateQueries({ queryKey: openclawKeys.liveProviderIds, }); + await queryClient.invalidateQueries({ + queryKey: openclawKeys.health, + }); } toast.success( t("notifications.removeFromConfigSuccess", { @@ -1225,6 +1240,9 @@ function App() {
+ {isOpenClawView && openclawHealthWarnings.length > 0 && ( + + )} {renderContent()}
diff --git a/src/components/openclaw/AgentsDefaultsPanel.tsx b/src/components/openclaw/AgentsDefaultsPanel.tsx index e8611f07..45b97999 100644 --- a/src/components/openclaw/AgentsDefaultsPanel.tsx +++ b/src/components/openclaw/AgentsDefaultsPanel.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { Save, Plus, Trash2 } from "lucide-react"; +import { Save, Plus, Trash2, TriangleAlert } from "lucide-react"; import { toast } from "sonner"; import { useOpenClawAgentsDefaults, @@ -10,6 +10,7 @@ import { extractErrorMessage } from "@/utils/errorUtils"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Select, SelectContent, @@ -19,6 +20,7 @@ import { } from "@/components/ui/select"; import type { OpenClawAgentsDefaults } from "@/types"; import { useOpenClawModelOptions } from "./hooks/useOpenClawModelOptions"; +import { getOpenClawTimeoutInputValue } from "./utils"; const UNSET_SENTINEL = "__unset__"; @@ -50,9 +52,16 @@ const AgentsDefaultsPanel: React.FC = () => { // Extract known extra fields setWorkspace(String(agentsData.workspace ?? "")); - setTimeout_(String(agentsData.timeout ?? "")); + setTimeout_(getOpenClawTimeoutInputValue(agentsData)); setContextTokens(String(agentsData.contextTokens ?? "")); setMaxConcurrent(String(agentsData.maxConcurrent ?? "")); + } else { + setPrimaryModel(""); + setFallbacks([]); + setWorkspace(""); + setTimeout_(""); + setContextTokens(""); + setMaxConcurrent(""); } }, [agentsData]); @@ -146,8 +155,9 @@ const AgentsDefaultsPanel: React.FC = () => { }; const timeoutNum = timeout.trim() ? parseNum(timeout) : undefined; - if (timeoutNum !== undefined) updated.timeout = timeoutNum; - else delete updated.timeout; + if (timeoutNum !== undefined) updated.timeoutSeconds = timeoutNum; + else delete updated.timeoutSeconds; + delete updated.timeout; const ctxNum = contextTokens.trim() ? parseNum(contextTokens) : undefined; if (ctxNum !== undefined) updated.contextTokens = ctxNum; @@ -159,8 +169,15 @@ const AgentsDefaultsPanel: React.FC = () => { if (concNum !== undefined) updated.maxConcurrent = concNum; else delete updated.maxConcurrent; - await saveAgentsMutation.mutateAsync(updated); - toast.success(t("openclaw.agents.saveSuccess")); + const outcome = await saveAgentsMutation.mutateAsync(updated); + toast.success(t("openclaw.agents.saveSuccess"), { + description: outcome.backupPath + ? t("openclaw.backupCreated", { + path: outcome.backupPath, + defaultValue: "Backup created: {{path}}", + }) + : undefined, + }); } catch (error) { const detail = extractErrorMessage(error); toast.error(t("openclaw.agents.saveFailed"), { @@ -180,6 +197,11 @@ const AgentsDefaultsPanel: React.FC = () => { } const noModels = modelOptions.length === 0 && !modelsLoading; + const hasLegacyTimeout = + agentsData !== undefined && + agentsData !== null && + typeof agentsData.timeout === "number" && + typeof agentsData.timeoutSeconds !== "number"; return (
@@ -187,6 +209,23 @@ const AgentsDefaultsPanel: React.FC = () => { {t("openclaw.agents.description")}

+ {hasLegacyTimeout && ( + + + + {t("openclaw.agents.legacyTimeoutTitle", { + defaultValue: "Legacy timeout detected", + })} + + + {t("openclaw.agents.legacyTimeoutDescription", { + defaultValue: + "This config still uses agents.defaults.timeout. Saving here will migrate it to timeoutSeconds.", + })} + + + )} + {/* Model Configuration Card */}

diff --git a/src/components/openclaw/EnvPanel.tsx b/src/components/openclaw/EnvPanel.tsx index 544ad79c..638045d2 100644 --- a/src/components/openclaw/EnvPanel.tsx +++ b/src/components/openclaw/EnvPanel.tsx @@ -1,96 +1,77 @@ -import React, { useState, useEffect } from "react"; +import React, { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Plus, Trash2, Save, Eye, EyeOff } from "lucide-react"; +import { Save } from "lucide-react"; import { toast } from "sonner"; import { useOpenClawEnv, useSaveOpenClawEnv } from "@/hooks/useOpenClaw"; import { extractErrorMessage } from "@/utils/errorUtils"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import type { OpenClawEnvConfig } from "@/types"; - -interface EnvEntry { - id: string; - key: string; - value: string; - isNew?: boolean; -} +import JsonEditor from "@/components/JsonEditor"; +import { parseOpenClawEnvEditorValue } from "./utils"; const EnvPanel: React.FC = () => { const { t } = useTranslation(); const { data: envData, isLoading } = useOpenClawEnv(); const saveEnvMutation = useSaveOpenClawEnv(); - const [entries, setEntries] = useState([]); - const [visibleKeys, setVisibleKeys] = useState>(new Set()); + const [editorValue, setEditorValue] = useState("{}"); + const [isDarkMode, setIsDarkMode] = useState(false); useEffect(() => { - if (envData) { - const items: EnvEntry[] = Object.entries(envData).map(([key, value]) => ({ - id: crypto.randomUUID(), - key, - value: String(value ?? ""), - })); - setEntries(items.length > 0 ? items : []); - } + const nextValue = + envData && Object.keys(envData).length > 0 + ? JSON.stringify(envData, null, 2) + : "{}"; + setEditorValue(nextValue); }, [envData]); + useEffect(() => { + setIsDarkMode(document.documentElement.classList.contains("dark")); + + const observer = new MutationObserver(() => { + setIsDarkMode(document.documentElement.classList.contains("dark")); + }); + + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"], + }); + + return () => observer.disconnect(); + }, []); + const handleSave = async () => { try { - const env: OpenClawEnvConfig = {}; - const seen = new Set(); - for (const entry of entries) { - const trimmedKey = entry.key.trim(); - if (trimmedKey) { - if (seen.has(trimmedKey)) { - toast.error(t("openclaw.env.duplicateKey", { key: trimmedKey })); - return; - } - seen.add(trimmedKey); - env[trimmedKey] = entry.value; - } - } - await saveEnvMutation.mutateAsync(env); - toast.success(t("openclaw.env.saveSuccess")); + const env = parseOpenClawEnvEditorValue(editorValue); + const outcome = await saveEnvMutation.mutateAsync(env); + toast.success(t("openclaw.env.saveSuccess"), { + description: outcome.backupPath + ? t("openclaw.backupCreated", { + path: outcome.backupPath, + defaultValue: "Backup created: {{path}}", + }) + : undefined, + }); } catch (error) { const detail = extractErrorMessage(error); + let description = detail || undefined; + if (detail === "OPENCLAW_ENV_EMPTY") { + description = t("openclaw.env.empty", { + defaultValue: "OpenClaw env cannot be empty. Use {} for an empty object.", + }); + } else if (detail === "OPENCLAW_ENV_INVALID_JSON") { + description = t("openclaw.env.invalidJson", { + defaultValue: "OpenClaw env must be valid JSON.", + }); + } else if (detail === "OPENCLAW_ENV_OBJECT_REQUIRED") { + description = t("openclaw.env.objectRequired", { + defaultValue: "OpenClaw env must be a JSON object.", + }); + } toast.error(t("openclaw.env.saveFailed"), { - description: detail || undefined, + description, }); } }; - const addEntry = () => { - setEntries((prev) => [ - ...prev, - { id: crypto.randomUUID(), key: "", value: "", isNew: true }, - ]); - }; - - const removeEntry = (index: number) => { - setEntries((prev) => prev.filter((_, i) => i !== index)); - }; - - const updateEntry = (index: number, field: "key" | "value", val: string) => { - setEntries((prev) => - prev.map((entry, i) => - i === index ? { ...entry, [field]: val } : entry, - ), - ); - }; - - const toggleVisibility = (key: string) => { - setVisibleKeys((prev) => { - const next = new Set(prev); - if (next.has(key)) { - next.delete(key); - } else { - next.add(key); - } - return next; - }); - }; - - const isApiKey = (key: string) => /key|token|secret|password/i.test(key); - if (isLoading) { return (
@@ -106,66 +87,23 @@ const EnvPanel: React.FC = () => {

{t("openclaw.env.description")}

- -
- {entries.map((entry, index) => { - const sensitive = isApiKey(entry.key); - const visibilityId = entry.key || `__new_${index}`; - const visible = visibleKeys.has(visibilityId); - - return ( -
-
- updateEntry(index, "key", e.target.value)} - placeholder={t("openclaw.env.keyPlaceholder")} - className="font-mono text-xs" - autoFocus={entry.isNew} - /> -
-
- updateEntry(index, "value", e.target.value)} - placeholder={t("openclaw.env.valuePlaceholder")} - className="font-mono text-xs" - /> - {sensitive && ( - - )} -
- -
- ); +

+ {t("openclaw.env.editorHint", { + defaultValue: + "Edit the full env section as JSON. Nested objects such as env.vars and env.shellEnv are supported.", })} -

+

-
- -
+ + +