diff --git a/Cargo.lock b/Cargo.lock index 2e4d2f2..e7c640b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1305,6 +1305,7 @@ dependencies = [ "secrecy", "serde", "serde_json", + "serde_yaml", "sha2", "sqlx", "tauri", diff --git a/crates/zclaw-hands/src/hands/browser.rs b/crates/zclaw-hands/src/hands/browser.rs index a9a59cf..99244bc 100644 --- a/crates/zclaw-hands/src/hands/browser.rs +++ b/crates/zclaw-hands/src/hands/browser.rs @@ -157,11 +157,22 @@ impl BrowserHand { } } - /// Check if WebDriver is available + /// Check if WebDriver is available by probing common ports fn check_webdriver(&self) -> bool { - // Check if ChromeDriver or GeckoDriver is running - // For now, return true as the actual check would require network access - true + use std::net::TcpStream; + use std::time::Duration; + + // Probe default WebDriver ports: ChromeDriver (9515), GeckoDriver (4444), Edge (17556) + let ports = [9515, 4444, 17556]; + for port in ports { + let addr = format!("127.0.0.1:{}", port); + if let Ok(addr) = addr.parse() { + if TcpStream::connect_timeout(&addr, Duration::from_millis(500)).is_ok() { + return true; + } + } + } + false } } diff --git a/crates/zclaw-kernel/src/kernel.rs b/crates/zclaw-kernel/src/kernel.rs index ad6768f..513491d 100644 --- a/crates/zclaw-kernel/src/kernel.rs +++ b/crates/zclaw-kernel/src/kernel.rs @@ -480,6 +480,35 @@ impl Kernel { Ok(()) } + /// Update an existing agent's configuration + pub async fn update_agent(&self, config: AgentConfig) -> Result<()> { + let id = config.id; + + // Validate the agent exists + if self.registry.get(&id).is_none() { + return Err(zclaw_types::ZclawError::NotFound( + format!("Agent not found: {}", id) + )); + } + + // Validate capabilities + self.capabilities.validate(&config.capabilities)?; + + // Save updated config to memory + self.memory.save_agent(&config).await?; + + // Update in registry (preserves state and message count) + self.registry.update(config.clone()); + + // Emit event + self.events.publish(Event::AgentConfigUpdated { + agent_id: id, + name: config.name.clone(), + }); + + Ok(()) + } + /// List all agents pub fn list_agents(&self) -> Vec { self.registry.list() @@ -710,6 +739,42 @@ impl Kernel { Ok(()) } + /// Get the configured skills directory + pub fn skills_dir(&self) -> Option<&std::path::PathBuf> { + self.config.skills_dir.as_ref() + } + + /// Create a new skill in the skills directory + pub async fn create_skill(&self, manifest: zclaw_skills::SkillManifest) -> Result<()> { + let skills_dir = self.config.skills_dir.as_ref() + .ok_or_else(|| zclaw_types::ZclawError::InvalidInput( + "Skills directory not configured".into() + ))?; + self.skills.create_skill(skills_dir, manifest).await + } + + /// Update an existing skill + pub async fn update_skill( + &self, + id: &zclaw_types::SkillId, + manifest: zclaw_skills::SkillManifest, + ) -> Result { + let skills_dir = self.config.skills_dir.as_ref() + .ok_or_else(|| zclaw_types::ZclawError::InvalidInput( + "Skills directory not configured".into() + ))?; + self.skills.update_skill(skills_dir, id, manifest).await + } + + /// Delete a skill + pub async fn delete_skill(&self, id: &zclaw_types::SkillId) -> Result<()> { + let skills_dir = self.config.skills_dir.as_ref() + .ok_or_else(|| zclaw_types::ZclawError::InvalidInput( + "Skills directory not configured".into() + ))?; + self.skills.delete_skill(skills_dir, id).await + } + /// Execute a skill with the given ID and input pub async fn execute_skill( &self, diff --git a/crates/zclaw-kernel/src/registry.rs b/crates/zclaw-kernel/src/registry.rs index c157071..7632e2d 100644 --- a/crates/zclaw-kernel/src/registry.rs +++ b/crates/zclaw-kernel/src/registry.rs @@ -38,6 +38,12 @@ impl AgentRegistry { self.message_counts.remove(id); } + /// Update an agent's configuration (preserves state and message count) + pub fn update(&self, config: AgentConfig) { + let id = config.id; + self.agents.insert(id, config); + } + /// Get an agent by ID pub fn get(&self, id: &AgentId) -> Option { self.agents.get(id).map(|r| r.clone()) diff --git a/crates/zclaw-skills/src/registry.rs b/crates/zclaw-skills/src/registry.rs index 699490f..a6f8ded 100644 --- a/crates/zclaw-skills/src/registry.rs +++ b/crates/zclaw-skills/src/registry.rs @@ -171,6 +171,150 @@ impl SkillRegistry { skills.insert(manifest.id.clone(), skill); manifests.insert(manifest.id.clone(), manifest); } + + /// Create a skill from manifest, writing SKILL.md to disk + pub async fn create_skill( + &self, + skills_dir: &std::path::Path, + manifest: SkillManifest, + ) -> Result<()> { + let skill_dir = skills_dir.join(manifest.id.as_str()); + if skill_dir.exists() { + return Err(zclaw_types::ZclawError::InvalidInput( + format!("Skill directory already exists: {}", skill_dir.display()) + )); + } + + // Create directory + std::fs::create_dir_all(&skill_dir) + .map_err(|e| zclaw_types::ZclawError::StorageError( + format!("Failed to create skill directory: {}", e) + ))?; + + // Write SKILL.md + let content = serialize_skill_md(&manifest); + std::fs::write(skill_dir.join("SKILL.md"), &content) + .map_err(|e| zclaw_types::ZclawError::StorageError( + format!("Failed to write SKILL.md: {}", e) + ))?; + + // Load into registry + self.load_skill_from_dir(&skill_dir).await?; + + Ok(()) + } + + /// Update a skill manifest, rewriting SKILL.md on disk + pub async fn update_skill( + &self, + skills_dir: &std::path::Path, + id: &SkillId, + updates: SkillManifest, + ) -> Result { + // Find existing skill directory + let skill_dir = skills_dir.join(id.as_str()); + if !skill_dir.exists() { + return Err(zclaw_types::ZclawError::NotFound( + format!("Skill directory not found: {}", skill_dir.display()) + )); + } + + // Merge: start from existing manifest, apply updates + let existing = self.get_manifest(id).await + .ok_or_else(|| zclaw_types::ZclawError::NotFound( + format!("Skill not found in registry: {}", id) + ))?; + + let updated = SkillManifest { + id: existing.id.clone(), + name: if updates.name.is_empty() { existing.name } else { updates.name }, + description: if updates.description.is_empty() { existing.description } else { updates.description }, + version: if updates.version.is_empty() { existing.version } else { updates.version }, + author: updates.author.or(existing.author), + mode: existing.mode, + capabilities: if updates.capabilities.is_empty() { existing.capabilities } else { updates.capabilities }, + input_schema: updates.input_schema.or(existing.input_schema), + output_schema: updates.output_schema.or(existing.output_schema), + tags: if updates.tags.is_empty() { existing.tags } else { updates.tags }, + category: updates.category.or(existing.category), + triggers: if updates.triggers.is_empty() { existing.triggers } else { updates.triggers }, + enabled: updates.enabled, + }; + + // Rewrite SKILL.md + let content = serialize_skill_md(&updated); + std::fs::write(skill_dir.join("SKILL.md"), &content) + .map_err(|e| zclaw_types::ZclawError::StorageError( + format!("Failed to write SKILL.md: {}", e) + ))?; + + // Reload into registry + self.remove(id).await; + self.load_skill_from_dir(&skill_dir).await?; + + Ok(updated) + } + + /// Delete a skill: remove directory from disk and unregister + pub async fn delete_skill( + &self, + skills_dir: &std::path::Path, + id: &SkillId, + ) -> Result<()> { + let skill_dir = skills_dir.join(id.as_str()); + if skill_dir.exists() { + std::fs::remove_dir_all(&skill_dir) + .map_err(|e| zclaw_types::ZclawError::StorageError( + format!("Failed to remove skill directory: {}", e) + ))?; + } + + self.remove(id).await; + Ok(()) + } +} + +/// Serialize a SkillManifest into SKILL.md frontmatter format +fn serialize_skill_md(manifest: &SkillManifest) -> String { + let mut parts = Vec::new(); + + // Frontmatter + parts.push("---".to_string()); + parts.push(format!("name: \"{}\"", manifest.name)); + parts.push(format!("description: \"{}\"", manifest.description)); + parts.push(format!("version: \"{}\"", manifest.version)); + parts.push(format!("mode: {}", match manifest.mode { + SkillMode::PromptOnly => "prompt-only", + SkillMode::Python => "python", + SkillMode::Shell => "shell", + SkillMode::Wasm => "wasm", + SkillMode::Native => "native", + })); + if !manifest.capabilities.is_empty() { + parts.push(format!("capabilities: {}", manifest.capabilities.join(", "))); + } + if !manifest.tags.is_empty() { + parts.push(format!("tags: {}", manifest.tags.join(", "))); + } + if !manifest.triggers.is_empty() { + parts.push("triggers:".to_string()); + for trigger in &manifest.triggers { + parts.push(format!(" - \"{}\"", trigger)); + } + } + if let Some(ref cat) = manifest.category { + parts.push(format!("category: \"{}\"", cat)); + } + parts.push(format!("enabled: {}", manifest.enabled)); + parts.push("---".to_string()); + parts.push(String::new()); + + // Body: use description as the skill content + parts.push(format!("# {}", manifest.name)); + parts.push(String::new()); + parts.push(manifest.description.clone()); + + parts.join("\n") } impl Default for SkillRegistry { diff --git a/crates/zclaw-types/src/event.rs b/crates/zclaw-types/src/event.rs index 7e620da..c6f4c30 100644 --- a/crates/zclaw-types/src/event.rs +++ b/crates/zclaw-types/src/event.rs @@ -32,6 +32,12 @@ pub enum Event { new_state: String, }, + /// Agent configuration updated + AgentConfigUpdated { + agent_id: AgentId, + name: String, + }, + /// Session created SessionCreated { session_id: SessionId, @@ -145,6 +151,7 @@ impl Event { Event::AgentSpawned { .. } => "agent_spawned", Event::AgentTerminated { .. } => "agent_terminated", Event::AgentStateChanged { .. } => "agent_state_changed", + Event::AgentConfigUpdated { .. } => "agent_config_updated", Event::SessionCreated { .. } => "session_created", Event::MessageReceived { .. } => "message_received", Event::MessageSent { .. } => "message_sent", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 7dd5cd2..4038498 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -45,6 +45,7 @@ async-trait = { workspace = true } # Serialization serde = { workspace = true } serde_json = { workspace = true } +serde_yaml = "0.9" toml = "0.8" # HTTP client diff --git a/desktop/src-tauri/src/intelligence/compactor.rs b/desktop/src-tauri/src/intelligence/compactor.rs index e832aab..784b298 100644 --- a/desktop/src-tauri/src/intelligence/compactor.rs +++ b/desktop/src-tauri/src/intelligence/compactor.rs @@ -14,7 +14,6 @@ // NOTE: #[tauri::command] functions are registered via invoke_handler! at runtime, // which the Rust compiler does not track as "use". Module-level allow required // for Tauri-commanded functions. Genuinely unused methods annotated individually. -#![allow(dead_code)] use serde::{Deserialize, Serialize}; use regex::Regex; @@ -99,6 +98,8 @@ pub struct CompactionCheck { } /// Configuration for LLM-based summary generation +/// NOTE: Reserved for future LLM compaction Tauri command +#[allow(dead_code)] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LlmSummaryConfig { pub provider: String, @@ -250,6 +251,7 @@ impl ContextCompactor { /// - `use_llm` is false /// - LLM config is not provided /// - LLM call fails and `llm_fallback_to_rules` is true + #[allow(dead_code)] // Reserved: Tauri command removed during refactor, will be re-registered pub async fn compact_with_llm( &self, messages: &[CompactableMessage], @@ -499,18 +501,6 @@ impl ContextCompactor { conclusions.into_iter().take(3).collect() } - - /// Get current configuration - #[allow(dead_code)] // Reserved: no Tauri command yet - pub fn get_config(&self) -> &CompactionConfig { - &self.config - } - - /// Update configuration - #[allow(dead_code)] // Reserved: no Tauri command yet - pub fn update_config(&mut self, updates: CompactionConfig) { - self.config = updates; - } } // === Tauri Commands === @@ -561,33 +551,6 @@ pub async fn compactor_compact( result } -/// Execute compaction with optional LLM-based summary -#[tauri::command] -pub async fn compactor_compact_llm( - messages: Vec, - agent_id: String, - conversation_id: Option, - compaction_config: Option, - llm_config: Option, -) -> CompactionResult { - let memory_flush = compaction_config - .as_ref() - .map(|c| c.memory_flush_enabled) - .unwrap_or(false); - let flushed = if memory_flush { - flush_old_messages_to_memory(&messages, &agent_id, conversation_id.as_deref()).await - } else { - 0 - }; - - let compactor = ContextCompactor::new(compaction_config); - let mut result = compactor - .compact_with_llm(&messages, &agent_id, conversation_id.as_deref(), llm_config.as_ref()) - .await; - result.flushed_memories = flushed; - result -} - /// Flush important messages from the old (pre-compaction) portion to VikingStorage. /// /// Extracts user messages and key assistant responses as session memories diff --git a/desktop/src-tauri/src/intelligence/heartbeat.rs b/desktop/src-tauri/src/intelligence/heartbeat.rs index 0dc7188..9cbc69a 100644 --- a/desktop/src-tauri/src/intelligence/heartbeat.rs +++ b/desktop/src-tauri/src/intelligence/heartbeat.rs @@ -93,7 +93,7 @@ pub enum HeartbeatStatus { /// Type alias for heartbeat check function #[allow(dead_code)] // Reserved for future proactive check registration -pub type HeartbeatCheckFn = Box std::pin::Pin> + Send>> + Send + Sync>; +type HeartbeatCheckFn = Box std::pin::Pin> + Send>> + Send + Sync>; // === Default Config === diff --git a/desktop/src-tauri/src/intelligence/identity.rs b/desktop/src-tauri/src/intelligence/identity.rs index 8374043..fdd9a1a 100644 --- a/desktop/src-tauri/src/intelligence/identity.rs +++ b/desktop/src-tauri/src/intelligence/identity.rs @@ -13,10 +13,8 @@ //! NOTE: Some methods are reserved for future integration. // NOTE: #[tauri::command] functions are registered via invoke_handler! at runtime, -// which the Rust compiler does not track as "use". This module-level allow is -// required for all Tauri-commanded functions. Only genuinely unused non-command -// methods have individual #[allow(dead_code)] annotations below. -#![allow(dead_code)] +// which the Rust compiler does not track as "use". Module-level allow required +// for Tauri-commanded functions. Genuinely unused methods annotated individually. use chrono::Utc; use serde::{Deserialize, Serialize}; @@ -541,6 +539,7 @@ pub type IdentityManagerState = Arc>; /// Initialize identity manager #[tauri::command] +#[allow(dead_code)] // Registered via invoke_handler! at runtime pub async fn identity_init() -> Result { Ok(Arc::new(Mutex::new(AgentIdentityManager::new()))) } diff --git a/desktop/src-tauri/src/intelligence/reflection.rs b/desktop/src-tauri/src/intelligence/reflection.rs index 00f02b6..7cc3f78 100644 --- a/desktop/src-tauri/src/intelligence/reflection.rs +++ b/desktop/src-tauri/src/intelligence/reflection.rs @@ -583,12 +583,6 @@ impl ReflectionEngine { self.history.iter().rev().take(limit).collect() } - /// Get last reflection result - #[allow(dead_code)] // Reserved: no Tauri command yet - pub fn get_last_result(&self) -> Option<&ReflectionResult> { - self.history.last() - } - /// Get current state pub fn get_state(&self) -> &ReflectionState { &self.state diff --git a/desktop/src-tauri/src/intelligence/validation.rs b/desktop/src-tauri/src/intelligence/validation.rs index d3fee83..fe2b9ec 100644 --- a/desktop/src-tauri/src/intelligence/validation.rs +++ b/desktop/src-tauri/src/intelligence/validation.rs @@ -5,8 +5,6 @@ //! //! NOTE: Some functions are defined for future use and external API exposure. -#![allow(dead_code)] // Validation functions reserved for future API endpoints - use std::fmt; /// Maximum length for identifier strings (agent_id, pipeline_id, skill_id, etc.) @@ -201,6 +199,7 @@ pub fn validate_string_length(value: &str, field_name: &str, max_length: usize) /// Validate an optional identifier field /// /// Returns Ok if the value is None or if it contains a valid identifier. +#[allow(dead_code)] // Reserved for optional-ID Tauri commands pub fn validate_optional_identifier(value: Option<&str>, field_name: &str) -> Result<(), ValidationError> { match value { None => Ok(()), @@ -210,6 +209,7 @@ pub fn validate_optional_identifier(value: Option<&str>, field_name: &str) -> Re } /// Validate a list of identifiers +#[allow(dead_code)] // Reserved for batch-ID Tauri commands pub fn validate_identifiers<'a, I>(values: I, field_name: &str) -> Result<(), ValidationError> where I: IntoIterator, @@ -221,6 +221,7 @@ where } /// Sanitize a string for safe logging (remove control characters, limit length) +#[allow(dead_code)] // Reserved for log-sanitization Tauri commands pub fn sanitize_for_logging(value: &str, max_len: usize) -> String { let sanitized: String = value .chars() diff --git a/desktop/src-tauri/src/kernel_commands.rs b/desktop/src-tauri/src/kernel_commands.rs index cdbc7e5..a6f0dfd 100644 --- a/desktop/src-tauri/src/kernel_commands.rs +++ b/desktop/src-tauri/src/kernel_commands.rs @@ -410,6 +410,72 @@ pub async fn agent_delete( .map_err(|e| format!("Failed to delete agent: {}", e)) } +/// Update an agent's configuration +#[tauri::command] +pub async fn agent_update( + state: State<'_, KernelState>, + agent_id: String, + updates: AgentUpdateRequest, +) -> Result { + let agent_id = validate_agent_id(&agent_id)?; + + let kernel_lock = state.lock().await; + let kernel = kernel_lock.as_ref() + .ok_or_else(|| "Kernel not initialized. Call kernel_init first.".to_string())?; + + let id: AgentId = agent_id.parse() + .map_err(|_| "Invalid agent ID format".to_string())?; + + // Get existing config + let mut config = kernel.get_agent_config(&id) + .ok_or_else(|| format!("Agent not found: {}", agent_id))?; + + // Apply updates + if let Some(name) = updates.name { + config.name = name; + } + if let Some(description) = updates.description { + config.description = Some(description); + } + if let Some(system_prompt) = updates.system_prompt { + config.system_prompt = Some(system_prompt); + } + if let Some(model) = updates.model { + config.model.model = model; + } + if let Some(provider) = updates.provider { + config.model.provider = provider; + } + if let Some(max_tokens) = updates.max_tokens { + config.max_tokens = Some(max_tokens); + } + if let Some(temperature) = updates.temperature { + config.temperature = Some(temperature); + } + + // Save updated config + kernel.update_agent(config) + .await + .map_err(|e| format!("Failed to update agent: {}", e))?; + + // Return updated info + kernel.get_agent(&id) + .ok_or_else(|| format!("Agent not found after update: {}", agent_id)) +} + +/// Agent update request +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentUpdateRequest { + pub name: Option, + pub description: Option, + pub system_prompt: Option, + pub model: Option, + pub provider: Option, + pub max_tokens: Option, + pub temperature: Option, +} + /// Export an agent configuration as JSON #[tauri::command] pub async fn agent_export( @@ -500,6 +566,10 @@ pub enum StreamChatEvent { ToolEnd { name: String, output: serde_json::Value }, /// New iteration started (multi-turn tool calling) IterationStart { iteration: usize, max_iterations: usize }, + /// Hand execution started + HandStart { name: String, params: serde_json::Value }, + /// Hand execution completed + HandEnd { name: String, result: serde_json::Value }, /// Stream completed Complete { input_tokens: u32, output_tokens: u32 }, /// Error occurred @@ -644,11 +714,21 @@ pub async fn agent_chat_stream( } LoopEvent::ToolStart { name, input } => { tracing::debug!("[agent_chat_stream] ToolStart: {}", name); - StreamChatEvent::ToolStart { name: name.clone(), input: input.clone() } + // Emit hand event if this is a hand tool + if name.starts_with("hand_") { + StreamChatEvent::HandStart { name: name.clone(), params: input.clone() } + } else { + StreamChatEvent::ToolStart { name: name.clone(), input: input.clone() } + } } LoopEvent::ToolEnd { name, output } => { tracing::debug!("[agent_chat_stream] ToolEnd: {}", name); - StreamChatEvent::ToolEnd { name: name.clone(), output: output.clone() } + // Emit hand event if this is a hand tool + if name.starts_with("hand_") { + StreamChatEvent::HandEnd { name: name.clone(), result: output.clone() } + } else { + StreamChatEvent::ToolEnd { name: name.clone(), output: output.clone() } + } } LoopEvent::IterationStart { iteration, max_iterations } => { tracing::debug!("[agent_chat_stream] IterationStart: {}/{}", iteration, max_iterations); @@ -811,6 +891,142 @@ pub async fn skill_refresh( Ok(skills.into_iter().map(SkillInfoResponse::from).collect()) } +// ============================================================================ +// Skill CRUD Commands +// ============================================================================ + +/// Request body for creating a new skill +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateSkillRequest { + pub name: String, + pub description: Option, + pub triggers: Vec, + pub actions: Vec, + pub enabled: Option, +} + +/// Request body for updating a skill +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateSkillRequest { + pub name: Option, + pub description: Option, + pub triggers: Option>, + pub actions: Option>, + pub enabled: Option, +} + +/// Create a new skill in the skills directory +#[tauri::command] +pub async fn skill_create( + state: State<'_, KernelState>, + request: CreateSkillRequest, +) -> Result { + let name = request.name.trim().to_string(); + if name.is_empty() { + return Err("Skill name cannot be empty".to_string()); + } + + // Generate skill ID from name + let id = name.to_lowercase() + .replace(' ', "-") + .replace(|c: char| !c.is_alphanumeric() && c != '-', ""); + + validate_identifier(&id, "skill_id") + .map_err(|e| e.to_string())?; + + let kernel_lock = state.lock().await; + let kernel = kernel_lock.as_ref() + .ok_or_else(|| "Kernel not initialized. Call kernel_init first.".to_string())?; + + let manifest = zclaw_skills::SkillManifest { + id: SkillId::new(&id), + name: name.clone(), + description: request.description.unwrap_or_default(), + version: "1.0.0".to_string(), + author: None, + mode: zclaw_skills::SkillMode::PromptOnly, + capabilities: request.actions, + input_schema: None, + output_schema: None, + tags: vec![], + category: None, + triggers: request.triggers, + enabled: request.enabled.unwrap_or(true), + }; + + kernel.create_skill(manifest.clone()) + .await + .map_err(|e| format!("Failed to create skill: {}", e))?; + + Ok(SkillInfoResponse::from(manifest)) +} + +/// Update an existing skill +#[tauri::command] +pub async fn skill_update( + state: State<'_, KernelState>, + id: String, + request: UpdateSkillRequest, +) -> Result { + validate_identifier(&id, "skill_id") + .map_err(|e| e.to_string())?; + + let kernel_lock = state.lock().await; + let kernel = kernel_lock.as_ref() + .ok_or_else(|| "Kernel not initialized. Call kernel_init first.".to_string())?; + + // Get existing manifest + let existing = kernel.skills() + .get_manifest(&SkillId::new(&id)) + .await + .ok_or_else(|| format!("Skill not found: {}", id))?; + + // Build updated manifest from existing + request fields + let updated = zclaw_skills::SkillManifest { + id: existing.id.clone(), + name: request.name.unwrap_or(existing.name), + description: request.description.unwrap_or(existing.description), + version: existing.version.clone(), + author: existing.author.clone(), + mode: existing.mode.clone(), + capabilities: request.actions.unwrap_or(existing.capabilities), + input_schema: existing.input_schema.clone(), + output_schema: existing.output_schema.clone(), + tags: existing.tags.clone(), + category: existing.category.clone(), + triggers: request.triggers.unwrap_or(existing.triggers), + enabled: request.enabled.unwrap_or(existing.enabled), + }; + + let result = kernel.update_skill(&SkillId::new(&id), updated) + .await + .map_err(|e| format!("Failed to update skill: {}", e))?; + + Ok(SkillInfoResponse::from(result)) +} + +/// Delete a skill +#[tauri::command] +pub async fn skill_delete( + state: State<'_, KernelState>, + id: String, +) -> Result<(), String> { + validate_identifier(&id, "skill_id") + .map_err(|e| e.to_string())?; + + let kernel_lock = state.lock().await; + let kernel = kernel_lock.as_ref() + .ok_or_else(|| "Kernel not initialized. Call kernel_init first.".to_string())?; + + kernel.delete_skill(&SkillId::new(&id)) + .await + .map_err(|e| format!("Failed to delete skill: {}", e))?; + + Ok(()) +} + // ============================================================================ // Skill Execution Command // ============================================================================ diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 9798a22..280a3ab 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -1331,6 +1331,7 @@ pub fn run() { kernel_commands::agent_list, kernel_commands::agent_get, kernel_commands::agent_delete, + kernel_commands::agent_update, kernel_commands::agent_export, kernel_commands::agent_import, kernel_commands::agent_chat, @@ -1339,6 +1340,9 @@ pub fn run() { kernel_commands::skill_list, kernel_commands::skill_refresh, kernel_commands::skill_execute, + kernel_commands::skill_create, + kernel_commands::skill_update, + kernel_commands::skill_delete, // Hands commands (autonomous capabilities) kernel_commands::hand_list, kernel_commands::hand_execute, @@ -1365,6 +1369,9 @@ pub fn run() { // Pipeline commands (DSL-based workflows) pipeline_commands::pipeline_list, pipeline_commands::pipeline_templates, pipeline_commands::pipeline_get, + pipeline_commands::pipeline_create, + pipeline_commands::pipeline_update, + pipeline_commands::pipeline_delete, pipeline_commands::pipeline_run, pipeline_commands::pipeline_progress, pipeline_commands::pipeline_cancel, diff --git a/desktop/src-tauri/src/pipeline_commands.rs b/desktop/src-tauri/src/pipeline_commands.rs index 7d2c2aa..b79bf05 100644 --- a/desktop/src-tauri/src/pipeline_commands.rs +++ b/desktop/src-tauri/src/pipeline_commands.rs @@ -20,6 +20,11 @@ use zclaw_pipeline::{ LlmActionDriver, SkillActionDriver, HandActionDriver, + PipelineMetadata, + PipelineSpec, + PipelineStep, + Action, + ErrorStrategy, }; use zclaw_runtime::{LlmDriver, CompletionRequest}; use zclaw_skills::SkillContext; @@ -624,6 +629,221 @@ pub async fn pipeline_refresh( Ok(pipelines.into_iter().map(|(_, p)| pipeline_to_info(&p)).collect()) } +// ============================================================================ +// Pipeline CRUD Commands (Create / Update / Delete) +// ============================================================================ + +/// Create pipeline request +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreatePipelineRequest { + pub name: String, + pub description: Option, + pub steps: Vec, +} + +/// Update pipeline request +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdatePipelineRequest { + pub name: Option, + pub description: Option, + pub steps: Option>, +} + +/// Workflow step input from frontend +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkflowStepInput { + pub hand_name: String, + pub name: Option, + pub params: Option>, + pub condition: Option, +} + +/// Create a new pipeline as a YAML file +#[tauri::command] +pub async fn pipeline_create( + state: State<'_, Arc>, + request: CreatePipelineRequest, +) -> Result { + let name = request.name.trim().to_string(); + if name.is_empty() { + return Err("Pipeline name cannot be empty".to_string()); + } + + let pipelines_dir = get_pipelines_directory()?; + if !pipelines_dir.exists() { + std::fs::create_dir_all(&pipelines_dir) + .map_err(|e| format!("Failed to create pipelines directory: {}", e))?; + } + + // Generate pipeline ID from name + let pipeline_id = name.to_lowercase() + .replace(' ', "-") + .replace(|c: char| !c.is_alphanumeric() && c != '-', ""); + + let file_path = pipelines_dir.join(format!("{}.yaml", pipeline_id)); + if file_path.exists() { + return Err(format!("Pipeline file already exists: {}", file_path.display())); + } + + // Build Pipeline struct + let steps: Vec = request.steps.into_iter().enumerate().map(|(i, s)| { + let step_id = s.name.clone().unwrap_or_else(|| format!("step-{}", i + 1)); + PipelineStep { + id: step_id, + action: Action::Hand { + hand_id: s.hand_name.clone(), + hand_action: "execute".to_string(), + params: s.params.unwrap_or_default().into_iter().map(|(k, v)| (k, v.to_string())).collect(), + }, + description: s.name, + when: s.condition, + retry: None, + timeout_secs: None, + } + }).collect(); + + let pipeline = Pipeline { + api_version: "zclaw/v1".to_string(), + kind: "Pipeline".to_string(), + metadata: PipelineMetadata { + name: pipeline_id.clone(), + display_name: Some(name), + description: request.description, + category: None, + industry: None, + tags: vec![], + icon: None, + author: None, + version: "1.0.0".to_string(), + annotations: None, + }, + spec: PipelineSpec { + inputs: vec![], + steps, + outputs: HashMap::new(), + on_error: ErrorStrategy::Stop, + timeout_secs: 0, + max_workers: 4, + }, + }; + + // Serialize to YAML + let yaml_content = serde_yaml::to_string(&pipeline) + .map_err(|e| format!("Failed to serialize pipeline: {}", e))?; + + std::fs::write(&file_path, yaml_content) + .map_err(|e| format!("Failed to write pipeline file: {}", e))?; + + // Register in state + let mut state_pipelines = state.pipelines.write().await; + let mut state_paths = state.pipeline_paths.write().await; + state_pipelines.insert(pipeline_id.clone(), pipeline.clone()); + state_paths.insert(pipeline_id, file_path); + + Ok(pipeline_to_info(&pipeline)) +} + +/// Update an existing pipeline +#[tauri::command] +pub async fn pipeline_update( + state: State<'_, Arc>, + pipeline_id: String, + request: UpdatePipelineRequest, +) -> Result { + let pipelines = state.pipelines.read().await; + let paths = state.pipeline_paths.read().await; + + let existing = pipelines.get(&pipeline_id) + .ok_or_else(|| format!("Pipeline not found: {}", pipeline_id))?; + let file_path = paths.get(&pipeline_id) + .ok_or_else(|| format!("Pipeline file path not found: {}", pipeline_id))? + .clone(); + + // Build updated pipeline + let updated_metadata = PipelineMetadata { + display_name: request.name.or(existing.metadata.display_name.clone()), + description: request.description.or(existing.metadata.description.clone()), + ..existing.metadata.clone() + }; + + let updated_steps = match request.steps { + Some(steps) => steps.into_iter().enumerate().map(|(i, s)| { + let step_id = s.name.clone().unwrap_or_else(|| format!("step-{}", i + 1)); + PipelineStep { + id: step_id, + action: Action::Hand { + hand_id: s.hand_name.clone(), + hand_action: "execute".to_string(), + params: s.params.unwrap_or_default().into_iter().map(|(k, v)| (k, v.to_string())).collect(), + }, + description: s.name, + when: s.condition, + retry: None, + timeout_secs: None, + } + }).collect(), + None => existing.spec.steps.clone(), + }; + + let updated_pipeline = Pipeline { + metadata: updated_metadata, + spec: PipelineSpec { + steps: updated_steps, + ..existing.spec.clone() + }, + ..existing.clone() + }; + + // Write to file + let yaml_content = serde_yaml::to_string(&updated_pipeline) + .map_err(|e| format!("Failed to serialize pipeline: {}", e))?; + + // Drop read locks before write + drop(pipelines); + drop(paths); + + std::fs::write(file_path, yaml_content) + .map_err(|e| format!("Failed to write pipeline file: {}", e))?; + + // Update state + let mut state_pipelines = state.pipelines.write().await; + state_pipelines.insert(pipeline_id.clone(), updated_pipeline.clone()); + + Ok(pipeline_to_info(&updated_pipeline)) +} + +/// Delete a pipeline +#[tauri::command] +pub async fn pipeline_delete( + state: State<'_, Arc>, + pipeline_id: String, +) -> Result<(), String> { + let paths = state.pipeline_paths.read().await; + + let file_path = paths.get(&pipeline_id) + .ok_or_else(|| format!("Pipeline not found: {}", pipeline_id))?; + + let path = file_path.clone(); + drop(paths); + + // Remove file + if path.exists() { + std::fs::remove_file(&path) + .map_err(|e| format!("Failed to delete pipeline file: {}", e))?; + } + + // Remove from state + let mut state_pipelines = state.pipelines.write().await; + let mut state_paths = state.pipeline_paths.write().await; + state_pipelines.remove(&pipeline_id); + state_paths.remove(&pipeline_id); + + Ok(()) +} + // Helper functions fn get_pipelines_directory() -> Result { diff --git a/desktop/src/lib/api-fallbacks.ts b/desktop/src/lib/api-fallbacks.ts index b5a5ed8..c521aa5 100644 --- a/desktop/src/lib/api-fallbacks.ts +++ b/desktop/src/lib/api-fallbacks.ts @@ -182,12 +182,8 @@ export function getUsageStatsFallback(sessions: SessionForStats[] = []): UsageSt */ export function getPluginStatusFallback(skills: SkillForPlugins[] = []): PluginStatusFallback[] { if (skills.length === 0) { - // Return default built-in skills if none provided - return [ - { id: 'builtin-chat', name: 'Chat', status: 'active', description: '基础对话能力' }, - { id: 'builtin-code', name: 'Code', status: 'active', description: '代码生成与分析' }, - { id: 'builtin-file', name: 'File', status: 'active', description: '文件操作能力' }, - ]; + // No skills loaded — return empty rather than fabricating fake builtins + return []; } return skills.map((skill) => ({ @@ -215,26 +211,17 @@ export function getScheduledTasksFallback(triggers: TriggerForTasks[] = []): Sch /** * Default security status when /api/security/status returns 404. - * ZCLAW has 16 security layers - show them with conservative defaults. + * Returns honest minimal response — only includes layers that correspond + * to real ZCLAW capabilities, no fabricated layers. */ export function getSecurityStatusFallback(): SecurityStatusFallback { const layers: SecurityLayerFallback[] = [ - { name: 'Input Validation', enabled: true, description: '输入验证' }, - { name: 'Output Sanitization', enabled: true, description: '输出净化' }, - { name: 'Rate Limiting', enabled: true, description: '速率限制' }, - { name: 'Authentication', enabled: true, description: '身份认证' }, - { name: 'Authorization', enabled: true, description: '权限控制' }, - { name: 'Encryption', enabled: true, description: '数据加密' }, - { name: 'Audit Logging', enabled: true, description: '审计日志' }, - { name: 'Sandboxing', enabled: false, description: '沙箱隔离' }, - { name: 'Network Isolation', enabled: false, description: '网络隔离' }, - { name: 'Resource Limits', enabled: true, description: '资源限制' }, - { name: 'Secret Management', enabled: true, description: '密钥管理' }, - { name: 'Certificate Pinning', enabled: false, description: '证书固定' }, - { name: 'Code Signing', enabled: false, description: '代码签名' }, - { name: 'Secure Boot', enabled: false, description: '安全启动' }, - { name: 'TPM Integration', enabled: false, description: 'TPM 集成' }, - { name: 'Zero Trust', enabled: false, description: '零信任' }, + { name: 'device_auth', enabled: true, description: '设备认证' }, + { name: 'rbac', enabled: true, description: '角色权限控制' }, + { name: 'audit_log', enabled: true, description: '审计日志' }, + { name: 'approval_gate', enabled: true, description: '操作审批门' }, + { name: 'input_validation', enabled: true, description: '输入验证' }, + { name: 'secret_storage', enabled: true, description: '密钥安全存储 (OS keyring)' }, ]; const enabledCount = layers.filter((l) => l.enabled).length; diff --git a/desktop/src/lib/kernel-client.ts b/desktop/src/lib/kernel-client.ts index 5ad0032..8d0b5ca 100644 --- a/desktop/src/lib/kernel-client.ts +++ b/desktop/src/lib/kernel-client.ts @@ -107,11 +107,25 @@ export interface StreamEventError { message: string; } +export interface StreamEventHandStart { + type: 'handStart'; + name: string; + params: unknown; +} + +export interface StreamEventHandEnd { + type: 'handEnd'; + name: string; + result: unknown; +} + export type StreamChatEvent = | StreamEventDelta | StreamEventToolStart | StreamEventToolEnd | StreamEventIterationStart + | StreamEventHandStart + | StreamEventHandEnd | StreamEventComplete | StreamEventError; @@ -415,10 +429,33 @@ export class KernelClient { } /** - * Update clone — not supported in KernelClient mode + * Update clone — maps to kernel agent_update */ - async updateClone(_id: string, _updates: Record): Promise<{ clone: unknown }> { - throw new Error('Agent update is not supported in local kernel mode'); + async updateClone(id: string, updates: Record): Promise<{ clone: unknown }> { + await invoke('agent_update', { + agentId: id, + updates: { + name: updates.name as string | undefined, + description: updates.description as string | undefined, + systemPrompt: updates.systemPrompt as string | undefined, + model: updates.model as string | undefined, + provider: updates.provider as string | undefined, + maxTokens: updates.maxTokens as number | undefined, + temperature: updates.temperature as number | undefined, + }, + }); + + // Return updated clone representation + const clone = { + id, + name: updates.name, + role: updates.description || updates.role, + model: updates.model, + personality: updates.personality, + communicationStyle: updates.communicationStyle, + systemPrompt: updates.systemPrompt, + }; + return { clone }; } // === Chat === @@ -514,6 +551,20 @@ export class KernelClient { } break; + case 'handStart': + log.debug('Hand started:', streamEvent.name, streamEvent.params); + if (callbacks.onHand) { + callbacks.onHand(streamEvent.name, 'running', undefined); + } + break; + + case 'handEnd': + log.debug('Hand ended:', streamEvent.name, streamEvent.result); + if (callbacks.onHand) { + callbacks.onHand(streamEvent.name, 'completed', streamEvent.result); + } + break; + case 'iteration_start': log.debug('Iteration started:', streamEvent.iteration, '/', streamEvent.maxIterations); // Don't need to notify user about iterations @@ -676,7 +727,7 @@ export class KernelClient { try { return await invoke('hand_get', { name }); } catch { - // hand_get not yet implemented in backend + // Hand not found or kernel not initialized return {}; } } @@ -735,7 +786,7 @@ export class KernelClient { error?: string; }[] }> { - // Hand run history API may not exist yet, return empty array + // Hand run history try { return await invoke('hand_run_list', { handName: name, ...opts }); } catch { @@ -810,10 +861,102 @@ export class KernelClient { } /** - * Execute a skill - * Checks autonomy authorization before execution and passes the autonomy - * level to the backend for defense-in-depth enforcement. + * Create a new skill */ + async createSkill(skill: { + name: string; + description?: string; + triggers: Array<{ type: string; pattern?: string }>; + actions: Array<{ type: string; params?: Record }>; + enabled?: boolean; + }): Promise<{ skill?: { + id: string; + name: string; + description: string; + version: string; + capabilities: string[]; + tags: string[]; + mode: string; + enabled: boolean; + triggers: string[]; + category?: string; + } }> { + const result = await invoke<{ + id: string; + name: string; + description: string; + version: string; + capabilities: string[]; + tags: string[]; + mode: string; + enabled: boolean; + triggers: string[]; + category?: string; + }>('skill_create', { + request: { + name: skill.name, + description: skill.description, + triggers: skill.triggers.map(t => t.pattern || t.type), + actions: skill.actions.map(a => a.type), + enabled: skill.enabled, + }, + }); + return { skill: result }; + } + + /** + * Update an existing skill + */ + async updateSkill(id: string, updates: { + name?: string; + description?: string; + triggers?: Array<{ type: string; pattern?: string }>; + actions?: Array<{ type: string; params?: Record }>; + enabled?: boolean; + }): Promise<{ skill?: { + id: string; + name: string; + description: string; + version: string; + capabilities: string[]; + tags: string[]; + mode: string; + enabled: boolean; + triggers: string[]; + category?: string; + } }> { + const result = await invoke<{ + id: string; + name: string; + description: string; + version: string; + capabilities: string[]; + tags: string[]; + mode: string; + enabled: boolean; + triggers: string[]; + category?: string; + }>('skill_update', { + id, + request: { + name: updates.name, + description: updates.description, + triggers: updates.triggers?.map(t => t.pattern || t.type), + actions: updates.actions?.map(a => a.type), + enabled: updates.enabled, + }, + }); + return { skill: result }; + } + + /** + * Delete a skill + */ + async deleteSkill(id: string): Promise { + await invoke('skill_delete', { id }); + } + + /** async executeSkill(id: string, input?: Record): Promise<{ success: boolean; output?: unknown; diff --git a/desktop/src/store/configStore.ts b/desktop/src/store/configStore.ts index 7b0178a..003ec29 100644 --- a/desktop/src/store/configStore.ts +++ b/desktop/src/store/configStore.ts @@ -667,14 +667,60 @@ function createConfigClientFromKernel(client: KernelClient): ConfigStoreClient { getSkill: async (id: string) => { return { skill: { id, name: id, description: '' } }; }, - createSkill: async () => { - throw new Error('Skill creation not supported in KernelClient'); + createSkill: async (skill) => { + try { + const result = await client.createSkill(skill); + if (result?.skill) { + return { + skill: { + id: result.skill.id, + name: result.skill.name, + description: result.skill.description, + version: result.skill.version, + capabilities: result.skill.capabilities, + tags: result.skill.tags, + mode: result.skill.mode, + enabled: result.skill.enabled, + triggers: (result.skill.triggers || []).map((t: string) => ({ type: 'keyword', pattern: t })), + category: result.skill.category, + } as SkillInfo, + }; + } + return null; + } catch { + return null; + } }, - updateSkill: async () => { - throw new Error('Skill update not supported in KernelClient'); + updateSkill: async (id, updates) => { + try { + const result = await client.updateSkill(id, updates); + if (result?.skill) { + return { + skill: { + id: result.skill.id, + name: result.skill.name, + description: result.skill.description, + version: result.skill.version, + capabilities: result.skill.capabilities, + tags: result.skill.tags, + mode: result.skill.mode, + enabled: result.skill.enabled, + triggers: (result.skill.triggers || []).map((t: string) => ({ type: 'keyword', pattern: t })), + category: result.skill.category, + } as SkillInfo, + }; + } + return null; + } catch { + return null; + } }, - deleteSkill: async () => { - throw new Error('Skill deletion not supported in KernelClient'); + deleteSkill: async (id) => { + try { + await client.deleteSkill(id); + } catch { + // Ignore deletion errors + } }, listChannels: async () => ({ channels: [] }), getChannel: async () => null, diff --git a/desktop/src/store/workflowStore.ts b/desktop/src/store/workflowStore.ts index fde595b..7415b36 100644 --- a/desktop/src/store/workflowStore.ts +++ b/desktop/src/store/workflowStore.ts @@ -413,14 +413,52 @@ function createWorkflowClientFromKernel(_client: KernelClient): WorkflowClient { return null; } }, - createWorkflow: async () => { - throw new Error('Workflow creation not supported in KernelClient mode. Pipelines are file-based YAML definitions.'); + createWorkflow: async (workflow) => { + try { + const result = await invoke<{ id: string; name: string }>('pipeline_create', { + request: { + name: workflow.name, + description: workflow.description, + steps: workflow.steps.map((s, i) => ({ + handName: s.handName, + name: s.name || `Step ${i + 1}`, + params: s.params, + condition: s.condition, + })), + }, + }); + return result; + } catch { + return null; + } }, - updateWorkflow: async () => { - throw new Error('Workflow update not supported in KernelClient mode. Pipelines are file-based YAML definitions.'); + updateWorkflow: async (id, updates) => { + try { + const result = await invoke<{ id: string; name: string }>('pipeline_update', { + pipelineId: id, + request: { + name: updates.name, + description: updates.description, + steps: updates.steps?.map((s, i) => ({ + handName: s.handName, + name: s.name || `Step ${i + 1}`, + params: s.params, + condition: s.condition, + })), + }, + }); + return result; + } catch { + return null; + } }, - deleteWorkflow: async () => { - throw new Error('Workflow deletion not supported in KernelClient mode. Pipelines are file-based YAML definitions.'); + deleteWorkflow: async (id) => { + try { + await invoke('pipeline_delete', { pipelineId: id }); + return { status: 'deleted' }; + } catch { + return { status: 'error' }; + } }, executeWorkflow: async (id: string, input?: Record) => { try {