Files
clawpal/src-tauri/src/commands.rs
2026-02-18 21:19:15 +09:00

4098 lines
139 KiB
Rust

use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::{fs, process::Command, time::{SystemTime, UNIX_EPOCH}};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use tauri::State;
use crate::config_io::{ensure_dirs, read_openclaw_config, write_json, write_text};
use crate::doctor::{apply_auto_fixes, run_doctor, DoctorReport};
use crate::history::{add_snapshot, list_snapshots, read_snapshot};
use crate::models::resolve_paths;
use crate::ssh::{SshConnectionPool, SshHostConfig, SshExecResult, SftpEntry};
use crate::recipe::{
load_recipes_with_fallback,
collect_change_paths,
build_candidate_config_from_template,
format_diff,
ApplyResult,
PreviewResult,
};
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SystemStatus {
pub healthy: bool,
pub config_path: String,
pub openclaw_dir: String,
pub clawpal_dir: String,
pub openclaw_version: String,
pub active_agents: u32,
pub snapshots: usize,
pub channels: ChannelSummary,
pub models: ModelSummary,
pub memory: MemorySummary,
pub sessions: SessionSummary,
pub openclaw_update: OpenclawUpdateCheck,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OpenclawUpdateCheck {
pub installed_version: String,
pub latest_version: Option<String>,
pub upgrade_available: bool,
pub channel: Option<String>,
pub details: Option<String>,
pub source: String,
pub checked_at: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelCatalogProviderCache {
pub cli_version: String,
pub updated_at: u64,
pub providers: Vec<ModelCatalogProvider>,
pub source: String,
pub error: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OpenclawCommandOutput {
pub stdout: String,
pub stderr: String,
pub exit_code: i32,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExtractModelProfilesResult {
pub created: usize,
pub reused: usize,
pub skipped_invalid: usize,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExtractModelProfileEntry {
pub provider: String,
pub model: String,
pub source: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OpenclawUpdateCache {
pub checked_at: u64,
pub latest_version: Option<String>,
pub channel: Option<String>,
pub details: Option<String>,
pub source: String,
pub installed_version: Option<String>,
pub ttl_seconds: u64,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelSummary {
pub global_default_model: Option<String>,
pub agent_overrides: Vec<String>,
pub channel_overrides: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChannelSummary {
pub configured_channels: usize,
pub channel_model_overrides: usize,
pub channel_examples: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MemoryFileSummary {
pub path: String,
pub size_bytes: u64,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MemorySummary {
pub file_count: usize,
pub total_bytes: u64,
pub files: Vec<MemoryFileSummary>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MemoryFile {
pub path: String,
pub relative_path: String,
pub size_bytes: u64,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentSessionSummary {
pub agent: String,
pub session_files: usize,
pub archive_files: usize,
pub total_bytes: u64,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionFile {
pub path: String,
pub relative_path: String,
pub agent: String,
pub kind: String,
pub size_bytes: u64,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionAnalysis {
pub agent: String,
pub session_id: String,
pub file_path: String,
pub size_bytes: u64,
pub message_count: usize,
pub user_message_count: usize,
pub assistant_message_count: usize,
pub last_activity: Option<String>,
pub age_days: f64,
pub total_tokens: u64,
pub model: Option<String>,
pub category: String,
pub kind: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentSessionAnalysis {
pub agent: String,
pub total_files: usize,
pub total_size_bytes: u64,
pub empty_count: usize,
pub low_value_count: usize,
pub valuable_count: usize,
pub sessions: Vec<SessionAnalysis>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionSummary {
pub total_session_files: usize,
pub total_archive_files: usize,
pub total_bytes: u64,
pub by_agent: Vec<AgentSessionSummary>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ModelProfile {
pub id: String,
pub name: String,
pub provider: String,
pub model: String,
#[serde(default)]
pub auth_ref: String,
#[serde(default)]
pub api_key: Option<String>,
pub base_url: Option<String>,
pub description: Option<String>,
pub enabled: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ModelCatalogModel {
pub id: String,
pub name: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ModelCatalogProvider {
pub provider: String,
pub base_url: Option<String>,
pub models: Vec<ModelCatalogModel>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChannelNode {
pub path: String,
pub channel_type: Option<String>,
pub mode: Option<String>,
pub allowlist: Vec<String>,
pub model: Option<String>,
pub has_model_field: bool,
pub display_name: Option<String>,
pub name_status: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DiscordGuildChannel {
pub guild_id: String,
pub guild_name: String,
pub channel_id: String,
pub channel_name: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProviderAuthSuggestion {
pub auth_ref: Option<String>,
pub has_key: bool,
pub source: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelBinding {
pub scope: String,
pub scope_id: String,
pub model_profile_id: Option<String>,
pub model_value: Option<String>,
pub path: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HistoryItem {
pub id: String,
pub recipe_id: Option<String>,
pub created_at: String,
pub source: String,
pub can_rollback: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollback_of: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HistoryPage {
pub items: Vec<HistoryItem>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FixResult {
pub ok: bool,
pub applied: Vec<String>,
pub remaining_issues: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentOverview {
pub id: String,
pub name: Option<String>,
pub emoji: Option<String>,
pub model: Option<String>,
pub channels: Vec<String>,
pub online: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub workspace: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StatusLight {
pub healthy: bool,
pub active_agents: u32,
pub global_default_model: Option<String>,
}
/// Fast status: reads config + quick TCP probe of gateway port.
#[tauri::command]
pub fn get_status_light() -> Result<StatusLight, String> {
let paths = resolve_paths();
let cfg = read_openclaw_config(&paths)?;
let active_agents = cfg
.get("agents")
.and_then(|a| a.get("list"))
.and_then(|a| a.as_array())
.map(|a| a.len() as u32)
.unwrap_or(0);
let global_default_model = cfg
.pointer("/agents/defaults/model")
.and_then(read_model_value)
.or_else(|| cfg.pointer("/agents/default/model").and_then(read_model_value));
// Quick gateway health: TCP connect to gateway port
let gateway_port = cfg.pointer("/gateway/port")
.and_then(Value::as_u64)
.unwrap_or(8080) as u16;
let healthy = std::net::TcpStream::connect_timeout(
&std::net::SocketAddr::from(([127, 0, 0, 1], gateway_port)),
std::time::Duration::from_millis(500),
).is_ok();
Ok(StatusLight {
healthy,
active_agents,
global_default_model,
})
}
/// Returns cached catalog instantly without calling CLI. Returns empty if no cache.
#[tauri::command]
pub fn get_cached_model_catalog() -> Result<Vec<ModelCatalogProvider>, String> {
let paths = resolve_paths();
let cache_path = model_catalog_cache_path(&paths);
if let Some(cached) = read_model_catalog_cache(&cache_path) {
if cached.error.is_none() && !cached.providers.is_empty() {
return Ok(cached.providers);
}
}
Ok(Vec::new())
}
/// Refresh catalog from CLI and update cache. Returns the fresh catalog.
#[tauri::command]
pub fn refresh_model_catalog() -> Result<Vec<ModelCatalogProvider>, String> {
let paths = resolve_paths();
let cfg = read_openclaw_config(&paths)?;
load_model_catalog(&paths, &cfg)
}
#[tauri::command]
pub fn get_system_status() -> Result<SystemStatus, String> {
let paths = resolve_paths();
ensure_dirs(&paths)?;
let cfg = read_openclaw_config(&paths)?;
let active_agents = cfg
.get("agents")
.and_then(|a| a.get("list"))
.and_then(|a| a.as_array())
.map(|a| a.len() as u32)
.unwrap_or(0);
let snapshots = list_snapshots(&paths.metadata_path).unwrap_or_default().items.len();
let model_summary = collect_model_summary(&cfg);
let channel_summary = collect_channel_summary(&cfg);
let memory = collect_memory_overview(&paths.base_dir);
let sessions = collect_session_overview(&paths.base_dir);
let openclaw_version = resolve_openclaw_version();
let openclaw_update = check_openclaw_update_cached(&paths, false).unwrap_or_else(|_| OpenclawUpdateCheck {
installed_version: openclaw_version.clone(),
latest_version: None,
upgrade_available: false,
channel: None,
details: Some("update status unavailable".into()),
source: "unknown".into(),
checked_at: format_timestamp_from_unix(unix_timestamp_secs()),
});
Ok(SystemStatus {
healthy: true,
config_path: paths.config_path.to_string_lossy().to_string(),
openclaw_dir: paths.openclaw_dir.to_string_lossy().to_string(),
clawpal_dir: paths.clawpal_dir.to_string_lossy().to_string(),
openclaw_version,
active_agents,
snapshots,
channels: channel_summary,
models: model_summary,
memory,
sessions,
openclaw_update,
})
}
#[tauri::command]
pub fn list_model_profiles() -> Result<Vec<ModelProfile>, String> {
let paths = resolve_paths();
Ok(load_model_profiles(&paths))
}
#[tauri::command]
pub fn list_model_catalog() -> Result<Vec<ModelCatalogProvider>, String> {
let paths = resolve_paths();
let cfg = read_openclaw_config(&paths)?;
load_model_catalog(&paths, &cfg)
}
#[tauri::command]
pub fn check_openclaw_update() -> Result<OpenclawUpdateCheck, String> {
let paths = resolve_paths();
check_openclaw_update_cached(&paths, true)
}
#[tauri::command]
pub fn extract_model_profiles_from_config() -> Result<ExtractModelProfilesResult, String> {
let paths = resolve_paths();
let cfg = read_openclaw_config(&paths)?;
let profiles = load_model_profiles(&paths);
let bindings = collect_model_bindings(&cfg, &profiles);
let mut created = 0usize;
let mut reused = 0usize;
let mut skipped_invalid = 0usize;
let mut seen = HashSet::new();
let mut next_profiles = profiles;
let mut model_profile_map: HashMap<String, String> = HashMap::new();
for profile in &next_profiles {
model_profile_map.insert(normalize_model_ref(&profile_to_model_value(profile)), profile.id.clone());
}
for binding in bindings {
let scope_label = match binding.scope.as_str() {
"global" => "global".to_string(),
"agent" => format!("agent:{}", binding.scope_id),
"channel" => format!("channel:{}", binding.scope_id),
_ => binding.scope_id,
};
let Some(model_ref) = binding.model_value else {
continue;
};
let model_ref = normalize_model_ref(&model_ref);
if model_ref.trim().is_empty() {
continue;
}
if model_profile_map.contains_key(&model_ref) || seen.contains(&model_ref) {
reused += 1;
continue;
}
let mut parts = model_ref.splitn(2, '/');
let provider = parts.next().unwrap_or("").trim();
let model = parts.next().unwrap_or("").trim();
if provider.is_empty() || model.is_empty() {
skipped_invalid += 1;
continue;
}
let auth_ref = resolve_auth_ref_for_provider(&cfg, provider)
.unwrap_or_else(|| format!("{provider}:default"));
let base_url = resolve_model_provider_base_url(&cfg, provider);
let profile = ModelProfile {
id: uuid::Uuid::new_v4().to_string(),
name: format!("{scope_label} model profile"),
provider: provider.to_string(),
model: model.to_string(),
auth_ref,
api_key: None,
base_url,
description: Some(format!("Extracted from config ({scope_label})")),
enabled: true,
};
let key = profile_to_model_value(&profile);
model_profile_map.insert(normalize_model_ref(&key), profile.id.clone());
next_profiles.push(profile);
seen.insert(model_ref);
created += 1;
}
if created > 0 {
save_model_profiles(&paths, &next_profiles)?;
}
Ok(ExtractModelProfilesResult {
created,
reused,
skipped_invalid,
})
}
#[tauri::command]
pub fn upsert_model_profile(mut profile: ModelProfile) -> Result<ModelProfile, String> {
if profile.provider.trim().is_empty() || profile.model.trim().is_empty() {
return Err("provider and model are required".into());
}
if profile.name.trim().is_empty() {
profile.name = format!("{}/{}", profile.provider, profile.model);
}
let has_api_key = profile.api_key.as_ref().is_some_and(|k| !k.trim().is_empty());
if profile.auth_ref.trim().is_empty() && !has_api_key {
// Auto-resolve auth ref from openclaw config or env vars
let paths_tmp = resolve_paths();
if let Ok(cfg) = read_openclaw_config(&paths_tmp) {
if let Some(auth_ref) = resolve_auth_ref_for_provider(&cfg, &profile.provider) {
profile.auth_ref = auth_ref;
}
}
if profile.auth_ref.trim().is_empty() {
// Try env var convention
let provider_upper = profile.provider.trim().to_uppercase().replace('-', "_");
for suffix in ["_API_KEY", "_KEY", "_TOKEN"] {
let env_name = format!("{provider_upper}{suffix}");
if std::env::var(&env_name).map(|v| !v.trim().is_empty()).unwrap_or(false) {
profile.auth_ref = env_name;
break;
}
}
}
if profile.auth_ref.trim().is_empty() {
return Err("API key or auth env var is required".into());
}
}
let paths = resolve_paths();
let mut profiles = load_model_profiles(&paths);
if profile.id.trim().is_empty() {
profile.id = uuid::Uuid::new_v4().to_string();
}
let id = profile.id.clone();
if let Some(existing) = profiles.iter_mut().find(|p| p.id == id) {
*existing = profile.clone();
} else {
profiles.push(profile.clone());
}
save_model_profiles(&paths, &profiles)?;
Ok(profile)
}
#[tauri::command]
pub fn delete_model_profile(profile_id: String) -> Result<bool, String> {
let paths = resolve_paths();
let mut profiles = load_model_profiles(&paths);
let before = profiles.len();
profiles.retain(|p| p.id != profile_id);
if profiles.len() == before {
return Ok(false);
}
save_model_profiles(&paths, &profiles)?;
Ok(true)
}
#[tauri::command]
pub fn resolve_provider_auth(provider: String) -> Result<ProviderAuthSuggestion, String> {
let provider_trimmed = provider.trim();
if provider_trimmed.is_empty() {
return Ok(ProviderAuthSuggestion { auth_ref: None, has_key: false, source: String::new() });
}
let paths = resolve_paths();
let cfg = read_openclaw_config(&paths)?;
// 1. Check openclaw config auth profiles
if let Some(auth_ref) = resolve_auth_ref_for_provider(&cfg, provider_trimmed) {
return Ok(ProviderAuthSuggestion {
auth_ref: Some(auth_ref),
has_key: true,
source: "openclaw auth profile".into(),
});
}
// 2. Check env vars
let provider_upper = provider_trimmed.to_uppercase().replace('-', "_");
for suffix in ["_API_KEY", "_KEY", "_TOKEN"] {
let env_name = format!("{provider_upper}{suffix}");
if std::env::var(&env_name).map(|v| !v.trim().is_empty()).unwrap_or(false) {
return Ok(ProviderAuthSuggestion {
auth_ref: Some(env_name),
has_key: true,
source: "environment variable".into(),
});
}
}
// 3. Check existing model profiles for this provider
let profiles = load_model_profiles(&paths);
for p in &profiles {
if p.provider.eq_ignore_ascii_case(provider_trimmed) {
let key = resolve_profile_api_key(p, &paths.base_dir);
if !key.is_empty() {
let auth_ref = if !p.auth_ref.trim().is_empty() {
Some(p.auth_ref.clone())
} else {
None
};
return Ok(ProviderAuthSuggestion {
auth_ref,
has_key: true,
source: format!("existing profile {}/{}", p.provider, p.model),
});
}
}
}
Ok(ProviderAuthSuggestion { auth_ref: None, has_key: false, source: String::new() })
}
#[tauri::command]
pub fn list_channels() -> Result<Vec<ChannelNode>, String> {
let paths = resolve_paths();
let cfg = read_openclaw_config(&paths)?;
let mut nodes = collect_channel_nodes(&cfg);
enrich_channel_display_names(&paths, &cfg, &mut nodes)?;
Ok(nodes)
}
#[tauri::command]
pub fn list_channels_minimal() -> Result<Vec<ChannelNode>, String> {
let paths = resolve_paths();
let cfg = read_openclaw_config(&paths)?;
let nodes = collect_channel_nodes(&cfg);
Ok(nodes)
}
/// Read Discord guild/channels from persistent cache. Fast, no subprocess.
#[tauri::command]
pub fn list_discord_guild_channels() -> Result<Vec<DiscordGuildChannel>, String> {
let paths = resolve_paths();
let cache_file = paths.clawpal_dir.join("discord-guild-channels.json");
if cache_file.exists() {
let text = fs::read_to_string(&cache_file).map_err(|e| e.to_string())?;
let entries: Vec<DiscordGuildChannel> = serde_json::from_str(&text).unwrap_or_default();
return Ok(entries);
}
Ok(Vec::new())
}
/// Resolve Discord guild/channel names via openclaw CLI and persist to cache.
#[tauri::command]
pub async fn refresh_discord_guild_channels() -> Result<Vec<DiscordGuildChannel>, String> {
tauri::async_runtime::spawn_blocking(move || {
let paths = resolve_paths();
ensure_dirs(&paths)?;
let cfg = read_openclaw_config(&paths)?;
let guilds = cfg
.get("channels")
.and_then(|c| c.get("discord"))
.and_then(|d| d.get("guilds"))
.and_then(Value::as_object);
let Some(guilds) = guilds else {
return Ok(Vec::new());
};
let mut entries: Vec<DiscordGuildChannel> = Vec::new();
let mut channel_ids: Vec<String> = Vec::new();
for (guild_id, guild_val) in guilds {
let guild_name = guild_val
.get("slug")
.or_else(|| guild_val.get("name"))
.and_then(Value::as_str)
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| guild_id.clone());
if let Some(channels) = guild_val.get("channels").and_then(Value::as_object) {
for (channel_id, _channel_val) in channels {
channel_ids.push(channel_id.clone());
entries.push(DiscordGuildChannel {
guild_id: guild_id.clone(),
guild_name: guild_name.clone(),
channel_id: channel_id.clone(),
channel_name: channel_id.clone(),
});
}
}
}
if !channel_ids.is_empty() {
let mut args = vec![
"channels", "resolve", "--json",
"--channel", "discord",
"--kind", "auto",
];
let id_refs: Vec<&str> = channel_ids.iter().map(String::as_str).collect();
args.extend_from_slice(&id_refs);
if let Ok(output) = run_openclaw_raw(&args) {
if let Some(name_map) = parse_resolve_name_map(&output.stdout) {
for entry in &mut entries {
if let Some(name) = name_map.get(&entry.channel_id) {
entry.channel_name = name.clone();
}
}
}
}
}
// Persist to cache
let cache_file = paths.clawpal_dir.join("discord-guild-channels.json");
let json = serde_json::to_string_pretty(&entries).map_err(|e| e.to_string())?;
write_text(&cache_file, &json)?;
Ok(entries)
}).await.map_err(|e| e.to_string())?
}
#[tauri::command]
pub fn update_channel_config(
path: String,
channel_type: Option<String>,
mode: Option<String>,
allowlist: Vec<String>,
model: Option<String>,
) -> Result<bool, String> {
if path.trim().is_empty() {
return Err("channel path is required".into());
}
let paths = resolve_paths();
let mut cfg = read_openclaw_config(&paths)?;
let current = serde_json::to_string_pretty(&cfg).map_err(|e| e.to_string())?;
set_nested_value(&mut cfg, &format!("{path}.type"), channel_type.map(Value::String))?;
set_nested_value(&mut cfg, &format!("{path}.mode"), mode.map(Value::String))?;
let allowlist_values = allowlist
.into_iter()
.map(Value::String)
.collect::<Vec<_>>();
set_nested_value(&mut cfg, &format!("{path}.allowlist"), Some(Value::Array(allowlist_values)))?;
set_nested_value(&mut cfg, &format!("{path}.model"), model.map(Value::String))?;
write_config_with_snapshot(&paths, &current, &cfg, "update-channel")?;
Ok(true)
}
/// List current channel→agent bindings from config.
#[tauri::command]
pub fn list_bindings() -> Result<Vec<Value>, String> {
let paths = resolve_paths();
let cfg = read_openclaw_config(&paths)?;
let bindings = cfg
.get("bindings")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
Ok(bindings)
}
/// Assign a Discord channel to an agent (modifies the `bindings` array).
/// Pass `agent_id = None` or empty to remove the binding (revert to default agent).
#[tauri::command]
pub fn assign_channel_agent(
channel_type: String,
peer_id: String,
agent_id: Option<String>,
) -> Result<bool, String> {
let paths = resolve_paths();
let mut cfg = read_openclaw_config(&paths)?;
let current = serde_json::to_string_pretty(&cfg).map_err(|e| e.to_string())?;
let bindings = cfg
.get_mut("bindings")
.and_then(Value::as_array_mut);
let agent_id = agent_id
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
if let Some(arr) = bindings {
// Remove existing binding for this peer
arr.retain(|b| {
let m = b.get("match");
let ch = m.and_then(|m| m.get("channel")).and_then(Value::as_str);
let pid = m.and_then(|m| m.pointer("/peer/id")).and_then(Value::as_str);
!(ch == Some(&channel_type) && pid == Some(&peer_id))
});
// Add new binding if agent_id is provided
if let Some(ref aid) = agent_id {
arr.push(serde_json::json!({
"agentId": aid,
"match": {
"channel": channel_type,
"peer": {
"id": peer_id,
"kind": "channel"
}
}
}));
}
} else if let Some(ref aid) = agent_id {
// No bindings array yet — create one
cfg.as_object_mut()
.ok_or("config is not an object")?
.insert("bindings".into(), serde_json::json!([
{
"agentId": aid,
"match": {
"channel": channel_type,
"peer": {
"id": peer_id,
"kind": "channel"
}
}
}
]));
}
write_config_with_snapshot(&paths, &current, &cfg, "assign-channel-agent")?;
Ok(true)
}
#[tauri::command]
pub fn delete_channel_node(path: String) -> Result<bool, String> {
if path.trim().is_empty() {
return Err("channel path is required".into());
}
let paths = resolve_paths();
let mut cfg = read_openclaw_config(&paths)?;
let current = serde_json::to_string_pretty(&cfg).map_err(|e| e.to_string())?;
let before = cfg.to_string();
set_nested_value(&mut cfg, &path, None)?;
if cfg.to_string() == before {
return Ok(false);
}
write_config_with_snapshot(&paths, &current, &cfg, "delete-channel")?;
Ok(true)
}
#[tauri::command]
pub fn set_global_model(profile_id: Option<String>) -> Result<bool, String> {
let paths = resolve_paths();
let mut cfg = read_openclaw_config(&paths)?;
let current = serde_json::to_string_pretty(&cfg).map_err(|e| e.to_string())?;
let model = resolve_profile_model_value(&paths, profile_id)?;
set_nested_value(
&mut cfg,
"agents.defaults.model",
model.map(Value::String),
)?;
write_config_with_snapshot(&paths, &current, &cfg, "set-global-model")?;
Ok(true)
}
#[tauri::command]
pub fn set_agent_model(agent_id: String, profile_id: Option<String>) -> Result<bool, String> {
if agent_id.trim().is_empty() {
return Err("agent id is required".into());
}
let paths = resolve_paths();
let mut cfg = read_openclaw_config(&paths)?;
let current = serde_json::to_string_pretty(&cfg).map_err(|e| e.to_string())?;
let value = resolve_profile_model_value(&paths, profile_id)?;
set_agent_model_value(&mut cfg, &agent_id, value)?;
write_config_with_snapshot(&paths, &current, &cfg, "set-agent-model")?;
Ok(true)
}
#[tauri::command]
pub fn set_channel_model(path: String, profile_id: Option<String>) -> Result<bool, String> {
if path.trim().is_empty() {
return Err("channel path is required".into());
}
let paths = resolve_paths();
let mut cfg = read_openclaw_config(&paths)?;
let current = serde_json::to_string_pretty(&cfg).map_err(|e| e.to_string())?;
let value = resolve_profile_model_value(&paths, profile_id)?;
set_nested_value(&mut cfg, &format!("{path}.model"), value.map(Value::String))?;
write_config_with_snapshot(&paths, &current, &cfg, "set-channel-model")?;
Ok(true)
}
#[tauri::command]
pub fn list_model_bindings() -> Result<Vec<ModelBinding>, String> {
let paths = resolve_paths();
let cfg = read_openclaw_config(&paths)?;
let profiles = load_model_profiles(&paths);
Ok(collect_model_bindings(&cfg, &profiles))
}
#[tauri::command]
pub fn list_agent_ids() -> Result<Vec<String>, String> {
let paths = resolve_paths();
let cfg = read_openclaw_config(&paths)?;
Ok(collect_agent_ids(&cfg))
}
#[tauri::command]
pub fn list_agents_overview() -> Result<Vec<AgentOverview>, String> {
let paths = resolve_paths();
let cfg = read_openclaw_config(&paths)?;
let mut agents = Vec::new();
let mut seen_ids = std::collections::HashSet::new();
let default_workspace = cfg.pointer("/agents/defaults/workspace")
.and_then(Value::as_str)
.map(|s| expand_tilde(s));
if let Some(list) = cfg.pointer("/agents/list").and_then(Value::as_array) {
let channel_nodes = collect_channel_nodes(&cfg);
for agent in list {
let id = agent.get("id").and_then(Value::as_str).unwrap_or("agent").to_string();
// Deduplicate by ID
if !seen_ids.insert(id.clone()) {
continue;
}
// Read name/emoji from IDENTITY.md in the agent's workspace
let workspace = agent.get("workspace").and_then(Value::as_str)
.map(|s| expand_tilde(s))
.or_else(|| default_workspace.clone());
let (name, emoji) = workspace.as_ref()
.and_then(|ws| parse_identity_md(ws))
.unwrap_or((None, None));
let model = agent.get("model").and_then(read_model_value)
.or_else(|| cfg.pointer("/agents/defaults/model").and_then(read_model_value))
.or_else(|| cfg.pointer("/agents/default/model").and_then(read_model_value));
let channels: Vec<String> = channel_nodes.iter()
.map(|ch| ch.path.clone())
.collect();
let has_sessions = paths.base_dir.join("agents").join(&id).join("sessions").exists();
agents.push(AgentOverview {
id,
name,
emoji,
model,
channels,
online: has_sessions,
workspace,
});
}
}
Ok(agents)
}
#[tauri::command]
pub fn create_agent(
agent_id: String,
model_profile_id: Option<String>,
independent: Option<bool>,
) -> Result<AgentOverview, String> {
let agent_id = agent_id.trim().to_string();
if agent_id.is_empty() {
return Err("Agent ID is required".into());
}
if !agent_id.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
return Err("Agent ID may only contain letters, numbers, hyphens, and underscores".into());
}
let paths = resolve_paths();
let mut cfg = read_openclaw_config(&paths)?;
let current = serde_json::to_string_pretty(&cfg).map_err(|e| e.to_string())?;
let existing_ids = collect_agent_ids(&cfg);
if existing_ids.iter().any(|id| id.eq_ignore_ascii_case(&agent_id)) {
return Err(format!("Agent '{}' already exists", agent_id));
}
// Resolve model value from profile if provided
let model_value = if let Some(ref pid) = model_profile_id {
let pid = pid.trim();
if pid.is_empty() { None } else {
Some(resolve_profile_model_value(&paths, Some(pid.to_string()))?)
}
} else { None };
let model_display = model_value.flatten();
// If independent, create a dedicated workspace directory;
// otherwise inherit the default workspace so the gateway doesn't auto-create one.
let workspace = if independent.unwrap_or(false) {
let ws_dir = paths.base_dir.join("workspaces").join(&agent_id);
fs::create_dir_all(&ws_dir).map_err(|e| e.to_string())?;
let ws_path = ws_dir.to_string_lossy().to_string();
Some(ws_path)
} else {
cfg.pointer("/agents/defaults/workspace")
.or_else(|| cfg.pointer("/agents/default/workspace"))
.and_then(Value::as_str)
.map(|s| s.to_string())
};
// Build agent entry
let mut agent_obj = serde_json::Map::new();
agent_obj.insert("id".into(), Value::String(agent_id.clone()));
if let Some(ref model_str) = model_display {
agent_obj.insert("model".into(), Value::String(model_str.clone()));
}
if let Some(ref ws) = workspace {
agent_obj.insert("workspace".into(), Value::String(ws.clone()));
}
let agents = cfg
.as_object_mut()
.ok_or("config is not an object")?
.entry("agents")
.or_insert_with(|| Value::Object(serde_json::Map::new()))
.as_object_mut()
.ok_or("agents is not an object")?;
let list = agents
.entry("list")
.or_insert_with(|| Value::Array(Vec::new()))
.as_array_mut()
.ok_or("agents.list is not an array")?;
list.push(Value::Object(agent_obj));
write_config_with_snapshot(&paths, &current, &cfg, "create-agent")?;
Ok(AgentOverview {
id: agent_id,
name: None,
emoji: None,
model: model_display,
channels: vec![],
online: false,
workspace,
})
}
#[tauri::command]
pub fn delete_agent(agent_id: String) -> Result<bool, String> {
let agent_id = agent_id.trim().to_string();
if agent_id.is_empty() {
return Err("Agent ID is required".into());
}
if agent_id == "main" {
return Err("Cannot delete the main agent".into());
}
let paths = resolve_paths();
let mut cfg = read_openclaw_config(&paths)?;
let current = serde_json::to_string_pretty(&cfg).map_err(|e| e.to_string())?;
let list = cfg
.pointer_mut("/agents/list")
.and_then(Value::as_array_mut)
.ok_or("agents.list not found")?;
let before = list.len();
list.retain(|agent| {
agent.get("id").and_then(Value::as_str) != Some(&agent_id)
});
if list.len() == before {
return Err(format!("Agent '{}' not found", agent_id));
}
// Reset any bindings that reference this agent back to "main" (default)
// so the channel doesn't lose its binding entry entirely.
if let Some(bindings) = cfg.pointer_mut("/bindings").and_then(Value::as_array_mut) {
for b in bindings.iter_mut() {
if b.get("agentId").and_then(Value::as_str) == Some(&agent_id) {
if let Some(obj) = b.as_object_mut() {
obj.insert("agentId".into(), Value::String("main".into()));
}
}
}
}
write_config_with_snapshot(&paths, &current, &cfg, "delete-agent")?;
Ok(true)
}
#[tauri::command]
pub fn setup_agent_identity(
agent_id: String,
name: String,
emoji: Option<String>,
) -> Result<bool, String> {
let agent_id = agent_id.trim().to_string();
let name = name.trim().to_string();
if agent_id.is_empty() {
return Err("Agent ID is required".into());
}
if name.is_empty() {
return Err("Name is required".into());
}
let paths = resolve_paths();
let cfg = read_openclaw_config(&paths)?;
// Find the agent's workspace
let agents_list = cfg.pointer("/agents/list")
.and_then(Value::as_array)
.ok_or("agents.list not found")?;
let agent = agents_list.iter()
.find(|a| a.get("id").and_then(Value::as_str) == Some(&agent_id))
.ok_or_else(|| format!("Agent '{}' not found", agent_id))?;
let default_workspace = cfg.pointer("/agents/defaults/workspace")
.or_else(|| cfg.pointer("/agents/default/workspace"))
.and_then(Value::as_str)
.map(|s| expand_tilde(s));
let workspace = agent.get("workspace")
.and_then(Value::as_str)
.map(|s| expand_tilde(s))
.or(default_workspace)
.ok_or_else(|| format!("Agent '{}' has no workspace configured", agent_id))?;
// Build IDENTITY.md content
let mut content = format!("- Name: {}\n", name);
if let Some(ref e) = emoji {
let e = e.trim();
if !e.is_empty() {
content.push_str(&format!("- Emoji: {}\n", e));
}
}
let ws_path = std::path::Path::new(&workspace);
fs::create_dir_all(ws_path).map_err(|e| format!("Failed to create workspace dir: {}", e))?;
let identity_path = ws_path.join("IDENTITY.md");
fs::write(&identity_path, &content)
.map_err(|e| format!("Failed to write IDENTITY.md: {}", e))?;
Ok(true)
}
fn expand_tilde(path: &str) -> String {
if path.starts_with("~/") {
if let Some(home) = std::env::var("HOME").ok() {
return format!("{}{}", home, &path[1..]);
}
}
path.to_string()
}
fn parse_identity_md(workspace: &str) -> Option<(Option<String>, Option<String>)> {
let identity_path = std::path::Path::new(workspace).join("IDENTITY.md");
let content = fs::read_to_string(&identity_path).ok()?;
let mut name = None;
let mut emoji = None;
for line in content.lines() {
let trimmed = line.trim().trim_start_matches('-').trim();
// Handle both "Name: X" and "**Name:** X"
let trimmed = trimmed.replace("**", "");
if let Some(val) = trimmed.strip_prefix("Name:") {
let val = val.trim();
if !val.is_empty() {
name = Some(val.to_string());
}
} else if let Some(val) = trimmed.strip_prefix("Emoji:") {
let val = val.trim();
if !val.is_empty() {
emoji = Some(val.to_string());
}
}
}
Some((name, emoji))
}
#[tauri::command]
pub fn list_memory_files() -> Result<Vec<MemoryFile>, String> {
let paths = resolve_paths();
list_memory_files_detailed(&paths.base_dir.join("memory"))
}
#[tauri::command]
pub fn delete_memory_file(path: String) -> Result<bool, String> {
let paths = resolve_paths();
let root = paths.base_dir.join("memory");
let target = resolve_child_path(&root, &path)?;
if !target.exists() {
return Ok(false);
}
if !target.is_file() {
return Err("target is not a file".into());
}
fs::remove_file(&target).map_err(|e| e.to_string())?;
Ok(true)
}
#[tauri::command]
pub fn clear_memory() -> Result<usize, String> {
let paths = resolve_paths();
let root = paths.base_dir.join("memory");
if !root.exists() {
return Ok(0);
}
let count = count_files_recursive(&root);
fs::remove_dir_all(&root).map_err(|e| e.to_string())?;
fs::create_dir_all(&root).map_err(|e| e.to_string())?;
Ok(count)
}
#[tauri::command]
pub fn list_session_files() -> Result<Vec<SessionFile>, String> {
let paths = resolve_paths();
list_session_files_detailed(&paths.base_dir)
}
#[tauri::command]
pub fn delete_session_file(path: String) -> Result<bool, String> {
let paths = resolve_paths();
let target = resolve_child_path(&paths.base_dir, &path)?;
if !target.exists() {
return Ok(false);
}
if !target.is_file() {
return Err("target is not a file".into());
}
fs::remove_file(&target).map_err(|e| e.to_string())?;
Ok(true)
}
#[tauri::command]
pub fn clear_all_sessions() -> Result<usize, String> {
let paths = resolve_paths();
clear_agent_and_global_sessions(&paths.base_dir.join("agents"), None)
}
#[tauri::command]
pub fn clear_agent_sessions(agent_id: String) -> Result<usize, String> {
if agent_id.trim().is_empty() {
return Err("agent id is required".into());
}
if agent_id.contains("..") || agent_id.contains('/') || agent_id.contains('\\') {
return Err("invalid agent id".into());
}
let paths = resolve_paths();
clear_agent_and_global_sessions(&paths.base_dir.join("agents"), Some(agent_id.as_str()))
}
#[tauri::command]
pub async fn analyze_sessions() -> Result<Vec<AgentSessionAnalysis>, String> {
tauri::async_runtime::spawn_blocking(|| {
analyze_sessions_sync()
})
.await
.map_err(|e| e.to_string())?
}
fn analyze_sessions_sync() -> Result<Vec<AgentSessionAnalysis>, String> {
let paths = resolve_paths();
let agents_root = paths.base_dir.join("agents");
if !agents_root.exists() {
return Ok(Vec::new());
}
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as f64;
let mut results: Vec<AgentSessionAnalysis> = Vec::new();
let entries = fs::read_dir(&agents_root).map_err(|e| e.to_string())?;
for entry in entries.flatten() {
let entry_path = entry.path();
if !entry_path.is_dir() {
continue;
}
let agent = entry.file_name().to_string_lossy().to_string();
// Load sessions.json metadata for this agent
let sessions_json_path = entry_path.join("sessions").join("sessions.json");
let sessions_meta: HashMap<String, Value> = if sessions_json_path.exists() {
let text = fs::read_to_string(&sessions_json_path).unwrap_or_default();
serde_json::from_str(&text).unwrap_or_default()
} else {
HashMap::new()
};
// Build sessionId -> metadata lookup
let mut meta_by_id: HashMap<String, &Value> = HashMap::new();
for (_key, val) in &sessions_meta {
if let Some(sid) = val.get("sessionId").and_then(Value::as_str) {
meta_by_id.insert(sid.to_string(), val);
}
}
let mut agent_sessions: Vec<SessionAnalysis> = Vec::new();
for (kind_name, dir_name) in [("sessions", "sessions"), ("archive", "sessions_archive")] {
let dir = entry_path.join(dir_name);
if !dir.exists() {
continue;
}
let files = match fs::read_dir(&dir) {
Ok(f) => f,
Err(_) => continue,
};
for file_entry in files.flatten() {
let file_path = file_entry.path();
let fname = file_entry.file_name().to_string_lossy().to_string();
if !fname.ends_with(".jsonl") {
continue;
}
let metadata = match file_entry.metadata() {
Ok(m) => m,
Err(_) => continue,
};
let size_bytes = metadata.len();
// Extract session ID from filename (e.g. "abc123.jsonl" or "abc123-topic-456.jsonl")
let session_id = fname.trim_end_matches(".jsonl").to_string();
// Parse JSONL to count messages
let mut message_count = 0usize;
let mut user_message_count = 0usize;
let mut assistant_message_count = 0usize;
let mut last_activity: Option<String> = None;
if let Ok(file) = fs::File::open(&file_path) {
let reader = BufReader::new(file);
for line in reader.lines() {
let line = match line {
Ok(l) => l,
Err(_) => continue,
};
if line.trim().is_empty() {
continue;
}
let obj: Value = match serde_json::from_str(&line) {
Ok(v) => v,
Err(_) => continue,
};
if obj.get("type").and_then(Value::as_str) == Some("message") {
message_count += 1;
if let Some(ts) = obj.get("timestamp").and_then(Value::as_str) {
last_activity = Some(ts.to_string());
}
let role = obj.pointer("/message/role").and_then(Value::as_str);
match role {
Some("user") => user_message_count += 1,
Some("assistant") => assistant_message_count += 1,
_ => {}
}
}
}
}
// Look up metadata from sessions.json
// For topic files like "abc-topic-123", try the base session ID "abc"
let base_id = if session_id.contains("-topic-") {
session_id.split("-topic-").next().unwrap_or(&session_id)
} else {
&session_id
};
let meta = meta_by_id.get(base_id);
let total_tokens = meta
.and_then(|m| m.get("totalTokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let model = meta
.and_then(|m| m.get("model"))
.and_then(Value::as_str)
.map(|s| s.to_string());
let updated_at = meta
.and_then(|m| m.get("updatedAt"))
.and_then(Value::as_f64)
.unwrap_or(0.0);
let age_days = if updated_at > 0.0 {
(now - updated_at) / (1000.0 * 60.0 * 60.0 * 24.0)
} else {
// Fall back to file modification time
metadata.modified().ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| (now - d.as_millis() as f64) / (1000.0 * 60.0 * 60.0 * 24.0))
.unwrap_or(0.0)
};
// Classify
let category = if size_bytes < 500 || message_count == 0 {
"empty"
} else if user_message_count <= 1 && age_days > 7.0 {
"low_value"
} else {
"valuable"
};
agent_sessions.push(SessionAnalysis {
agent: agent.clone(),
session_id,
file_path: file_path.to_string_lossy().to_string(),
size_bytes,
message_count,
user_message_count,
assistant_message_count,
last_activity,
age_days,
total_tokens,
model,
category: category.to_string(),
kind: kind_name.to_string(),
});
}
}
// Sort: empty first, then low_value, then valuable; within each by age descending
agent_sessions.sort_by(|a, b| {
let cat_order = |c: &str| match c {
"empty" => 0,
"low_value" => 1,
_ => 2,
};
cat_order(&a.category).cmp(&cat_order(&b.category))
.then(b.age_days.partial_cmp(&a.age_days).unwrap_or(std::cmp::Ordering::Equal))
});
let total_files = agent_sessions.len();
let total_size_bytes = agent_sessions.iter().map(|s| s.size_bytes).sum();
let empty_count = agent_sessions.iter().filter(|s| s.category == "empty").count();
let low_value_count = agent_sessions.iter().filter(|s| s.category == "low_value").count();
let valuable_count = agent_sessions.iter().filter(|s| s.category == "valuable").count();
if total_files > 0 {
results.push(AgentSessionAnalysis {
agent,
total_files,
total_size_bytes,
empty_count,
low_value_count,
valuable_count,
sessions: agent_sessions,
});
}
}
results.sort_by(|a, b| b.total_size_bytes.cmp(&a.total_size_bytes));
Ok(results)
}
#[tauri::command]
pub async fn delete_sessions_by_ids(agent_id: String, session_ids: Vec<String>) -> Result<usize, String> {
tauri::async_runtime::spawn_blocking(move || {
delete_sessions_by_ids_sync(&agent_id, &session_ids)
})
.await
.map_err(|e| e.to_string())?
}
fn delete_sessions_by_ids_sync(agent_id: &str, session_ids: &[String]) -> Result<usize, String> {
if agent_id.trim().is_empty() {
return Err("agent id is required".into());
}
if agent_id.contains("..") || agent_id.contains('/') || agent_id.contains('\\') {
return Err("invalid agent id".into());
}
let paths = resolve_paths();
let agent_dir = paths.base_dir.join("agents").join(agent_id);
let mut deleted = 0usize;
// Search in both sessions and sessions_archive
let dirs = ["sessions", "sessions_archive"];
for sid in session_ids {
if sid.contains("..") || sid.contains('/') || sid.contains('\\') {
continue;
}
for dir_name in &dirs {
let dir = agent_dir.join(dir_name);
if !dir.exists() {
continue;
}
let jsonl_path = dir.join(format!("{}.jsonl", sid));
if jsonl_path.exists() {
if fs::remove_file(&jsonl_path).is_ok() {
deleted += 1;
}
}
// Also clean up related files (topic files, .lock, .deleted.*)
if let Ok(entries) = fs::read_dir(&dir) {
for entry in entries.flatten() {
let fname = entry.file_name().to_string_lossy().to_string();
if fname.starts_with(sid.as_str()) && fname != format!("{}.jsonl", sid) {
let _ = fs::remove_file(entry.path());
}
}
}
}
}
// Remove entries from sessions.json (in sessions dir)
let sessions_json_path = agent_dir.join("sessions").join("sessions.json");
if sessions_json_path.exists() {
if let Ok(text) = fs::read_to_string(&sessions_json_path) {
if let Ok(mut data) = serde_json::from_str::<serde_json::Map<String, Value>>(&text) {
let id_set: HashSet<&str> = session_ids.iter().map(String::as_str).collect();
data.retain(|_key, val| {
let sid = val.get("sessionId").and_then(Value::as_str).unwrap_or("");
!id_set.contains(sid)
});
let _ = fs::write(&sessions_json_path, serde_json::to_string(&data).unwrap_or_default());
}
}
}
Ok(deleted)
}
#[tauri::command]
pub async fn preview_session(agent_id: String, session_id: String) -> Result<Vec<Value>, String> {
tauri::async_runtime::spawn_blocking(move || {
preview_session_sync(&agent_id, &session_id)
})
.await
.map_err(|e| e.to_string())?
}
fn preview_session_sync(agent_id: &str, session_id: &str) -> Result<Vec<Value>, String> {
if agent_id.contains("..") || agent_id.contains('/') || agent_id.contains('\\') {
return Err("invalid agent id".into());
}
if session_id.contains("..") || session_id.contains('/') || session_id.contains('\\') {
return Err("invalid session id".into());
}
let paths = resolve_paths();
let agent_dir = paths.base_dir.join("agents").join(agent_id);
let jsonl_name = format!("{}.jsonl", session_id);
// Search in both sessions and sessions_archive
let file_path = ["sessions", "sessions_archive"]
.iter()
.map(|dir| agent_dir.join(dir).join(&jsonl_name))
.find(|p| p.exists());
let file_path = match file_path {
Some(p) => p,
None => return Ok(Vec::new()),
};
let file = fs::File::open(&file_path).map_err(|e| e.to_string())?;
let reader = BufReader::new(file);
let mut messages: Vec<Value> = Vec::new();
for line in reader.lines() {
let line = match line {
Ok(l) => l,
Err(_) => continue,
};
if line.trim().is_empty() {
continue;
}
let obj: Value = match serde_json::from_str(&line) {
Ok(v) => v,
Err(_) => continue,
};
if obj.get("type").and_then(Value::as_str) == Some("message") {
let role = obj.pointer("/message/role").and_then(Value::as_str).unwrap_or("unknown");
let content = obj.pointer("/message/content")
.map(|c| {
if let Some(arr) = c.as_array() {
arr.iter()
.filter_map(|item| item.get("text").and_then(Value::as_str))
.collect::<Vec<_>>()
.join("\n")
} else if let Some(s) = c.as_str() {
s.to_string()
} else {
String::new()
}
})
.unwrap_or_default();
messages.push(serde_json::json!({
"role": role,
"content": content,
}));
}
}
Ok(messages)
}
#[tauri::command]
pub fn list_recipes(source: Option<String>) -> Result<Vec<crate::recipe::Recipe>, String> {
let paths = resolve_paths();
let default_path = paths.clawpal_dir.join("recipes").join("recipes.json");
Ok(load_recipes_with_fallback(source, &default_path))
}
#[tauri::command]
pub fn apply_config_patch(
patch_template: String,
params: Map<String, Value>,
) -> Result<ApplyResult, String> {
let paths = resolve_paths();
ensure_dirs(&paths)?;
let current = read_openclaw_config(&paths)?;
let current_text = serde_json::to_string_pretty(&current).map_err(|e| e.to_string())?;
let snapshot = add_snapshot(
&paths.history_dir,
&paths.metadata_path,
Some("config-patch".into()),
"apply",
true,
&current_text,
None,
)?;
let (candidate, _changes) = build_candidate_config_from_template(&current, &patch_template, &params)?;
write_json(&paths.config_path, &candidate)?;
Ok(ApplyResult {
ok: true,
snapshot_id: Some(snapshot.id),
config_path: paths.config_path.to_string_lossy().to_string(),
backup_path: Some(snapshot.config_path),
warnings: Vec::new(),
errors: Vec::new(),
})
}
#[tauri::command]
pub async fn restart_gateway() -> Result<bool, String> {
tauri::async_runtime::spawn_blocking(move || {
run_openclaw_raw(&["gateway", "restart"])?;
Ok(true)
}).await.map_err(|e| e.to_string())?
}
#[tauri::command]
pub fn list_history(limit: usize, offset: usize) -> Result<HistoryPage, String> {
let paths = resolve_paths();
let index = list_snapshots(&paths.metadata_path)?;
let items = index
.items
.into_iter()
.skip(offset)
.take(limit)
.map(|item| HistoryItem {
id: item.id,
recipe_id: item.recipe_id,
created_at: item.created_at,
source: item.source,
can_rollback: item.can_rollback,
rollback_of: item.rollback_of,
})
.collect();
Ok(HistoryPage { items })
}
#[tauri::command]
pub fn preview_rollback(snapshot_id: String) -> Result<PreviewResult, String> {
let paths = resolve_paths();
let index = list_snapshots(&paths.metadata_path)?;
let target = index
.items
.into_iter()
.find(|s| s.id == snapshot_id)
.ok_or_else(|| "snapshot not found".to_string())?;
if !target.can_rollback {
return Err("snapshot is not rollbackable".to_string());
}
let current = read_openclaw_config(&paths)?;
let target_text = read_snapshot(&target.config_path)?;
let target_json: Value = json5::from_str(&target_text).unwrap_or(Value::Object(Default::default()));
let before_text = serde_json::to_string_pretty(&current).unwrap_or_else(|_| "{}".into());
let after_text = serde_json::to_string_pretty(&target_json).unwrap_or_else(|_| "{}".into());
Ok(PreviewResult {
recipe_id: "rollback".into(),
diff: format_diff(&current, &target_json),
config_before: before_text,
config_after: after_text,
changes: collect_change_paths(&current, &target_json),
overwrites_existing: true,
can_rollback: true,
impact_level: "medium".into(),
warnings: vec!["Rollback will replace current configuration".into()],
})
}
#[tauri::command]
pub fn rollback(snapshot_id: String) -> Result<ApplyResult, String> {
let paths = resolve_paths();
ensure_dirs(&paths)?;
let index = list_snapshots(&paths.metadata_path)?;
let target = index
.items
.into_iter()
.find(|s| s.id == snapshot_id)
.ok_or_else(|| "snapshot not found".to_string())?;
if !target.can_rollback {
return Err("snapshot is not rollbackable".to_string());
}
let target_text = read_snapshot(&target.config_path)?;
let backup = read_openclaw_config(&paths)?;
let backup_text = serde_json::to_string_pretty(&backup).map_err(|e| e.to_string())?;
let _ = add_snapshot(
&paths.history_dir,
&paths.metadata_path,
target.recipe_id.clone(),
"rollback",
true,
&backup_text,
Some(target.id.clone()),
)?;
write_text(&paths.config_path, &target_text)?;
Ok(ApplyResult {
ok: true,
snapshot_id: Some(target.id),
config_path: paths.config_path.to_string_lossy().to_string(),
backup_path: None,
warnings: vec!["rolled back".into()],
errors: Vec::new(),
})
}
#[tauri::command]
pub fn run_doctor_command() -> Result<DoctorReport, String> {
let paths = resolve_paths();
Ok(run_doctor(&paths))
}
#[tauri::command]
pub fn fix_issues(ids: Vec<String>) -> Result<FixResult, String> {
let paths = resolve_paths();
let issues = run_doctor(&paths);
let mut fixable = Vec::new();
for issue in issues.issues {
if ids.contains(&issue.id) && issue.auto_fixable {
fixable.push(issue.id);
}
}
let auto_applied = apply_auto_fixes(&paths, &fixable);
let mut remaining = Vec::new();
let mut applied = Vec::new();
for id in ids {
if fixable.contains(&id) && auto_applied.iter().any(|x| x == &id) {
applied.push(id);
} else {
remaining.push(id);
}
}
Ok(FixResult {
ok: true,
applied,
remaining_issues: remaining,
})
}
fn collect_model_summary(cfg: &Value) -> ModelSummary {
let global_default_model = cfg
.pointer("/agents/defaults/model")
.and_then(|value| read_model_value(value))
.or_else(|| cfg.pointer("/agents/default/model").and_then(|value| read_model_value(value)));
let mut agent_overrides = Vec::new();
if let Some(agents) = cfg.pointer("/agents/list").and_then(Value::as_array) {
for agent in agents {
if let Some(model_value) = agent.get("model").and_then(read_model_value) {
let should_emit = global_default_model
.as_ref()
.map(|global| global != &model_value)
.unwrap_or(true);
if should_emit {
let id = agent
.get("id")
.and_then(Value::as_str)
.unwrap_or("agent");
agent_overrides.push(format!("{id} => {model_value}"));
}
}
}
}
ModelSummary {
global_default_model,
agent_overrides,
channel_overrides: collect_channel_model_overrides(cfg),
}
}
fn run_external_command_raw(parts: &[&str]) -> Result<OpenclawCommandOutput, String> {
if parts.is_empty() {
return Err("no command specified".into());
}
let mut command = Command::new(parts[0]);
if parts.len() > 1 {
command.args(&parts[1..]);
}
let output = command
.output()
.map_err(|error| format!("failed to run {}: {error}", parts[0]))?;
let exit_code = output.status.code().unwrap_or(-1);
Ok(OpenclawCommandOutput {
stdout: String::from_utf8_lossy(&output.stdout).trim_end().to_string(),
stderr: String::from_utf8_lossy(&output.stderr).trim_end().to_string(),
exit_code,
})
}
fn run_openclaw_raw(args: &[&str]) -> Result<OpenclawCommandOutput, String> {
run_openclaw_raw_timeout(args, None)
}
fn run_openclaw_raw_timeout(args: &[&str], timeout_secs: Option<u64>) -> Result<OpenclawCommandOutput, String> {
let mut child = Command::new("openclaw")
.args(args)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(|error| format!("failed to run openclaw: {error}"))?;
if let Some(secs) = timeout_secs {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(secs);
loop {
match child.try_wait().map_err(|e| e.to_string())? {
Some(status) => {
let mut stdout_buf = Vec::new();
let mut stderr_buf = Vec::new();
if let Some(mut out) = child.stdout.take() {
std::io::Read::read_to_end(&mut out, &mut stdout_buf).ok();
}
if let Some(mut err) = child.stderr.take() {
std::io::Read::read_to_end(&mut err, &mut stderr_buf).ok();
}
let exit_code = status.code().unwrap_or(-1);
let result = OpenclawCommandOutput {
stdout: String::from_utf8_lossy(&stdout_buf).trim_end().to_string(),
stderr: String::from_utf8_lossy(&stderr_buf).trim_end().to_string(),
exit_code,
};
if exit_code != 0 {
let details = if !result.stderr.is_empty() {
result.stderr.clone()
} else {
result.stdout.clone()
};
return Err(format!("openclaw command failed ({exit_code}): {details}"));
}
return Ok(result);
}
None => {
if std::time::Instant::now() >= deadline {
let _ = child.kill();
return Err(format!(
"Command timed out after {secs}s. The gateway may still be restarting in the background."
));
}
std::thread::sleep(std::time::Duration::from_millis(250));
}
}
}
} else {
let output = child
.wait_with_output()
.map_err(|error| format!("failed to run openclaw: {error}"))?;
let exit_code = output.status.code().unwrap_or(-1);
let result = OpenclawCommandOutput {
stdout: String::from_utf8_lossy(&output.stdout).trim_end().to_string(),
stderr: String::from_utf8_lossy(&output.stderr).trim_end().to_string(),
exit_code,
};
if exit_code != 0 {
let details = if !result.stderr.is_empty() {
result.stderr.clone()
} else {
result.stdout.clone()
};
return Err(format!("openclaw command failed ({exit_code}): {details}"));
}
Ok(result)
}
}
/// Strip leading non-JSON lines from CLI output (plugin logs, ANSI codes, etc.)
fn extract_json_from_output(raw: &str) -> Option<&str> {
let start = raw.find('{').or_else(|| raw.find('['))?;
Some(&raw[start..])
}
/// Extract the last JSON array from CLI output that may contain ANSI codes and plugin logs.
/// Scans from the end to find the last `]`, then finds its matching `[`.
fn extract_last_json_array(raw: &str) -> Option<&str> {
let bytes = raw.as_bytes();
let end = bytes.iter().rposition(|&b| b == b']')?;
let mut depth = 0;
for i in (0..=end).rev() {
match bytes[i] {
b']' => depth += 1,
b'[' => {
depth -= 1;
if depth == 0 {
return Some(&raw[i..=end]);
}
}
_ => {}
}
}
None
}
/// Parse `openclaw channels resolve --json` output into a map of id -> name.
fn parse_resolve_name_map(stdout: &str) -> Option<HashMap<String, String>> {
let json_str = extract_last_json_array(stdout)?;
let parsed: Vec<Value> = serde_json::from_str(json_str).ok()?;
let mut map = HashMap::new();
for item in parsed {
let resolved = item.get("resolved").and_then(Value::as_bool).unwrap_or(false);
if !resolved {
continue;
}
if let (Some(input), Some(name)) = (
item.get("input").and_then(Value::as_str),
item.get("name").and_then(Value::as_str),
) {
let name = name.trim().to_string();
if !name.is_empty() {
map.insert(input.to_string(), name);
}
}
}
Some(map)
}
fn extract_version_from_text(input: &str) -> Option<String> {
let re = regex::Regex::new(r"\d+\.\d+(?:\.\d+){1,3}(?:[-+._a-zA-Z0-9]*)?").ok()?;
re.find(input).map(|mat| mat.as_str().to_string())
}
fn compare_semver(installed: &str, latest: Option<&str>) -> bool {
let installed = normalize_semver_components(installed);
let latest = latest.and_then(normalize_semver_components);
let (mut installed, mut latest) = match (installed, latest) {
(Some(installed), Some(latest)) => (installed, latest),
_ => return false,
};
let len = installed.len().max(latest.len());
while installed.len() < len {
installed.push(0);
}
while latest.len() < len {
latest.push(0);
}
installed < latest
}
fn normalize_semver_components(raw: &str) -> Option<Vec<u32>> {
let mut parts = Vec::new();
for bit in raw.split('.') {
let filtered = bit.trim_start_matches(|c: char| c == 'v' || c == 'V');
let head = filtered.split(|c: char| !c.is_ascii_digit()).next().unwrap_or("");
if head.is_empty() {
continue;
}
parts.push(head.parse::<u32>().ok()?);
}
if parts.is_empty() {
return None;
}
Some(parts)
}
fn unix_timestamp_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |delta| delta.as_secs())
}
fn format_timestamp_from_unix(timestamp: u64) -> String {
let Some(utc) = chrono::DateTime::<chrono::Utc>::from_timestamp(timestamp as i64, 0) else {
return "unknown".into();
};
utc.to_rfc3339()
}
fn openclaw_update_cache_path(paths: &crate::models::OpenClawPaths) -> PathBuf {
paths.clawpal_dir.join("openclaw-update-cache.json")
}
fn read_openclaw_update_cache(
path: &Path,
) -> Option<OpenclawUpdateCache> {
let text = fs::read_to_string(path).ok()?;
serde_json::from_str::<OpenclawUpdateCache>(&text).ok()
}
fn save_openclaw_update_cache(
path: &Path,
cache: &OpenclawUpdateCache,
) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|error| error.to_string())?;
}
let text = serde_json::to_string_pretty(cache).map_err(|error| error.to_string())?;
write_text(path, &text)
}
fn read_model_catalog_cache(path: &Path) -> Option<ModelCatalogProviderCache> {
let text = fs::read_to_string(path).ok()?;
serde_json::from_str::<ModelCatalogProviderCache>(&text).ok()
}
fn save_model_catalog_cache(
path: &Path,
cache: &ModelCatalogProviderCache,
) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|error| error.to_string())?;
}
let text = serde_json::to_string_pretty(cache).map_err(|error| error.to_string())?;
write_text(path, &text)
}
fn model_catalog_cache_path(paths: &crate::models::OpenClawPaths) -> PathBuf {
paths.clawpal_dir.join("model-catalog-cache.json")
}
fn normalize_model_ref(raw: &str) -> String {
raw.trim().to_lowercase().replace('\\', "/")
}
fn resolve_openclaw_version() -> String {
match run_openclaw_raw(&["--version"]) {
Ok(output) => extract_version_from_text(&output.stdout).unwrap_or_else(|| "unknown".into()),
Err(_) => "unknown".into(),
}
}
fn check_openclaw_update_cached(paths: &crate::models::OpenClawPaths, force: bool) -> Result<OpenclawUpdateCheck, String> {
let cache_path = openclaw_update_cache_path(paths);
let now = unix_timestamp_secs();
if !force {
if let Some(cached) = read_openclaw_update_cache(&cache_path) {
if now.saturating_sub(cached.checked_at) < cached.ttl_seconds {
let installed_version = cached.installed_version.unwrap_or_else(resolve_openclaw_version);
let upgrade_available = compare_semver(&installed_version, cached.latest_version.as_deref());
return Ok(OpenclawUpdateCheck {
installed_version,
latest_version: cached.latest_version,
upgrade_available,
channel: cached.channel,
details: cached.details,
source: cached.source,
checked_at: format_timestamp_from_unix(now),
});
}
}
}
let installed_version = resolve_openclaw_version();
let (latest_version, channel, details, source, upgrade_available) = detect_openclaw_update_cached(&installed_version)
.unwrap_or((None, None, Some("failed to detect update status".into()), "openclaw-command".into(), false));
let checked_at = format_timestamp_from_unix(now);
let cache = OpenclawUpdateCache {
checked_at: now,
latest_version: latest_version.clone(),
channel,
details: details.clone(),
source: source.clone(),
installed_version: Some(installed_version.clone()),
ttl_seconds: 60 * 60 * 6,
};
save_openclaw_update_cache(&cache_path, &cache)?;
let upgrade = compare_semver(&installed_version, latest_version.as_deref());
Ok(OpenclawUpdateCheck {
installed_version,
latest_version,
upgrade_available: upgrade || upgrade_available,
channel: cache.channel,
details,
source,
checked_at,
})
}
fn detect_openclaw_update_cached(installed_version: &str) -> Option<(Option<String>, Option<String>, Option<String>, String, bool)> {
let output = run_openclaw_raw(&["update", "status"]).ok()?;
if let Some((latest_version, channel, details, upgrade_available)) =
parse_openclaw_update_json(&output.stdout, installed_version)
{
return Some((latest_version, Some(channel), Some(details), "openclaw update status --json".into(), upgrade_available));
}
let parsed = parse_openclaw_update_text(&output.stdout);
if let Some((latest_version, channel, details)) = parsed {
let source = "openclaw update status".into();
let available = latest_version
.as_ref()
.is_some_and(|latest| compare_semver(installed_version, Some(latest)));
return Some((latest_version, Some(channel), Some(details), source, available));
}
let latest_version = query_openclaw_latest_npm().ok().flatten();
let details = latest_version
.as_ref()
.map(|value| format!("npm latest {value}"))
.unwrap_or_else(|| "update status not available".into());
let upgrade = latest_version
.as_ref()
.is_some_and(|latest| compare_semver(installed_version, Some(latest.as_str())));
Some((latest_version, None, Some(details), "npm".into(), upgrade))
}
fn parse_openclaw_update_json(raw: &str, installed_version: &str) -> Option<(Option<String>, String, String, bool)> {
let json_str = extract_json_from_output(raw)?;
let payload: Value = serde_json::from_str(json_str).ok()?;
let channel = payload
.pointer("/channel/value")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_string();
let latest_from_update = payload
.pointer("/update/registry/latestVersion")
.and_then(Value::as_str)
.map(|value| value.to_string());
let latest = payload
.pointer("/availability/latestVersion")
.and_then(Value::as_str)
.map(|value| value.to_string())
.or(latest_from_update);
let has_update = payload
.pointer("/availability/available")
.and_then(Value::as_bool)
.unwrap_or(false);
let details = payload
.pointer("/availability/latestVersion")
.and_then(Value::as_str)
.map(|value| format!("npm latest {value}"))
.or_else(|| {
if has_update {
Some("update available".into())
} else {
Some("up to date".into())
}
})
.unwrap_or_else(|| "update status unavailable".into());
let upgrade_available = if let Some(latest_version) = latest.as_deref() {
compare_semver(installed_version, Some(latest_version))
} else {
has_update
};
Some((latest, channel, details, upgrade_available))
}
fn parse_openclaw_update_text(raw: &str) -> Option<(Option<String>, String, String)> {
let mut channel = String::from("unknown");
for line in raw.lines() {
if line.contains("Channel") {
let right = line.split('│').last().or_else(|| line.split('|').last())?;
channel = right.trim().to_string();
}
if line.to_lowercase().contains("update") && line.contains("npm latest") {
if let Some(token) = extract_version_from_text(line) {
return Some((Some(token), channel, line.trim().to_string()));
}
return Some((None, channel, line.trim().to_string()));
}
if line.to_lowercase().contains("update") && line.contains("unknown") {
return Some((None, channel, line.trim().to_string()));
}
}
None
}
fn query_openclaw_latest_npm() -> Result<Option<String>, String> {
let output = run_external_command_raw(&["npm", "view", "openclaw", "version"]);
let output = match output {
Ok(output) => output,
Err(_) => return Ok(None),
};
if output.stdout.trim().is_empty() {
return Ok(None);
}
let trimmed = output.stdout.trim().trim_matches(['\"', '\''].as_ref());
Ok(Some(trimmed.to_string()))
}
fn collect_channel_summary(cfg: &Value) -> ChannelSummary {
let examples = collect_channel_model_overrides_list(cfg);
let configured_channels = cfg
.get("channels")
.and_then(|v| v.as_object())
.map(|channels| channels.len())
.unwrap_or(0);
ChannelSummary {
configured_channels,
channel_model_overrides: examples.len(),
channel_examples: examples,
}
}
fn read_model_value(value: &Value) -> Option<String> {
if let Some(value) = value.as_str() {
return Some(value.to_string());
}
if let Some(model_obj) = value.as_object() {
if let Some(primary) = model_obj.get("primary").and_then(Value::as_str) {
return Some(primary.to_string());
}
if let Some(name) = model_obj.get("name").and_then(Value::as_str) {
return Some(name.to_string());
}
if let Some(model) = model_obj.get("model").and_then(Value::as_str) {
return Some(model.to_string());
}
if let Some(model) = model_obj.get("default").and_then(Value::as_str) {
return Some(model.to_string());
}
if let Some(v) = model_obj.get("provider").and_then(Value::as_str) {
if let Some(inner) = model_obj.get("id").and_then(Value::as_str) {
return Some(format!("{v}/{inner}"));
}
}
}
None
}
fn collect_channel_model_overrides(cfg: &Value) -> Vec<String> {
collect_channel_model_overrides_list(cfg)
}
fn collect_channel_model_overrides_list(cfg: &Value) -> Vec<String> {
let mut out = Vec::new();
if let Some(channels) = cfg.get("channels").and_then(Value::as_object) {
for (name, entry) in channels {
let mut branch = Vec::new();
collect_channel_paths(name, entry, &mut branch);
out.extend(branch);
}
}
out
}
fn collect_channel_paths(prefix: &str, node: &Value, out: &mut Vec<String>) {
if let Some(obj) = node.as_object() {
if let Some(model) = obj.get("model").and_then(read_model_value) {
out.push(format!("{prefix} => {model}"));
}
for (key, child) in obj {
if key == "model" {
continue;
}
let next = format!("{prefix}.{key}");
collect_channel_paths(&next, child, out);
}
}
}
fn collect_memory_overview(base_dir: &Path) -> MemorySummary {
let memory_root = base_dir.join("memory");
collect_file_inventory(&memory_root, Some(80))
}
fn collect_file_inventory(path: &Path, max_files: Option<usize>) -> MemorySummary {
let mut queue = VecDeque::new();
let mut file_count = 0usize;
let mut total_bytes = 0u64;
let mut files = Vec::new();
if !path.exists() {
return MemorySummary {
file_count: 0,
total_bytes: 0,
files,
};
}
queue.push_back(path.to_path_buf());
while let Some(current) = queue.pop_front() {
let entries = match fs::read_dir(&current) {
Ok(entries) => entries,
Err(_) => continue,
};
for entry in entries.flatten() {
let entry_path = entry.path();
if let Ok(metadata) = entry.metadata() {
if metadata.is_dir() {
queue.push_back(entry_path);
continue;
}
if metadata.is_file() {
file_count += 1;
total_bytes = total_bytes.saturating_add(metadata.len());
if max_files.is_none_or(|limit| files.len() < limit) {
files.push(MemoryFileSummary {
path: entry_path.to_string_lossy().to_string(),
size_bytes: metadata.len(),
});
}
}
}
}
}
files.sort_by(|a, b| b.size_bytes.cmp(&a.size_bytes));
MemorySummary {
file_count,
total_bytes,
files,
}
}
fn collect_session_overview(base_dir: &Path) -> SessionSummary {
let agents_dir = base_dir.join("agents");
let mut by_agent = Vec::new();
let mut total_session_files = 0usize;
let mut total_archive_files = 0usize;
let mut total_bytes = 0u64;
if !agents_dir.exists() {
return SessionSummary {
total_session_files,
total_archive_files,
total_bytes,
by_agent,
};
}
if let Ok(entries) = fs::read_dir(agents_dir) {
for entry in entries.flatten() {
let agent_path = entry.path();
if !agent_path.is_dir() {
continue;
}
let agent = entry.file_name().to_string_lossy().to_string();
let sessions_dir = agent_path.join("sessions");
let archive_dir = agent_path.join("sessions_archive");
let session_info = collect_file_inventory_with_limit(&sessions_dir);
let archive_info = collect_file_inventory_with_limit(&archive_dir);
if session_info.files > 0 || archive_info.files > 0 {
by_agent.push(AgentSessionSummary {
agent: agent.clone(),
session_files: session_info.files,
archive_files: archive_info.files,
total_bytes: session_info.total_bytes.saturating_add(archive_info.total_bytes),
});
}
total_session_files = total_session_files.saturating_add(session_info.files);
total_archive_files = total_archive_files.saturating_add(archive_info.files);
total_bytes = total_bytes
.saturating_add(session_info.total_bytes)
.saturating_add(archive_info.total_bytes);
}
}
by_agent.sort_by(|a, b| b.total_bytes.cmp(&a.total_bytes));
SessionSummary {
total_session_files,
total_archive_files,
total_bytes,
by_agent,
}
}
struct InventorySummary {
files: usize,
total_bytes: u64,
}
fn collect_file_inventory_with_limit(path: &Path) -> InventorySummary {
if !path.exists() {
return InventorySummary {
files: 0,
total_bytes: 0,
};
}
let mut queue = VecDeque::new();
let mut files = 0usize;
let mut total_bytes = 0u64;
queue.push_back(path.to_path_buf());
while let Some(current) = queue.pop_front() {
let entries = match fs::read_dir(&current) {
Ok(entries) => entries,
Err(_) => continue,
};
for entry in entries.flatten() {
if let Ok(metadata) = entry.metadata() {
let p = entry.path();
if metadata.is_dir() {
queue.push_back(p);
} else if metadata.is_file() {
files += 1;
total_bytes = total_bytes.saturating_add(metadata.len());
}
}
}
}
InventorySummary {
files,
total_bytes,
}
}
fn list_memory_files_detailed(memory_root: &Path) -> Result<Vec<MemoryFile>, String> {
if !memory_root.exists() {
return Ok(Vec::new());
}
let mut queue = VecDeque::new();
let mut files = Vec::new();
queue.push_back(memory_root.to_path_buf());
while let Some(current) = queue.pop_front() {
let entries = match fs::read_dir(&current) {
Ok(entries) => entries,
Err(_) => continue,
};
for entry in entries.flatten() {
let entry_path = entry.path();
let metadata = match entry.metadata() {
Ok(meta) => meta,
Err(_) => continue,
};
if metadata.is_dir() {
queue.push_back(entry_path);
continue;
}
if metadata.is_file() {
let relative_path = entry_path
.strip_prefix(memory_root)
.unwrap_or(&entry_path)
.to_string_lossy()
.to_string();
files.push(MemoryFile {
path: entry_path.to_string_lossy().to_string(),
relative_path,
size_bytes: metadata.len(),
});
}
}
}
files.sort_by(|a, b| b.size_bytes.cmp(&a.size_bytes));
Ok(files)
}
fn list_session_files_detailed(base_dir: &Path) -> Result<Vec<SessionFile>, String> {
let agents_root = base_dir.join("agents");
if !agents_root.exists() {
return Ok(Vec::new());
}
let mut out = Vec::new();
let entries = fs::read_dir(&agents_root).map_err(|e| e.to_string())?;
for entry in entries.flatten() {
let entry_path = entry.path();
if !entry_path.is_dir() {
continue;
}
let agent = entry.file_name().to_string_lossy().to_string();
let sessions_root = entry_path.join("sessions");
let archive_root = entry_path.join("sessions_archive");
collect_session_files_in_scope(&sessions_root, &agent, "sessions", base_dir, &mut out)?;
collect_session_files_in_scope(&archive_root, &agent, "archive", base_dir, &mut out)?;
}
out.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
Ok(out)
}
fn collect_session_files_in_scope(
scope_root: &Path,
agent: &str,
kind: &str,
base_dir: &Path,
out: &mut Vec<SessionFile>,
) -> Result<(), String> {
if !scope_root.exists() {
return Ok(());
}
let mut queue = VecDeque::new();
queue.push_back(scope_root.to_path_buf());
while let Some(current) = queue.pop_front() {
let entries = match fs::read_dir(&current) {
Ok(entries) => entries,
Err(_) => continue,
};
for entry in entries.flatten() {
let entry_path = entry.path();
let metadata = match entry.metadata() {
Ok(meta) => meta,
Err(_) => continue,
};
if metadata.is_dir() {
queue.push_back(entry_path);
continue;
}
if metadata.is_file() {
let relative_path = entry_path
.strip_prefix(base_dir)
.unwrap_or(&entry_path)
.to_string_lossy()
.to_string();
out.push(SessionFile {
path: entry_path.to_string_lossy().to_string(),
relative_path,
agent: agent.to_string(),
kind: kind.to_string(),
size_bytes: metadata.len(),
});
}
}
}
Ok(())
}
fn resolve_child_path(root: &Path, target: &str) -> Result<PathBuf, String> {
if target.trim().is_empty() {
return Err("path is required".into());
}
let candidate = if Path::new(target).is_absolute() {
PathBuf::from(target)
} else {
root.join(target)
};
let root_abs = if root.exists() {
fs::canonicalize(root).map_err(|e| e.to_string())?
} else {
return Err("root directory not found".into());
};
let target_abs = fs::canonicalize(&candidate).map_err(|e| e.to_string())?;
if !target_abs.starts_with(&root_abs) {
return Err("path is outside managed directory".into());
}
Ok(target_abs)
}
fn count_files_recursive(root: &Path) -> usize {
if !root.exists() {
return 0;
}
let mut queue = VecDeque::new();
let mut total = 0usize;
queue.push_back(root.to_path_buf());
while let Some(current) = queue.pop_front() {
let entries = match fs::read_dir(&current) {
Ok(entries) => entries,
Err(_) => continue,
};
for entry in entries.flatten() {
if let Ok(metadata) = entry.metadata() {
let entry_path = entry.path();
if metadata.is_dir() {
queue.push_back(entry_path);
} else if metadata.is_file() {
total = total.saturating_add(1);
}
}
}
}
total
}
fn clear_agent_and_global_sessions(agents_root: &Path, agent_id: Option<&str>) -> Result<usize, String> {
if !agents_root.exists() {
return Ok(0);
}
let mut total = 0usize;
let mut targets = Vec::new();
match agent_id {
Some(agent) => targets.push(agents_root.join(agent)),
None => {
for entry in fs::read_dir(agents_root).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?;
if entry
.file_type()
.map_err(|e| e.to_string())?
.is_dir()
{
targets.push(entry.path());
}
}
}
}
for agent_path in targets {
let sessions = agent_path.join("sessions");
let archive = agent_path.join("sessions_archive");
total = total.saturating_add(clear_directory_contents(&sessions)?);
total = total.saturating_add(clear_directory_contents(&archive)?);
fs::create_dir_all(&sessions).map_err(|e| e.to_string())?;
fs::create_dir_all(&archive).map_err(|e| e.to_string())?;
}
Ok(total)
}
fn clear_directory_contents(target: &Path) -> Result<usize, String> {
if !target.exists() {
return Ok(0);
}
let mut total = 0usize;
let entries = fs::read_dir(target).map_err(|e| e.to_string())?;
for entry in entries {
let entry = entry.map_err(|e| e.to_string())?;
let path = entry.path();
let metadata = entry
.metadata()
.map_err(|e| e.to_string())?;
if metadata.is_dir() {
total = total.saturating_add(clear_directory_contents(&path)?);
fs::remove_dir_all(&path).map_err(|e| e.to_string())?;
continue;
}
if metadata.is_file() || metadata.is_symlink() {
fs::remove_file(&path).map_err(|e| e.to_string())?;
total = total.saturating_add(1);
}
}
Ok(total)
}
fn model_profiles_path(paths: &crate::models::OpenClawPaths) -> std::path::PathBuf {
paths.clawpal_dir.join("model-profiles.json")
}
fn resolve_profile_model_value(
paths: &crate::models::OpenClawPaths,
profile_id: Option<String>,
) -> Result<Option<String>, String> {
let profile_id = profile_id.and_then(|value| {
let trimmed = value.trim().to_string();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
});
if profile_id.is_none() {
return Ok(None);
}
let target = profile_id.expect("checked");
let profiles = load_model_profiles(paths);
for profile in profiles {
if profile.id == target {
return Ok(Some(profile_to_model_value(&profile)));
}
}
Err(format!("model profile not found: {target}"))
}
fn profile_to_model_value(profile: &ModelProfile) -> String {
if profile.model.contains('/') {
profile.model.clone()
} else {
format!("{}/{}", profile.provider, profile.model)
}
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ResolvedApiKey {
pub profile_id: String,
pub masked_key: String,
}
#[tauri::command]
pub fn resolve_api_keys() -> Result<Vec<ResolvedApiKey>, String> {
let paths = resolve_paths();
let profiles = load_model_profiles(&paths);
let mut out = Vec::new();
for profile in &profiles {
let key = resolve_profile_api_key(profile, &paths.base_dir);
let masked = mask_api_key(&key);
out.push(ResolvedApiKey {
profile_id: profile.id.clone(),
masked_key: masked,
});
}
Ok(out)
}
fn resolve_profile_api_key(profile: &ModelProfile, base_dir: &Path) -> String {
// 1. Direct api_key field (user entered key directly in ClawPal)
if let Some(ref key) = profile.api_key {
let trimmed = key.trim();
if !trimmed.is_empty() {
return trimmed.to_string();
}
}
// 2. Try auth_ref as env var name directly (e.g. "OPENAI_API_KEY")
let auth_ref = profile.auth_ref.trim();
if !auth_ref.is_empty() {
if let Ok(val) = std::env::var(auth_ref) {
if !val.trim().is_empty() {
return val;
}
}
}
// 3. Look up auth_ref in agent-level auth-profiles.json files
// Keys are stored at: {base_dir}/agents/{agent}/agent/auth-profiles.json
if !auth_ref.is_empty() {
if let Some(key) = resolve_key_from_agent_auth_profiles(base_dir, auth_ref) {
return key;
}
}
// 4. Try common env var naming conventions based on provider
let provider = profile.provider.trim().to_uppercase().replace('-', "_");
if !provider.is_empty() {
for suffix in ["_API_KEY", "_KEY", "_TOKEN"] {
let env_name = format!("{provider}{suffix}");
if let Ok(val) = std::env::var(&env_name) {
if !val.trim().is_empty() {
return val;
}
}
}
}
String::new()
}
/// Reads agent-level auth-profiles.json to find the actual API key/token.
/// Scans all agents and returns the first match.
fn resolve_key_from_agent_auth_profiles(base_dir: &Path, auth_ref: &str) -> Option<String> {
let agents_dir = base_dir.join("agents");
if !agents_dir.exists() {
return None;
}
let entries = fs::read_dir(&agents_dir).ok()?;
for entry in entries.flatten() {
let auth_file = entry.path().join("agent").join("auth-profiles.json");
if !auth_file.exists() {
continue;
}
let text = fs::read_to_string(&auth_file).ok()?;
let data: Value = serde_json::from_str(&text).ok()?;
if let Some(profiles) = data.get("profiles").and_then(Value::as_object) {
if let Some(auth_entry) = profiles.get(auth_ref) {
if let Some(key) = extract_token_from_auth_entry(auth_entry) {
return Some(key);
}
}
}
}
None
}
/// Extract the actual key/token from an agent auth-profiles entry.
/// Handles different auth types: token, api_key, oauth.
fn extract_token_from_auth_entry(entry: &Value) -> Option<String> {
// "token" type → "token" field (e.g. anthropic)
// "api_key" type → "key" field (e.g. kimi-coding)
// "oauth" type → "access" field (e.g. minimax-portal, openai-codex)
for field in ["token", "key", "apiKey", "api_key", "access"] {
if let Some(val) = entry.get(field).and_then(Value::as_str) {
let trimmed = val.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
}
None
}
fn mask_api_key(key: &str) -> String {
let key = key.trim();
if key.is_empty() {
return "not set".to_string();
}
if key.len() <= 8 {
return "***".to_string();
}
let prefix = &key[..4.min(key.len())];
let suffix = &key[key.len().saturating_sub(4)..];
format!("{prefix}...{suffix}")
}
fn load_model_profiles(paths: &crate::models::OpenClawPaths) -> Vec<ModelProfile> {
let path = model_profiles_path(paths);
let text = std::fs::read_to_string(&path).unwrap_or_else(|_| r#"{"profiles":[]}"#.to_string());
#[derive(serde::Deserialize)]
struct Storage {
#[serde(default)]
profiles: Vec<ModelProfile>,
}
let parsed = serde_json::from_str::<Storage>(&text).unwrap_or(Storage {
profiles: Vec::new(),
});
parsed.profiles
}
fn save_model_profiles(paths: &crate::models::OpenClawPaths, profiles: &[ModelProfile]) -> Result<(), String> {
let path = model_profiles_path(paths);
#[derive(serde::Serialize)]
struct Storage<'a> {
profiles: &'a [ModelProfile],
#[serde(rename = "version")]
version: u8,
}
let payload = Storage {
profiles,
version: 1,
};
let text = serde_json::to_string_pretty(&payload).map_err(|e| e.to_string())?;
crate::config_io::write_text(&path, &text)
}
fn write_config_with_snapshot(
paths: &crate::models::OpenClawPaths,
current_text: &str,
next: &Value,
source: &str,
) -> Result<(), String> {
let _ = add_snapshot(
&paths.history_dir,
&paths.metadata_path,
Some(source.to_string()),
source,
true,
current_text,
None,
)?;
write_json(&paths.config_path, next)
}
fn set_nested_value(root: &mut Value, path: &str, value: Option<Value>) -> Result<(), String> {
let path = path.trim().trim_matches('.');
if path.is_empty() {
return Err("invalid path".into());
}
let mut cur = root;
let mut parts = path.split('.').peekable();
while let Some(part) = parts.next() {
let is_last = parts.peek().is_none();
let obj = cur
.as_object_mut()
.ok_or_else(|| "path must point to object".to_string())?;
if is_last {
if let Some(v) = value {
obj.insert(part.to_string(), v);
} else {
obj.remove(part);
}
return Ok(());
}
let child = obj
.entry(part.to_string())
.or_insert_with(|| Value::Object(Default::default()));
if !child.is_object() {
*child = Value::Object(Default::default());
}
cur = child;
}
unreachable!("path should have at least one segment");
}
fn set_agent_model_value(
root: &mut Value,
agent_id: &str,
model: Option<String>,
) -> Result<(), String> {
if let Some(agents) = root.pointer_mut("/agents").and_then(Value::as_object_mut) {
if let Some(list) = agents.get_mut("list").and_then(Value::as_array_mut) {
for agent in list {
if agent.get("id").and_then(Value::as_str) == Some(agent_id) {
if let Some(v) = model {
if let Some(agent_obj) = agent.as_object_mut() {
agent_obj.insert("model".into(), Value::String(v));
}
} else if let Some(agent_obj) = agent.as_object_mut() {
agent_obj.remove("model");
}
return Ok(());
}
}
}
}
Err(format!("agent not found: {agent_id}"))
}
fn load_model_catalog(
paths: &crate::models::OpenClawPaths,
cfg: &Value,
) -> Result<Vec<ModelCatalogProvider>, String> {
let now = unix_timestamp_secs();
let cache_path = model_catalog_cache_path(paths);
let current_version = resolve_openclaw_version();
let ttl_seconds = 60 * 60 * 12;
if let Some(cached) = read_model_catalog_cache(&cache_path)
.filter(|cache| cache.cli_version == current_version)
{
if now.saturating_sub(cached.updated_at) < ttl_seconds && cached.error.is_none() {
return Ok(cached.providers);
}
if cached.error.is_none() {
if let Some(fresh) = extract_model_catalog_from_cli(paths) {
if !fresh.is_empty() {
return Ok(fresh);
}
}
if !cached.providers.is_empty() {
return Ok(cached.providers);
}
}
}
if let Some(catalog) = extract_model_catalog_from_cli(paths) {
if !catalog.is_empty() {
let cache = ModelCatalogProviderCache {
cli_version: current_version,
updated_at: now,
providers: catalog.clone(),
source: "openclaw models list --all --json".into(),
error: None,
};
let _ = save_model_catalog_cache(&cache_path, &cache);
return Ok(catalog);
}
}
let fallback = collect_model_catalog(cfg);
if let Some(cached) = read_model_catalog_cache(&cache_path) {
if !cached.providers.is_empty() {
let catalog = if fallback.is_empty() {
cached.providers
} else {
fallback
};
return Ok(catalog);
}
}
Ok(fallback)
}
fn extract_model_catalog_from_cli(
paths: &crate::models::OpenClawPaths,
) -> Option<Vec<ModelCatalogProvider>> {
let output = run_openclaw_raw(&["models", "list", "--all", "--json", "--no-color"]).ok()?;
if output.stdout.trim().is_empty() {
return None;
}
// The CLI may prefix JSON with plugin log lines — strip them.
let json_str = extract_json_from_output(&output.stdout)?;
let response: Value = serde_json::from_str(json_str).ok()?;
let models: Vec<Value> = response
.as_array()
.map(|values| values.to_vec())
.or_else(|| {
response
.get("models")
.and_then(Value::as_array)
.map(|values| values.to_vec())
})
.or_else(|| {
response
.get("items")
.and_then(Value::as_array)
.map(|values| values.to_vec())
})
.or_else(|| {
response
.get("data")
.and_then(Value::as_array)
.map(|values| values.to_vec())
})
.unwrap_or_default();
if models.is_empty() {
return None;
}
let mut providers: BTreeMap<String, ModelCatalogProvider> = BTreeMap::new();
for model in models {
let key = model
.get("key")
.and_then(Value::as_str)
.map(str::to_string)
.or_else(|| {
let provider = model.get("provider").and_then(Value::as_str)?;
let model_id = model.get("id").and_then(Value::as_str)?;
Some(format!("{provider}/{model_id}"))
})?;
let mut parts = key.splitn(2, '/');
let provider = parts.next()?.trim().to_lowercase();
let id = parts.next().unwrap_or("").trim().to_string();
if provider.is_empty() || id.is_empty() {
continue;
}
let name = model
.get("name")
.and_then(Value::as_str)
.or_else(|| model.get("model").and_then(Value::as_str))
.or_else(|| model.get("title").and_then(Value::as_str))
.map(str::to_string);
let base_url = model
.get("baseUrl")
.or_else(|| model.get("base_url"))
.or_else(|| model.get("apiBase"))
.or_else(|| model.get("api_base"))
.and_then(Value::as_str)
.map(str::to_string)
.or_else(|| {
response
.get("providers")
.and_then(Value::as_object)
.and_then(|providers| providers.get(&provider))
.and_then(Value::as_object)
.and_then(|provider_cfg| {
provider_cfg
.get("baseUrl")
.or_else(|| provider_cfg.get("base_url"))
.or_else(|| provider_cfg.get("apiBase"))
.or_else(|| provider_cfg.get("api_base"))
.and_then(Value::as_str)
})
.map(str::to_string)
});
let entry = providers.entry(provider.clone()).or_insert(ModelCatalogProvider {
provider: provider.clone(),
base_url,
models: Vec::new(),
});
if !entry.models.iter().any(|existing| existing.id == id) {
entry.models.push(ModelCatalogModel {
id: id.clone(),
name: name.clone(),
});
}
}
if providers.is_empty() {
return None;
}
let _ = cache_model_catalog(paths, providers.values().cloned().collect());
let mut out: Vec<ModelCatalogProvider> = providers.into_values().collect();
for provider in &mut out {
provider.models.sort_by(|a, b| a.id.cmp(&b.id));
}
out.sort_by(|a, b| a.provider.cmp(&b.provider));
Some(out)
}
fn cache_model_catalog(paths: &crate::models::OpenClawPaths, providers: Vec<ModelCatalogProvider>) -> Option<()> {
let cache_path = model_catalog_cache_path(paths);
let now = unix_timestamp_secs();
let cache = ModelCatalogProviderCache {
cli_version: resolve_openclaw_version(),
updated_at: now,
providers,
source: "openclaw models list --all --json".into(),
error: None,
};
let _ = save_model_catalog_cache(&cache_path, &cache);
Some(())
}
fn collect_model_catalog(cfg: &Value) -> Vec<ModelCatalogProvider> {
let mut providers: BTreeMap<String, ModelCatalogProvider> = BTreeMap::new();
if let Some(configured) = cfg.pointer("/models/providers").and_then(Value::as_object) {
for (provider_name, provider_cfg) in configured {
let provider_model_map = extract_catalog_models(provider_cfg).unwrap_or_default();
let base_url = provider_cfg
.get("baseUrl")
.or_else(|| provider_cfg.get("base_url"))
.and_then(Value::as_str)
.map(str::to_string);
providers.entry(provider_name.clone())
.and_modify(|entry| {
if entry.base_url.is_none() {
entry.base_url = base_url.clone();
}
for model in provider_model_map.iter() {
if !entry.models.iter().any(|item| item.id == model.id) {
entry.models.push(model.clone());
}
}
})
.or_insert(ModelCatalogProvider {
provider: provider_name.clone(),
base_url,
models: provider_model_map,
});
}
}
if let Some(auth_profiles) = cfg.pointer("/auth/profiles").and_then(Value::as_object) {
for profile in auth_profiles.values() {
if let Some(provider_name) = profile
.get("provider")
.or_else(|| profile.get("name"))
.and_then(Value::as_str)
{
providers.entry(provider_name.to_string())
.or_insert(ModelCatalogProvider {
provider: provider_name.to_string(),
base_url: None,
models: Vec::new(),
});
}
}
}
providers.into_values().collect()
}
fn extract_catalog_models(provider_cfg: &Value) -> Option<Vec<ModelCatalogModel>> {
let mut model_items: BTreeMap<String, String> = BTreeMap::new();
let raw_models = provider_cfg.get("models")?.as_array()?;
for model in raw_models {
let id = model.as_str().map(str::to_string).or_else(|| {
model
.get("id")
.and_then(Value::as_str)
.map(str::to_string)
});
if let Some(id) = id {
let name = model
.get("name")
.and_then(Value::as_str)
.map(str::to_string);
model_items.entry(id).or_insert(name.unwrap_or_default());
}
}
if model_items.is_empty() {
return None;
}
let models = model_items
.into_iter()
.map(|(id, name)| ModelCatalogModel {
id,
name: (!name.is_empty()).then_some(name),
})
.collect();
Some(models)
}
fn collect_channel_nodes(cfg: &Value) -> Vec<ChannelNode> {
let mut out = Vec::new();
if let Some(channels) = cfg.get("channels") {
walk_channel_nodes("channels", channels, &mut out);
}
out.sort_by(|a, b| a.path.cmp(&b.path));
out
}
fn walk_channel_nodes(prefix: &str, node: &Value, out: &mut Vec<ChannelNode>) {
let Some(obj) = node.as_object() else {
return;
};
if is_channel_like_node(prefix, obj) {
let channel_type = resolve_channel_type(prefix, obj);
let mode = resolve_channel_mode(obj);
let allowlist = collect_channel_allowlist(obj);
let has_model_field = obj.contains_key("model");
let model = obj.get("model").and_then(read_model_value);
out.push(ChannelNode {
path: prefix.to_string(),
channel_type,
mode,
allowlist,
model,
has_model_field,
display_name: None,
name_status: None,
});
}
for (key, child) in obj {
if key == "allowlist" || key == "model" || key == "mode" {
continue;
}
if let Value::Object(_) = child {
walk_channel_nodes(&format!("{prefix}.{key}"), child, out);
}
}
}
fn enrich_channel_display_names(
paths: &crate::models::OpenClawPaths,
cfg: &Value,
nodes: &mut [ChannelNode],
) -> Result<(), String> {
let mut grouped: BTreeMap<String, Vec<(usize, String, String)>> = BTreeMap::new();
let mut local_names: Vec<(usize, String)> = Vec::new();
for (index, node) in nodes.iter().enumerate() {
if let Some((plugin, identifier, kind)) = resolve_channel_node_identity(cfg, node) {
grouped
.entry(plugin)
.or_default()
.push((index, identifier, kind));
}
if node.display_name.is_none() {
if let Some(local_name) = channel_node_local_name(cfg, &node.path) {
local_names.push((index, local_name));
}
}
}
for (index, local_name) in local_names {
if let Some(node) = nodes.get_mut(index) {
node.display_name = Some(local_name);
node.name_status = Some("local".into());
}
}
let cache_file = paths.base_dir.join(".clawpal-channel-name-cache.json");
if nodes.is_empty() {
if cache_file.exists() {
let _ = fs::remove_file(&cache_file);
}
return Ok(());
}
for (plugin, entries) in grouped {
if entries.is_empty() {
continue;
}
let ids: Vec<String> = entries.iter().map(|(_, identifier, _)| identifier.clone()).collect();
let kind = &entries[0].2;
let mut args = vec![
"channels".to_string(),
"resolve".to_string(),
"--json".to_string(),
"--channel".to_string(),
plugin.clone(),
"--kind".to_string(),
kind.clone(),
];
for entry in &ids {
args.push(entry.clone());
}
let args: Vec<&str> = args.iter().map(String::as_str).collect();
let output = match run_openclaw_raw(&args) {
Ok(output) => output,
Err(_) => {
for (index, _, _) in entries {
nodes[index].name_status = Some("resolve failed".into());
}
continue;
}
};
if output.stdout.trim().is_empty() {
for (index, _, _) in entries {
nodes[index].name_status = Some("unresolved".into());
}
continue;
}
let json_str = extract_json_from_output(&output.stdout).unwrap_or("[]");
let parsed: Vec<Value> = serde_json::from_str(json_str).unwrap_or_default();
let mut name_map = HashMap::new();
for item in parsed {
let input = item.get("input").and_then(Value::as_str).unwrap_or_default().to_string();
let resolved = item.get("resolved").and_then(Value::as_bool).unwrap_or(false);
let name = item
.get("name")
.and_then(Value::as_str)
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let note = item.get("note").and_then(Value::as_str).map(|value| value.to_string());
if !input.is_empty() {
name_map.insert(input, (resolved, name, note));
}
}
for (index, identifier, _) in entries {
if let Some((resolved, name, note)) = name_map.get(&identifier) {
if *resolved {
if let Some(name) = name {
nodes[index].display_name = Some(name.clone());
nodes[index].name_status = Some("resolved".into());
} else {
nodes[index].name_status = Some("resolved".into());
}
} else if let Some(note) = note {
nodes[index].name_status = Some(note.clone());
} else {
nodes[index].name_status = Some("unresolved".into());
}
} else {
nodes[index].name_status = Some("unresolved".into());
}
}
}
let _ = save_json_cache(&cache_file, nodes);
Ok(())
}
#[derive(Serialize, Deserialize)]
struct ChannelNameCacheEntry {
path: String,
display_name: Option<String>,
name_status: Option<String>,
}
fn save_json_cache(cache_file: &Path, nodes: &[ChannelNode]) -> Result<(), String> {
let payload: Vec<ChannelNameCacheEntry> = nodes
.iter()
.map(|node| ChannelNameCacheEntry {
path: node.path.clone(),
display_name: node.display_name.clone(),
name_status: node.name_status.clone(),
})
.collect();
write_text(cache_file, &serde_json::to_string_pretty(&payload).map_err(|e| e.to_string())?)
}
fn resolve_channel_node_identity(cfg: &Value, node: &ChannelNode) -> Option<(String, String, String)> {
let parts: Vec<&str> = node.path.split('.').collect();
if parts.len() < 2 || parts[0] != "channels" {
return None;
}
let plugin = parts[1].to_string();
let identifier = channel_last_segment(node.path.as_str())?;
let config_node = channel_lookup_node(cfg, &node.path);
let kind = if node.channel_type.as_deref() == Some("dm") || node.path.ends_with(".dm") {
"user".to_string()
} else if config_node
.and_then(|value| value.get("users").or(value.get("members")).or_else(|| value.get("peerIds")))
.is_some()
{
"user".to_string()
} else {
"group".to_string()
};
Some((plugin, identifier, kind))
}
fn channel_last_segment(path: &str) -> Option<String> {
path.split('.').next_back().map(|value| value.to_string())
}
fn channel_node_local_name(cfg: &Value, path: &str) -> Option<String> {
channel_lookup_node(cfg, path).and_then(|node| {
if let Some(slug) = node.get("slug").and_then(Value::as_str) {
let trimmed = slug.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
if let Some(name) = node.get("name").and_then(Value::as_str) {
let trimmed = name.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
None
})
}
fn channel_lookup_node<'a>(cfg: &'a Value, path: &str) -> Option<&'a Value> {
let mut current = cfg;
for part in path.split('.') {
current = current.get(part)?;
}
Some(current)
}
fn is_channel_like_node(prefix: &str, obj: &serde_json::Map<String, Value>) -> bool {
if prefix == "channels" {
return false;
}
if obj.contains_key("model")
|| obj.contains_key("type")
|| obj.contains_key("mode")
|| obj.contains_key("policy")
|| obj.contains_key("allowlist")
|| obj.contains_key("allowFrom")
|| obj.contains_key("groupAllowFrom")
|| obj.contains_key("dmPolicy")
|| obj.contains_key("groupPolicy")
|| obj.contains_key("guilds")
|| obj.contains_key("accounts")
|| obj.contains_key("dm")
|| obj.contains_key("users")
|| obj.contains_key("enabled")
|| obj.contains_key("token")
|| obj.contains_key("botToken")
{
return true;
}
if prefix.contains(".accounts.") || prefix.contains(".guilds.") || prefix.contains(".channels.") {
return true;
}
if prefix.ends_with(".dm") || prefix.ends_with(".default") {
return true;
}
false
}
fn resolve_channel_type(prefix: &str, obj: &serde_json::Map<String, Value>) -> Option<String> {
obj.get("type")
.and_then(Value::as_str)
.map(str::to_string)
.or_else(|| {
if prefix.ends_with(".dm") {
Some("dm".into())
} else if prefix.contains(".accounts.") {
Some("account".into())
} else if prefix.contains(".channels.") && prefix.contains(".guilds.") {
Some("channel".into())
} else if prefix.contains(".guilds.") {
Some("guild".into())
} else if obj.contains_key("guilds") {
Some("platform".into())
} else if obj.contains_key("accounts") {
Some("platform".into())
} else {
None
}
})
}
fn resolve_channel_mode(obj: &serde_json::Map<String, Value>) -> Option<String> {
let mut modes: Vec<String> = Vec::new();
if let Some(v) = obj.get("mode").and_then(Value::as_str) {
modes.push(v.to_string());
}
if let Some(v) = obj.get("policy").and_then(Value::as_str) {
if !modes.iter().any(|m| m == v) {
modes.push(v.to_string());
}
}
if let Some(v) = obj.get("dmPolicy").and_then(Value::as_str) {
if !modes.iter().any(|m| m == v) {
modes.push(v.to_string());
}
}
if let Some(v) = obj.get("groupPolicy").and_then(Value::as_str) {
if !modes.iter().any(|m| m == v) {
modes.push(v.to_string());
}
}
if modes.is_empty() {
None
} else {
Some(modes.join(" / "))
}
}
fn collect_channel_allowlist(obj: &serde_json::Map<String, Value>) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut uniq = HashSet::<String>::new();
for key in ["allowlist", "allowFrom", "groupAllowFrom"] {
if let Some(values) = obj.get(key).and_then(Value::as_array) {
for value in values.iter().filter_map(Value::as_str) {
let next = value.to_string();
if uniq.insert(next.clone()) {
out.push(next);
}
}
}
}
if let Some(values) = obj.get("users").and_then(Value::as_array) {
for value in values.iter().filter_map(Value::as_str) {
let next = value.to_string();
if uniq.insert(next.clone()) {
out.push(next);
}
}
}
out
}
fn collect_agent_ids(cfg: &Value) -> Vec<String> {
let mut ids = Vec::new();
if let Some(agents) = cfg.get("agents").and_then(|v| v.get("list")).and_then(Value::as_array) {
for agent in agents {
if let Some(id) = agent.get("id").and_then(Value::as_str) {
ids.push(id.to_string());
}
}
}
ids
}
fn collect_model_bindings(cfg: &Value, profiles: &[ModelProfile]) -> Vec<ModelBinding> {
let mut out = Vec::new();
let global = cfg
.pointer("/agents/defaults/model")
.or_else(|| cfg.pointer("/agents/default/model"))
.and_then(read_model_value);
out.push(ModelBinding {
scope: "global".into(),
scope_id: "global".into(),
model_profile_id: find_profile_by_model(profiles, global.as_deref()),
model_value: global,
path: Some("agents.defaults.model".into()),
});
if let Some(agents) = cfg.get("agents").and_then(|v| v.get("list")).and_then(Value::as_array) {
for agent in agents {
let id = agent.get("id").and_then(Value::as_str).unwrap_or("agent");
let model = agent.get("model").and_then(read_model_value);
out.push(ModelBinding {
scope: "agent".into(),
scope_id: id.to_string(),
model_profile_id: find_profile_by_model(profiles, model.as_deref()),
model_value: model,
path: Some(format!("agents.list.{id}.model")),
});
}
}
fn walk_channel_binding(prefix: &str, node: &Value, out: &mut Vec<ModelBinding>, profiles: &[ModelProfile]) {
if let Some(obj) = node.as_object() {
if let Some(model) = obj.get("model").and_then(read_model_value) {
out.push(ModelBinding {
scope: "channel".into(),
scope_id: prefix.to_string(),
model_profile_id: find_profile_by_model(profiles, Some(&model)),
model_value: Some(model),
path: Some(format!("{}.model", prefix)),
});
}
for (k, child) in obj {
if let Value::Object(_) = child {
walk_channel_binding(&format!("{}.{}", prefix, k), child, out, profiles);
}
}
}
}
if let Some(channels) = cfg.get("channels") {
walk_channel_binding("channels", channels, &mut out, profiles);
}
out
}
fn find_profile_by_model(profiles: &[ModelProfile], value: Option<&str>) -> Option<String> {
let value = value?;
let normalized = normalize_model_ref(value);
for profile in profiles {
if normalize_model_ref(&profile_to_model_value(profile)) == normalized
|| normalize_model_ref(&profile.model) == normalized
{
return Some(profile.id.clone());
}
}
None
}
fn resolve_auth_ref_for_provider(cfg: &Value, provider: &str) -> Option<String> {
let provider = provider.trim().to_lowercase();
if provider.is_empty() {
return None;
}
if let Some(auth_profiles) = cfg.pointer("/auth/profiles").and_then(Value::as_object) {
let mut fallback = None;
for (profile_id, profile) in auth_profiles {
let entry_provider = profile.get("provider").or_else(|| profile.get("name"));
if let Some(entry_provider) = entry_provider.and_then(Value::as_str) {
if entry_provider.trim().eq_ignore_ascii_case(&provider) {
if profile_id.ends_with(":default") {
return Some(profile_id.clone());
}
if fallback.is_none() {
fallback = Some(profile_id.clone());
}
}
}
}
if fallback.is_some() {
return fallback;
}
}
None
}
#[tauri::command]
pub fn read_raw_config() -> Result<String, String> {
let paths = resolve_paths();
let cfg = read_openclaw_config(&paths)?;
serde_json::to_string_pretty(&cfg).map_err(|e| e.to_string())
}
// ---- Config baseline for dirty tracking ----
fn baseline_path(paths: &crate::models::OpenClawPaths) -> std::path::PathBuf {
paths.clawpal_dir.join("config-baseline.json")
}
#[tauri::command]
pub fn save_config_baseline() -> Result<bool, String> {
let paths = resolve_paths();
let cfg = read_openclaw_config(&paths)?;
let text = serde_json::to_string_pretty(&cfg).map_err(|e| e.to_string())?;
let bp = baseline_path(&paths);
fs::create_dir_all(bp.parent().unwrap()).map_err(|e| e.to_string())?;
fs::write(&bp, text).map_err(|e| e.to_string())?;
Ok(true)
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConfigDirtyState {
pub dirty: bool,
pub baseline: String,
pub current: String,
}
#[tauri::command]
pub fn check_config_dirty() -> Result<ConfigDirtyState, String> {
let paths = resolve_paths();
let cfg = read_openclaw_config(&paths)?;
let current = serde_json::to_string_pretty(&cfg).map_err(|e| e.to_string())?;
let bp = baseline_path(&paths);
let baseline = if bp.exists() {
fs::read_to_string(&bp).map_err(|e| e.to_string())?
} else {
// No baseline yet — treat current as clean, save it
fs::create_dir_all(bp.parent().unwrap()).map_err(|e| e.to_string())?;
fs::write(&bp, &current).map_err(|e| e.to_string())?;
current.clone()
};
let dirty = baseline.trim() != current.trim();
Ok(ConfigDirtyState { dirty, baseline, current })
}
#[tauri::command]
pub fn discard_config_changes() -> Result<bool, String> {
let paths = resolve_paths();
let bp = baseline_path(&paths);
if !bp.exists() {
return Err("No baseline config found".into());
}
let baseline_text = fs::read_to_string(&bp).map_err(|e| e.to_string())?;
let baseline: Value = serde_json::from_str(&baseline_text).map_err(|e| e.to_string())?;
// Save current as snapshot before discarding
let cfg = read_openclaw_config(&paths)?;
let current_text = serde_json::to_string_pretty(&cfg).map_err(|e| e.to_string())?;
let _ = add_snapshot(
&paths.history_dir,
&paths.metadata_path,
None,
"discard-changes",
true,
&current_text,
None,
);
write_json(&paths.config_path, &baseline)?;
Ok(true)
}
#[tauri::command]
pub async fn apply_pending_changes() -> Result<bool, String> {
tauri::async_runtime::spawn_blocking(move || {
let paths = resolve_paths();
// Save current config as new baseline
let cfg = read_openclaw_config(&paths)?;
let text = serde_json::to_string_pretty(&cfg).map_err(|e| e.to_string())?;
let bp = baseline_path(&paths);
fs::create_dir_all(bp.parent().unwrap()).map_err(|e| e.to_string())?;
fs::write(&bp, &text).map_err(|e| e.to_string())?;
// Restart gateway (30s timeout to prevent indefinite hang)
run_openclaw_raw_timeout(&["gateway", "restart"], Some(30))?;
Ok(true)
}).await.map_err(|e| e.to_string())?
}
#[tauri::command]
pub fn resolve_full_api_key(profile_id: String) -> Result<String, String> {
let paths = resolve_paths();
let profiles = load_model_profiles(&paths);
let profile = profiles.iter().find(|p| p.id == profile_id)
.ok_or_else(|| "Profile not found".to_string())?;
let key = resolve_profile_api_key(profile, &paths.base_dir);
if key.is_empty() {
return Err("No API key configured for this profile".to_string());
}
Ok(key)
}
#[tauri::command]
pub fn open_url(url: String) -> Result<(), String> {
if url.trim().is_empty() {
return Err("URL is required".into());
}
#[cfg(target_os = "macos")]
{
Command::new("open").arg(&url).spawn().map_err(|e| e.to_string())?;
}
#[cfg(target_os = "linux")]
{
Command::new("xdg-open").arg(&url).spawn().map_err(|e| e.to_string())?;
}
#[cfg(target_os = "windows")]
{
Command::new("cmd").args(["/c", "start", &url]).spawn().map_err(|e| e.to_string())?;
}
Ok(())
}
#[tauri::command]
pub async fn chat_via_openclaw(agent_id: String, message: String, session_id: Option<String>) -> Result<Value, String> {
tauri::async_runtime::spawn_blocking(move || {
let mut args = vec![
"agent".to_string(),
"--local".to_string(),
"--agent".to_string(),
agent_id,
"--message".to_string(),
message,
"--json".to_string(),
"--no-color".to_string(),
];
if let Some(sid) = session_id {
args.push("--session-id".to_string());
args.push(sid);
}
let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
let output = run_openclaw_raw(&arg_refs)?;
let json_str = extract_json_from_output(&output.stdout)
.ok_or_else(|| format!("No JSON in openclaw output: {}", output.stdout))?;
serde_json::from_str(json_str)
.map_err(|e| format!("Parse openclaw response failed: {}", e))
})
.await
.map_err(|e| format!("Task join failed: {}", e))?
}
// ---- Backup / Restore ----
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BackupInfo {
pub name: String,
pub path: String,
pub created_at: String,
pub size_bytes: u64,
}
#[tauri::command]
pub fn backup_before_upgrade() -> Result<BackupInfo, String> {
let paths = resolve_paths();
let backups_dir = paths.clawpal_dir.join("backups");
fs::create_dir_all(&backups_dir).map_err(|e| format!("Failed to create backups dir: {e}"))?;
let now_secs = unix_timestamp_secs();
let now_dt = chrono::DateTime::<chrono::Utc>::from_timestamp(now_secs as i64, 0);
let name = now_dt
.map(|dt| dt.format("%Y-%m-%d_%H%M%S").to_string())
.unwrap_or_else(|| format!("{now_secs}"));
let backup_dir = backups_dir.join(&name);
fs::create_dir_all(&backup_dir).map_err(|e| format!("Failed to create backup dir: {e}"))?;
let mut total_bytes = 0u64;
// Copy config file
if paths.config_path.exists() {
let dest = backup_dir.join("openclaw.json");
fs::copy(&paths.config_path, &dest).map_err(|e| format!("Failed to copy config: {e}"))?;
total_bytes += fs::metadata(&dest).map(|m| m.len()).unwrap_or(0);
}
// Copy directories, excluding sessions and archive
let skip_dirs: HashSet<&str> = ["sessions", "archive", ".clawpal"].iter().copied().collect();
copy_dir_recursive(&paths.base_dir, &backup_dir, &skip_dirs, &mut total_bytes)?;
Ok(BackupInfo {
name: name.clone(),
path: backup_dir.to_string_lossy().to_string(),
created_at: format_timestamp_from_unix(now_secs),
size_bytes: total_bytes,
})
}
fn copy_dir_recursive(src: &Path, dst: &Path, skip_dirs: &HashSet<&str>, total: &mut u64) -> Result<(), String> {
let entries = fs::read_dir(src).map_err(|e| format!("Failed to read dir {}: {e}", src.display()))?;
for entry in entries {
let entry = entry.map_err(|e| e.to_string())?;
let name = entry.file_name();
let name_str = name.to_string_lossy();
// Skip the config file (already copied separately) and skip dirs
if name_str == "openclaw.json" {
continue;
}
let file_type = entry.file_type().map_err(|e| e.to_string())?;
let dest = dst.join(&name);
if file_type.is_dir() {
if skip_dirs.contains(name_str.as_ref()) {
continue;
}
fs::create_dir_all(&dest).map_err(|e| format!("Failed to create dir {}: {e}", dest.display()))?;
copy_dir_recursive(&entry.path(), &dest, skip_dirs, total)?;
} else if file_type.is_file() {
fs::copy(entry.path(), &dest).map_err(|e| format!("Failed to copy {}: {e}", name_str))?;
*total += fs::metadata(&dest).map(|m| m.len()).unwrap_or(0);
}
}
Ok(())
}
#[tauri::command]
pub fn list_backups() -> Result<Vec<BackupInfo>, String> {
let paths = resolve_paths();
let backups_dir = paths.clawpal_dir.join("backups");
if !backups_dir.exists() {
return Ok(Vec::new());
}
let mut backups = Vec::new();
let entries = fs::read_dir(&backups_dir).map_err(|e| e.to_string())?;
for entry in entries {
let entry = entry.map_err(|e| e.to_string())?;
if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
continue;
}
let name = entry.file_name().to_string_lossy().to_string();
let path = entry.path();
let size = dir_size(&path);
let created_at = fs::metadata(&path)
.and_then(|m| m.created())
.map(|t| {
let secs = t.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
format_timestamp_from_unix(secs)
})
.unwrap_or_else(|_| name.clone());
backups.push(BackupInfo {
name,
path: path.to_string_lossy().to_string(),
created_at,
size_bytes: size,
});
}
backups.sort_by(|a, b| b.name.cmp(&a.name));
Ok(backups)
}
fn dir_size(path: &Path) -> u64 {
let mut total = 0u64;
if let Ok(entries) = fs::read_dir(path) {
for entry in entries.flatten() {
if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
total += dir_size(&entry.path());
} else {
total += fs::metadata(entry.path()).map(|m| m.len()).unwrap_or(0);
}
}
}
total
}
#[tauri::command]
pub fn restore_from_backup(backup_name: String) -> Result<String, String> {
let paths = resolve_paths();
let backup_dir = paths.clawpal_dir.join("backups").join(&backup_name);
if !backup_dir.exists() {
return Err(format!("Backup '{}' not found", backup_name));
}
// Restore config file
let backup_config = backup_dir.join("openclaw.json");
if backup_config.exists() {
fs::copy(&backup_config, &paths.config_path)
.map_err(|e| format!("Failed to restore config: {e}"))?;
}
// Restore other directories (agents except sessions/archive, memory, etc.)
let skip_dirs: HashSet<&str> = ["sessions", "archive", ".clawpal"].iter().copied().collect();
restore_dir_recursive(&backup_dir, &paths.base_dir, &skip_dirs)?;
Ok(format!("Restored from backup '{}'", backup_name))
}
fn restore_dir_recursive(src: &Path, dst: &Path, skip_dirs: &HashSet<&str>) -> Result<(), String> {
let entries = fs::read_dir(src).map_err(|e| format!("Failed to read backup dir: {e}"))?;
for entry in entries {
let entry = entry.map_err(|e| e.to_string())?;
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str == "openclaw.json" {
continue; // Already restored separately
}
let file_type = entry.file_type().map_err(|e| e.to_string())?;
let dest = dst.join(&name);
if file_type.is_dir() {
if skip_dirs.contains(name_str.as_ref()) {
continue;
}
fs::create_dir_all(&dest).map_err(|e| e.to_string())?;
restore_dir_recursive(&entry.path(), &dest, skip_dirs)?;
} else if file_type.is_file() {
fs::copy(entry.path(), &dest).map_err(|e| format!("Failed to restore {}: {e}", name_str))?;
}
}
Ok(())
}
#[tauri::command]
pub fn delete_backup(backup_name: String) -> Result<bool, String> {
let paths = resolve_paths();
let backup_dir = paths.clawpal_dir.join("backups").join(&backup_name);
if !backup_dir.exists() {
return Ok(false);
}
fs::remove_dir_all(&backup_dir).map_err(|e| format!("Failed to delete backup: {e}"))?;
Ok(true)
}
fn resolve_model_provider_base_url(cfg: &Value, provider: &str) -> Option<String> {
let provider = provider.trim();
if provider.is_empty() {
return None;
}
cfg.pointer("/models/providers")
.and_then(Value::as_object)
.and_then(|providers| providers.get(provider))
.and_then(Value::as_object)
.and_then(|provider_cfg| {
provider_cfg
.get("baseUrl")
.or_else(|| provider_cfg.get("base_url"))
.and_then(Value::as_str)
.map(str::to_string)
.or_else(|| {
provider_cfg
.get("apiBase")
.or_else(|| provider_cfg.get("api_base"))
.and_then(Value::as_str)
.map(str::to_string)
})
})
}
// ---------------------------------------------------------------------------
// Task 3: Remote instance config CRUD
// ---------------------------------------------------------------------------
fn remote_instances_path() -> PathBuf {
resolve_paths().clawpal_dir.join("remote-instances.json")
}
fn read_hosts_from_disk() -> Result<Vec<SshHostConfig>, String> {
let path = remote_instances_path();
if !path.exists() {
return Ok(Vec::new());
}
let data = fs::read_to_string(&path).map_err(|e| format!("Failed to read remote-instances.json: {e}"))?;
serde_json::from_str(&data).map_err(|e| format!("Failed to parse remote-instances.json: {e}"))
}
fn write_hosts_to_disk(hosts: &[SshHostConfig]) -> Result<(), String> {
let path = remote_instances_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| format!("Failed to create dir: {e}"))?;
}
let json = serde_json::to_string_pretty(hosts).map_err(|e| format!("Failed to serialize hosts: {e}"))?;
fs::write(&path, json).map_err(|e| format!("Failed to write remote-instances.json: {e}"))
}
#[tauri::command]
pub fn list_ssh_hosts() -> Result<Vec<SshHostConfig>, String> {
read_hosts_from_disk()
}
#[tauri::command]
pub fn upsert_ssh_host(host: SshHostConfig) -> Result<SshHostConfig, String> {
let mut hosts = read_hosts_from_disk()?;
if let Some(existing) = hosts.iter_mut().find(|h| h.id == host.id) {
*existing = host.clone();
} else {
hosts.push(host.clone());
}
write_hosts_to_disk(&hosts)?;
Ok(host)
}
#[tauri::command]
pub fn delete_ssh_host(host_id: String) -> Result<bool, String> {
let mut hosts = read_hosts_from_disk()?;
let before = hosts.len();
hosts.retain(|h| h.id != host_id);
let removed = hosts.len() < before;
write_hosts_to_disk(&hosts)?;
Ok(removed)
}
// ---------------------------------------------------------------------------
// Task 4: SSH connect / disconnect / status
// ---------------------------------------------------------------------------
#[tauri::command]
pub async fn ssh_connect(pool: State<'_, SshConnectionPool>, host_id: String) -> Result<bool, String> {
let hosts = read_hosts_from_disk()?;
let host = hosts.into_iter().find(|h| h.id == host_id)
.ok_or_else(|| format!("No SSH host config with id: {host_id}"))?;
pool.connect(&host).await?;
Ok(true)
}
#[tauri::command]
pub async fn ssh_disconnect(pool: State<'_, SshConnectionPool>, host_id: String) -> Result<bool, String> {
pool.disconnect(&host_id).await?;
Ok(true)
}
#[tauri::command]
pub async fn ssh_status(pool: State<'_, SshConnectionPool>, host_id: String) -> Result<String, String> {
if pool.is_connected(&host_id).await {
Ok("connected".to_string())
} else {
Ok("disconnected".to_string())
}
}
// ---------------------------------------------------------------------------
// Task 5: SSH exec and SFTP Tauri commands
// ---------------------------------------------------------------------------
#[tauri::command]
pub async fn ssh_exec(pool: State<'_, SshConnectionPool>, host_id: String, command: String) -> Result<SshExecResult, String> {
pool.exec(&host_id, &command).await
}
#[tauri::command]
pub async fn sftp_read_file(pool: State<'_, SshConnectionPool>, host_id: String, path: String) -> Result<String, String> {
pool.sftp_read(&host_id, &path).await
}
#[tauri::command]
pub async fn sftp_write_file(pool: State<'_, SshConnectionPool>, host_id: String, path: String, content: String) -> Result<bool, String> {
pool.sftp_write(&host_id, &path, &content).await?;
Ok(true)
}
#[tauri::command]
pub async fn sftp_list_dir(pool: State<'_, SshConnectionPool>, host_id: String, path: String) -> Result<Vec<SftpEntry>, String> {
pool.sftp_list(&host_id, &path).await
}
#[tauri::command]
pub async fn sftp_remove_file(pool: State<'_, SshConnectionPool>, host_id: String, path: String) -> Result<bool, String> {
pool.sftp_remove(&host_id, &path).await?;
Ok(true)
}
// ---------------------------------------------------------------------------
// Task 6: Remote business commands
// ---------------------------------------------------------------------------
#[tauri::command]
pub async fn remote_read_raw_config(pool: State<'_, SshConnectionPool>, host_id: String) -> Result<Value, String> {
let raw = pool.sftp_read(&host_id, "~/.openclaw/openclaw.json").await?;
serde_json::from_str(&raw).map_err(|e| format!("Failed to parse remote config: {e}"))
}
#[tauri::command]
pub async fn remote_get_system_status(pool: State<'_, SshConnectionPool>, host_id: String) -> Result<Value, String> {
// 1. Get openclaw version
let version_result = pool.exec(&host_id, "openclaw --version").await?;
let openclaw_version = version_result.stdout.trim().to_string();
// 2. Read remote config
let config_raw = pool.sftp_read(&host_id, "~/.openclaw/openclaw.json").await;
let (active_agents, global_default_model, gateway_port) = match &config_raw {
Ok(raw) => {
let cfg: Value = serde_json::from_str(raw).unwrap_or(Value::Null);
let agents = cfg.pointer("/agents")
.and_then(Value::as_object)
.map(|a| a.len() as u32)
.unwrap_or(0);
let model = cfg.pointer("/models/default")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let port = cfg.pointer("/gateway/port")
.and_then(Value::as_u64)
.unwrap_or(5337);
(agents, model, port)
}
Err(_) => (0, String::new(), 5337),
};
// 3. Check gateway health
let health_cmd = format!("curl -sf http://localhost:{gateway_port}/health");
let health_result = pool.exec(&host_id, &health_cmd).await;
let healthy = match health_result {
Ok(r) => r.exit_code == 0,
Err(_) => false,
};
let status = serde_json::json!({
"healthy": healthy,
"openclawVersion": openclaw_version,
"activeAgents": active_agents,
"globalDefaultModel": global_default_model,
"configPath": "~/.openclaw/openclaw.json",
"openclawDir": "~/.openclaw",
});
Ok(status)
}