From f79560a9112d97b8ac067f438918b98936a3b062 Mon Sep 17 00:00:00 2001 From: iven Date: Tue, 31 Mar 2026 11:12:47 +0800 Subject: [PATCH] refactor(desktop): split kernel_commands/pipeline_commands into modules, add SaaS client libs and gateway modules Split monolithic kernel_commands.rs (2185 lines) and pipeline_commands.rs (1391 lines) into focused sub-modules under kernel_commands/ and pipeline_commands/ directories. Add gateway module (commands, config, io, runtime), health_check, and 15 new TypeScript client libraries for SaaS relay, auth, admin, telemetry, and kernel sub-systems (a2a, agent, chat, hands, skills, triggers). Co-Authored-By: Claude Opus 4.6 --- desktop/src-tauri/src/gateway/commands.rs | 245 ++ desktop/src-tauri/src/gateway/config.rs | 237 ++ desktop/src-tauri/src/gateway/io.rs | 167 ++ desktop/src-tauri/src/gateway/mod.rs | 4 + desktop/src-tauri/src/gateway/runtime.rs | 290 +++ desktop/src-tauri/src/health_check.rs | 296 +++ desktop/src-tauri/src/kernel_commands.rs | 2185 ----------------- desktop/src-tauri/src/kernel_commands/a2a.rs | 114 + .../src-tauri/src/kernel_commands/agent.rs | 257 ++ .../src-tauri/src/kernel_commands/approval.rs | 140 ++ desktop/src-tauri/src/kernel_commands/chat.rs | 274 +++ desktop/src-tauri/src/kernel_commands/hand.rs | 431 ++++ .../src/kernel_commands/lifecycle.rs | 251 ++ desktop/src-tauri/src/kernel_commands/mod.rs | 72 + .../src/kernel_commands/scheduled_task.rs | 124 + .../src-tauri/src/kernel_commands/skill.rs | 350 +++ .../src-tauri/src/kernel_commands/trigger.rs | 242 ++ desktop/src-tauri/src/lib.rs | 1363 +--------- desktop/src-tauri/src/pipeline_commands.rs | 1391 ----------- .../src/pipeline_commands/adapters.rs | 210 ++ .../src-tauri/src/pipeline_commands/crud.rs | 230 ++ .../src/pipeline_commands/discovery.rs | 310 +++ .../src/pipeline_commands/helpers.rs | 167 ++ .../src/pipeline_commands/intent_router.rs | 293 +++ .../src-tauri/src/pipeline_commands/mod.rs | 63 + .../src/pipeline_commands/presentation.rs | 103 + .../src-tauri/src/pipeline_commands/types.rs | 99 + .../src/components/AgentOnboardingWizard.tsx | 3 +- desktop/src/lib/api-key-storage.ts | 21 +- desktop/src/lib/audit-logger.ts | 3 +- desktop/src/lib/autonomy-manager.ts | 19 +- desktop/src/lib/embedding-client.ts | 13 +- desktop/src/lib/encrypted-chat-storage.ts | 7 +- desktop/src/lib/gateway-api.ts | 9 +- desktop/src/lib/gateway-client.ts | 26 +- desktop/src/lib/gateway-errors.ts | 108 + desktop/src/lib/gateway-heartbeat.ts | 117 + desktop/src/lib/gateway-reconnect.ts | 80 + desktop/src/lib/gateway-storage.ts | 21 +- desktop/src/lib/gateway-stream.ts | 288 +++ desktop/src/lib/intelligence-client.ts | 42 +- desktop/src/lib/json-utils.ts | 2 +- desktop/src/lib/kernel-a2a.ts | 59 + desktop/src/lib/kernel-agent.ts | 135 + desktop/src/lib/kernel-chat.ts | 202 ++ desktop/src/lib/kernel-client.ts | 1227 ++------- desktop/src/lib/kernel-hands.ts | 174 ++ desktop/src/lib/kernel-skills.ts | 116 + desktop/src/lib/kernel-triggers.ts | 131 + desktop/src/lib/kernel-types.ts | 138 ++ desktop/src/lib/llm-service.ts | 18 +- desktop/src/lib/memory-extractor.ts | 7 +- desktop/src/lib/request-helper.ts | 3 +- desktop/src/lib/saas-admin.ts | 233 ++ desktop/src/lib/saas-auth.ts | 97 + desktop/src/lib/saas-errors.ts | 16 + desktop/src/lib/saas-prompt.ts | 46 + desktop/src/lib/saas-relay.ts | 131 + desktop/src/lib/saas-session.ts | 153 ++ desktop/src/lib/saas-telemetry.ts | 45 + desktop/src/lib/secure-storage.ts | 35 +- desktop/src/lib/security-audit.ts | 12 +- desktop/src/lib/security-utils.ts | 13 +- desktop/src/lib/skill-discovery.ts | 12 +- desktop/src/lib/useProposalNotifications.ts | 8 +- desktop/src/store/connectionStore.ts | 64 +- docs/analysis-2026-03-31-future-work.md | 187 ++ docs/audit-2026-03-31-system-assessment.md | 258 ++ docs/qa/admin-agent-evaluation-report.md | 256 ++ .../2026-03-30-saas-positioning-design.md | 66 +- start-all.ps1 | 9 +- 71 files changed, 8521 insertions(+), 5997 deletions(-) create mode 100644 desktop/src-tauri/src/gateway/commands.rs create mode 100644 desktop/src-tauri/src/gateway/config.rs create mode 100644 desktop/src-tauri/src/gateway/io.rs create mode 100644 desktop/src-tauri/src/gateway/mod.rs create mode 100644 desktop/src-tauri/src/gateway/runtime.rs create mode 100644 desktop/src-tauri/src/health_check.rs delete mode 100644 desktop/src-tauri/src/kernel_commands.rs create mode 100644 desktop/src-tauri/src/kernel_commands/a2a.rs create mode 100644 desktop/src-tauri/src/kernel_commands/agent.rs create mode 100644 desktop/src-tauri/src/kernel_commands/approval.rs create mode 100644 desktop/src-tauri/src/kernel_commands/chat.rs create mode 100644 desktop/src-tauri/src/kernel_commands/hand.rs create mode 100644 desktop/src-tauri/src/kernel_commands/lifecycle.rs create mode 100644 desktop/src-tauri/src/kernel_commands/mod.rs create mode 100644 desktop/src-tauri/src/kernel_commands/scheduled_task.rs create mode 100644 desktop/src-tauri/src/kernel_commands/skill.rs create mode 100644 desktop/src-tauri/src/kernel_commands/trigger.rs delete mode 100644 desktop/src-tauri/src/pipeline_commands.rs create mode 100644 desktop/src-tauri/src/pipeline_commands/adapters.rs create mode 100644 desktop/src-tauri/src/pipeline_commands/crud.rs create mode 100644 desktop/src-tauri/src/pipeline_commands/discovery.rs create mode 100644 desktop/src-tauri/src/pipeline_commands/helpers.rs create mode 100644 desktop/src-tauri/src/pipeline_commands/intent_router.rs create mode 100644 desktop/src-tauri/src/pipeline_commands/mod.rs create mode 100644 desktop/src-tauri/src/pipeline_commands/presentation.rs create mode 100644 desktop/src-tauri/src/pipeline_commands/types.rs create mode 100644 desktop/src/lib/gateway-errors.ts create mode 100644 desktop/src/lib/gateway-heartbeat.ts create mode 100644 desktop/src/lib/gateway-reconnect.ts create mode 100644 desktop/src/lib/gateway-stream.ts create mode 100644 desktop/src/lib/kernel-a2a.ts create mode 100644 desktop/src/lib/kernel-agent.ts create mode 100644 desktop/src/lib/kernel-chat.ts create mode 100644 desktop/src/lib/kernel-hands.ts create mode 100644 desktop/src/lib/kernel-skills.ts create mode 100644 desktop/src/lib/kernel-triggers.ts create mode 100644 desktop/src/lib/kernel-types.ts create mode 100644 desktop/src/lib/saas-admin.ts create mode 100644 desktop/src/lib/saas-auth.ts create mode 100644 desktop/src/lib/saas-errors.ts create mode 100644 desktop/src/lib/saas-prompt.ts create mode 100644 desktop/src/lib/saas-relay.ts create mode 100644 desktop/src/lib/saas-session.ts create mode 100644 desktop/src/lib/saas-telemetry.ts create mode 100644 docs/analysis-2026-03-31-future-work.md create mode 100644 docs/audit-2026-03-31-system-assessment.md create mode 100644 docs/qa/admin-agent-evaluation-report.md diff --git a/desktop/src-tauri/src/gateway/commands.rs b/desktop/src-tauri/src/gateway/commands.rs new file mode 100644 index 0000000..14f0bd5 --- /dev/null +++ b/desktop/src-tauri/src/gateway/commands.rs @@ -0,0 +1,245 @@ +use serde::Serialize; +use serde_json::{json, Value}; +use std::thread; +use std::time::Duration; +use tauri::AppHandle; + +use super::config::{ + approve_local_device_pairing, ensure_local_gateway_ready_for_tauri, read_local_gateway_auth, + LocalGatewayAuth, LocalGatewayPairingApprovalResult, LocalGatewayPrepareResult, +}; +use super::io::{parse_json_output, read_gateway_status, run_zclaw, LocalGatewayStatus}; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct VersionResponse { + version: String, + commit: Option, + build_date: Option, + runtime_source: Option, + raw: Value, +} + +/// Process information structure +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProcessInfo { + pid: u32, + name: String, + status: String, + cpu_percent: Option, + memory_mb: Option, + uptime_seconds: Option, +} + +/// Process list response +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProcessListResponse { + processes: Vec, + total_count: usize, + runtime_source: Option, +} + +/// Process logs response +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProcessLogsResponse { + pid: Option, + logs: String, + lines: usize, + runtime_source: Option, +} + +/// Get ZCLAW Kernel status +#[tauri::command] +pub fn zclaw_status(app: AppHandle) -> Result { + read_gateway_status(&app) +} + +/// Start ZCLAW Kernel +#[tauri::command] +pub fn zclaw_start(app: AppHandle) -> Result { + ensure_local_gateway_ready_for_tauri(&app)?; + run_zclaw(&app, &["gateway", "start", "--json"])?; + thread::sleep(Duration::from_millis(800)); + read_gateway_status(&app) +} + +/// Stop ZCLAW Kernel +#[tauri::command] +pub fn zclaw_stop(app: AppHandle) -> Result { + run_zclaw(&app, &["gateway", "stop", "--json"])?; + thread::sleep(Duration::from_millis(800)); + read_gateway_status(&app) +} + +/// Restart ZCLAW Kernel +#[tauri::command] +pub fn zclaw_restart(app: AppHandle) -> Result { + ensure_local_gateway_ready_for_tauri(&app)?; + run_zclaw(&app, &["gateway", "restart", "--json"])?; + thread::sleep(Duration::from_millis(1200)); + read_gateway_status(&app) +} + +/// Get local auth token from ZCLAW config +#[tauri::command] +pub fn zclaw_local_auth() -> Result { + read_local_gateway_auth() +} + +/// Prepare ZCLAW for Tauri (update allowed origins) +#[tauri::command] +pub fn zclaw_prepare_for_tauri(app: AppHandle) -> Result { + ensure_local_gateway_ready_for_tauri(&app) +} + +/// Approve device pairing request +#[tauri::command] +pub fn zclaw_approve_device_pairing( + app: AppHandle, + device_id: String, + public_key_base64: String, + url: Option, +) -> Result { + approve_local_device_pairing(&app, &device_id, &public_key_base64, url.as_deref()) +} + +/// Run ZCLAW doctor to diagnose issues +#[tauri::command] +pub fn zclaw_doctor(app: AppHandle) -> Result { + let result = run_zclaw(&app, &["doctor", "--json"])?; + Ok(result.stdout) +} + +/// List ZCLAW processes +#[tauri::command] +pub fn zclaw_process_list(app: AppHandle) -> Result { + let result = run_zclaw(&app, &["process", "list", "--json"])?; + + let raw = parse_json_output(&result.stdout).unwrap_or_else(|_| json!({"processes": []})); + + let processes: Vec = raw + .get("processes") + .and_then(Value::as_array) + .map(|arr| { + arr.iter() + .filter_map(|p| { + Some(ProcessInfo { + pid: p.get("pid").and_then(Value::as_u64)?.try_into().ok()?, + name: p.get("name").and_then(Value::as_str)?.to_string(), + status: p + .get("status") + .and_then(Value::as_str) + .unwrap_or("unknown") + .to_string(), + cpu_percent: p.get("cpuPercent").and_then(Value::as_f64), + memory_mb: p.get("memoryMb").and_then(Value::as_f64), + uptime_seconds: p.get("uptimeSeconds").and_then(Value::as_u64), + }) + }) + .collect() + }) + .unwrap_or_default(); + + Ok(ProcessListResponse { + total_count: processes.len(), + processes, + runtime_source: Some(result.runtime.source), + }) +} + +/// Get ZCLAW process logs +#[tauri::command] +pub fn zclaw_process_logs( + app: AppHandle, + pid: Option, + lines: Option, +) -> Result { + let line_count = lines.unwrap_or(100); + let lines_str = line_count.to_string(); + + // Build owned strings first to avoid lifetime issues + let args: Vec = if let Some(pid_value) = pid { + vec![ + "process".to_string(), + "logs".to_string(), + "--pid".to_string(), + pid_value.to_string(), + "--lines".to_string(), + lines_str, + "--json".to_string(), + ] + } else { + vec![ + "process".to_string(), + "logs".to_string(), + "--lines".to_string(), + lines_str, + "--json".to_string(), + ] + }; + + // Convert to &str for the command + let args_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); + let result = run_zclaw(&app, &args_refs)?; + + // Parse the logs - could be JSON array or plain text + let logs = if let Ok(json) = parse_json_output(&result.stdout) { + // If JSON format, extract logs array or convert to string + if let Some(log_lines) = json.get("logs").and_then(Value::as_array) { + log_lines + .iter() + .filter_map(|l| l.as_str()) + .collect::>() + .join("\n") + } else if let Some(log_text) = json.get("log").and_then(Value::as_str) { + log_text.to_string() + } else { + result.stdout.clone() + } + } else { + result.stdout.clone() + }; + + let log_lines_count = logs.lines().count(); + + Ok(ProcessLogsResponse { + pid, + logs, + lines: log_lines_count, + runtime_source: Some(result.runtime.source), + }) +} + +/// Get ZCLAW version information +#[tauri::command] +pub fn zclaw_version(app: AppHandle) -> Result { + let result = run_zclaw(&app, &["--version", "--json"])?; + + let raw = parse_json_output(&result.stdout).unwrap_or_else(|_| { + // Fallback: try to parse plain text version output + json!({ + "version": result.stdout.trim(), + "raw": result.stdout.trim() + }) + }); + + let version = raw + .get("version") + .and_then(Value::as_str) + .unwrap_or("unknown") + .to_string(); + + let commit = raw.get("commit").and_then(Value::as_str).map(ToOwned::to_owned); + let build_date = raw.get("buildDate").and_then(Value::as_str).map(ToOwned::to_owned); + + Ok(VersionResponse { + version, + commit, + build_date, + runtime_source: Some(result.runtime.source), + raw, + }) +} diff --git a/desktop/src-tauri/src/gateway/config.rs b/desktop/src-tauri/src/gateway/config.rs new file mode 100644 index 0000000..d310521 --- /dev/null +++ b/desktop/src-tauri/src/gateway/config.rs @@ -0,0 +1,237 @@ +use serde::Serialize; +use serde_json::Value; +use std::fs; +use std::thread; +use std::time::Duration; +use tauri::AppHandle; + +use super::io::{read_gateway_status, run_zclaw, parse_json_output}; +use super::runtime::{resolve_zclaw_config_path, TAURI_ALLOWED_ORIGINS}; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LocalGatewayAuth { + pub config_path: Option, + pub gateway_token: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LocalGatewayPrepareResult { + pub config_path: Option, + pub origins_updated: bool, + pub gateway_restarted: bool, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LocalGatewayPairingApprovalResult { + pub approved: bool, + pub request_id: Option, + pub device_id: Option, +} + +/// Parse TOML config and extract gateway token +pub fn read_local_gateway_auth() -> Result { + let config_path = resolve_zclaw_config_path() + .ok_or_else(|| "未找到 ZCLAW 配置目录。".to_string())?; + let config_text = fs::read_to_string(&config_path) + .map_err(|error| format!("读取 ZCLAW 配置失败: {error}"))?; + + // Parse TOML format - simple extraction for gateway.token + let gateway_token = extract_toml_token(&config_text); + + Ok(LocalGatewayAuth { + config_path: Some(config_path.display().to_string()), + gateway_token, + }) +} + +/// Extract gateway.token from TOML config text +fn extract_toml_token(config_text: &str) -> Option { + // Simple TOML parsing for gateway.token + // Format: token = "value" under [gateway] section + let mut in_gateway_section = false; + for line in config_text.lines() { + let trimmed = line.trim(); + if trimmed.starts_with("[gateway") { + in_gateway_section = true; + continue; + } + if trimmed.starts_with('[') && !trimmed.starts_with("[gateway") { + in_gateway_section = false; + continue; + } + if in_gateway_section && trimmed.starts_with("token") { + if let Some(eq_pos) = trimmed.find('=') { + let value = trimmed[eq_pos + 1..].trim(); + // Remove quotes + let value = value.trim_matches('"').trim_matches('\''); + if !value.is_empty() { + return Some(value.to_string()); + } + } + } + } + None +} + +/// Ensure Tauri origins are allowed in ZCLAW config +fn ensure_tauri_allowed_origins(config_text: &str) -> (String, bool) { + let mut lines: Vec = config_text.lines().map(|s| s.to_string()).collect(); + let mut changed = false; + let mut in_control_ui = false; + let mut has_allowed_origins = false; + + // Find or create [gateway.controlUi] section with allowedOrigins + for i in 0..lines.len() { + let trimmed = lines[i].trim(); + + if trimmed.starts_with("[gateway.controlUi") || trimmed == "[gateway.controlUi]" { + in_control_ui = true; + } else if trimmed.starts_with('[') && in_control_ui { + in_control_ui = false; + } + + if in_control_ui && trimmed.starts_with("allowedOrigins") { + has_allowed_origins = true; + // Check if all required origins are present + for origin in TAURI_ALLOWED_ORIGINS { + if !lines[i].contains(origin) { + // Append origin to the array + // This is a simple approach - for production, use proper TOML parsing + if lines[i].ends_with(']') { + let insert_pos = lines[i].len() - 1; + lines[i].insert_str(insert_pos, &format!(", \"{}\"", origin)); + changed = true; + } + } + } + } + } + + // If no allowedOrigins found, add the section + if !has_allowed_origins { + // Find [gateway] section and add controlUi after it + for i in 0..lines.len() { + if lines[i].trim().starts_with("[gateway]") || lines[i].trim() == "[gateway]" { + // Insert controlUi section after gateway + let origins: String = TAURI_ALLOWED_ORIGINS + .iter() + .map(|s| format!("\"{}\"", s)) + .collect::>() + .join(", "); + lines.insert(i + 1, "[gateway.controlUi]".to_string()); + lines.insert(i + 2, format!("allowedOrigins = [{}]", origins)); + changed = true; + break; + } + } + + // If no [gateway] section found, create it + if !changed { + let origins: String = TAURI_ALLOWED_ORIGINS + .iter() + .map(|s| format!("\"{}\"", s)) + .collect::>() + .join(", "); + lines.push("[gateway]".to_string()); + lines.push("[gateway.controlUi]".to_string()); + lines.push(format!("allowedOrigins = [{}]", origins)); + changed = true; + } + } + + (lines.join("\n"), changed) +} + +pub fn ensure_local_gateway_ready_for_tauri(app: &AppHandle) -> Result { + let config_path = resolve_zclaw_config_path() + .ok_or_else(|| "未找到 ZCLAW 配置目录。".to_string())?; + let config_text = fs::read_to_string(&config_path) + .map_err(|error| format!("读取 ZCLAW 配置失败: {error}"))?; + + let (updated_config, origins_updated) = ensure_tauri_allowed_origins(&config_text); + + if origins_updated { + fs::write(&config_path, format!("{}\n", updated_config)) + .map_err(|error| format!("写入 ZCLAW 配置失败: {error}"))?; + } + + let mut gateway_restarted = false; + if origins_updated { + if let Ok(status) = read_gateway_status(app) { + if status.port_status.as_deref() == Some("busy") || !status.listener_pids.is_empty() { + run_zclaw(app, &["gateway", "restart", "--json"])?; + thread::sleep(Duration::from_millis(1200)); + gateway_restarted = true; + } + } + } + + Ok(LocalGatewayPrepareResult { + config_path: Some(config_path.display().to_string()), + origins_updated, + gateway_restarted, + }) +} + +pub fn approve_local_device_pairing( + app: &AppHandle, + device_id: &str, + public_key_base64: &str, + url: Option<&str>, +) -> Result { + let local_auth = read_local_gateway_auth()?; + let gateway_token = local_auth + .gateway_token + .ok_or_else(|| "本地 Gateway token 不可用,无法自动批准设备配对。".to_string())?; + + let devices_output = run_zclaw(app, &["devices", "list", "--json"])?; + let devices_json = parse_json_output(&devices_output.stdout)?; + let pending = devices_json + .get("pending") + .and_then(Value::as_array) + .ok_or_else(|| "设备列表输出缺少 pending 数组。".to_string())?; + + let pending_request = pending.iter().find(|entry| { + entry.get("deviceId").and_then(Value::as_str) == Some(device_id) + && entry.get("publicKey").and_then(Value::as_str) == Some(public_key_base64) + }); + + let Some(request) = pending_request else { + return Ok(LocalGatewayPairingApprovalResult { + approved: false, + request_id: None, + device_id: Some(device_id.to_string()), + }); + }; + + let request_id = request + .get("requestId") + .and_then(Value::as_str) + .ok_or_else(|| "待批准设备缺少 requestId。".to_string())? + .to_string(); + + // Use ZCLAW default port 4200 + let gateway_url = url.unwrap_or("ws://127.0.0.1:4200").to_string(); + let args = vec![ + "devices".to_string(), + "approve".to_string(), + request_id.clone(), + "--json".to_string(), + "--token".to_string(), + gateway_token, + "--url".to_string(), + gateway_url, + ]; + let arg_refs = args.iter().map(|value| value.as_str()).collect::>(); + run_zclaw(app, &arg_refs)?; + thread::sleep(Duration::from_millis(300)); + + Ok(LocalGatewayPairingApprovalResult { + approved: true, + request_id: Some(request_id), + device_id: Some(device_id.to_string()), + }) +} diff --git a/desktop/src-tauri/src/gateway/io.rs b/desktop/src-tauri/src/gateway/io.rs new file mode 100644 index 0000000..7b91a05 --- /dev/null +++ b/desktop/src-tauri/src/gateway/io.rs @@ -0,0 +1,167 @@ +use serde::Serialize; +use serde_json::{json, Value}; +use std::process::Command; +use tauri::AppHandle; + +use super::runtime::{ + command_error, resolve_zclaw_runtime, runtime_path_string, ZclawCommandOutput, ZclawRuntime, + ZCLAW_DEFAULT_PORT, +}; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LocalGatewayStatus { + pub supported: bool, + pub cli_available: bool, + pub runtime_source: Option, + pub runtime_path: Option, + pub service_label: Option, + pub service_loaded: bool, + pub service_status: Option, + pub config_ok: bool, + pub port: Option, + pub port_status: Option, + pub probe_url: Option, + pub listener_pids: Vec, + pub error: Option, + pub raw: Value, +} + +pub fn run_zclaw(app: &AppHandle, args: &[&str]) -> Result { + let runtime = resolve_zclaw_runtime(app); + let mut command = Command::new(&runtime.executable); + command.args(&runtime.pre_args).args(args); + let output = command.output().map_err(|error| command_error(&runtime, error))?; + + if output.status.success() { + Ok(ZclawCommandOutput { + stdout: String::from_utf8_lossy(&output.stdout).trim().to_string(), + runtime, + }) + } else { + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let message = if stderr.is_empty() { + stdout + } else if stdout.is_empty() { + stderr + } else { + format!("{stderr}\n{stdout}") + }; + + if message.is_empty() { + Err(format!("ZCLAW {:?} 执行失败: {}", args, output.status)) + } else { + Err(message) + } + } +} + +pub fn parse_json_output(stdout: &str) -> Result { + if let Ok(raw) = serde_json::from_str::(stdout) { + return Ok(raw); + } + + if let Some(index) = stdout.find('{') { + let trimmed = &stdout[index..]; + return serde_json::from_str::(trimmed) + .map_err(|error| format!("解析 Gateway 状态失败: {error}")); + } + + Err("Gateway 状态输出不包含可解析的 JSON。".to_string()) +} + +pub fn unavailable_status(error: String, runtime: Option<&ZclawRuntime>) -> LocalGatewayStatus { + LocalGatewayStatus { + supported: true, + cli_available: false, + runtime_source: runtime.map(|value| value.source.clone()), + runtime_path: runtime.map(runtime_path_string), + service_label: None, + service_loaded: false, + service_status: None, + config_ok: false, + port: None, + port_status: None, + probe_url: None, + listener_pids: Vec::new(), + error: Some(error), + raw: json!({}), + } +} + +pub fn parse_gateway_status(raw: Value, runtime: &ZclawRuntime) -> LocalGatewayStatus { + let listener_pids = raw + .get("port") + .and_then(|port| port.get("listeners")) + .and_then(Value::as_array) + .map(|listeners| { + listeners + .iter() + .filter_map(|listener| listener.get("pid").and_then(Value::as_u64)) + .filter_map(|pid| u32::try_from(pid).ok()) + .collect::>() + }) + .unwrap_or_default(); + + LocalGatewayStatus { + supported: true, + cli_available: true, + runtime_source: Some(runtime.source.clone()), + runtime_path: Some(runtime_path_string(runtime)), + service_label: raw + .get("service") + .and_then(|service| service.get("label")) + .and_then(Value::as_str) + .map(ToOwned::to_owned), + service_loaded: raw + .get("service") + .and_then(|service| service.get("loaded")) + .and_then(Value::as_bool) + .unwrap_or(false), + service_status: raw + .get("service") + .and_then(|service| service.get("runtime")) + .and_then(|runtime| runtime.get("status")) + .and_then(Value::as_str) + .map(ToOwned::to_owned), + config_ok: raw + .get("service") + .and_then(|service| service.get("configAudit")) + .and_then(|config_audit| config_audit.get("ok")) + .and_then(Value::as_bool) + .unwrap_or(false), + port: raw + .get("gateway") + .and_then(|gateway| gateway.get("port")) + .and_then(Value::as_u64) + .and_then(|port| u16::try_from(port).ok()) + .or(Some(ZCLAW_DEFAULT_PORT)), + port_status: raw + .get("port") + .and_then(|port| port.get("status")) + .and_then(Value::as_str) + .map(ToOwned::to_owned), + probe_url: raw + .get("gateway") + .and_then(|gateway| gateway.get("probeUrl")) + .and_then(Value::as_str) + .map(ToOwned::to_owned), + listener_pids, + error: None, + raw, + } +} + +pub fn read_gateway_status(app: &AppHandle) -> Result { + match run_zclaw(app, &["gateway", "status", "--json", "--no-probe"]) { + Ok(result) => { + let raw = parse_json_output(&result.stdout)?; + Ok(parse_gateway_status(raw, &result.runtime)) + } + Err(error) => { + let runtime = resolve_zclaw_runtime(app); + Ok(unavailable_status(error, Some(&runtime))) + } + } +} diff --git a/desktop/src-tauri/src/gateway/mod.rs b/desktop/src-tauri/src/gateway/mod.rs new file mode 100644 index 0000000..a1afe03 --- /dev/null +++ b/desktop/src-tauri/src/gateway/mod.rs @@ -0,0 +1,4 @@ +pub mod commands; +pub mod config; +pub mod io; +pub mod runtime; diff --git a/desktop/src-tauri/src/gateway/runtime.rs b/desktop/src-tauri/src/gateway/runtime.rs new file mode 100644 index 0000000..e0085c8 --- /dev/null +++ b/desktop/src-tauri/src/gateway/runtime.rs @@ -0,0 +1,290 @@ +use std::path::PathBuf; +use tauri::{AppHandle, Manager}; + +pub(crate) struct ZclawRuntime { + pub source: String, + pub executable: PathBuf, + pub pre_args: Vec, + pub display_path: PathBuf, +} + +pub(crate) struct ZclawCommandOutput { + pub stdout: String, + pub runtime: ZclawRuntime, +} + +/// Default ZCLAW Kernel port +pub const ZCLAW_DEFAULT_PORT: u16 = 4200; + +pub(super) const TAURI_ALLOWED_ORIGINS: [&str; 2] = ["http://tauri.localhost", "tauri://localhost"]; + +pub(super) fn command_error(runtime: &ZclawRuntime, error: std::io::Error) -> String { + if error.kind() == std::io::ErrorKind::NotFound { + match runtime.source.as_str() { + "bundled" => format!( + "未找到 ZCLAW 内置运行时:{}", + runtime.display_path.display() + ), + "development" => format!( + "未找到开发态运行时:{}", + runtime.display_path.display() + ), + "override" => format!( + "未找到 ZCLAW_BIN 指定的运行时:{}", + runtime.display_path.display() + ), + _ => "未找到运行时。请重新安装 ZCLAW,或在开发环境中安装 ZCLAW CLI。" + .to_string(), + } + } else { + format!("运行 ZCLAW 失败: {error}") + } +} + +pub(super) fn runtime_path_string(runtime: &ZclawRuntime) -> String { + runtime.display_path.display().to_string() +} + +fn binary_extension() -> &'static str { + if cfg!(target_os = "windows") { + ".exe" + } else { + "" + } +} + +fn zclaw_sidecar_filename() -> String { + format!("zclaw-{}{}", env!("TARGET"), binary_extension()) +} + +fn zclaw_plain_filename() -> String { + format!("zclaw{}", binary_extension()) +} + +fn push_runtime_candidate(candidates: &mut Vec, source: &str, executable: PathBuf) { + if candidates.iter().any(|candidate| candidate.display_path == executable) { + return; + } + + candidates.push(ZclawRuntime { + source: source.to_string(), + display_path: executable.clone(), + executable, + pre_args: Vec::new(), + }); +} + +/// Build binary runtime (ZCLAW is a single binary, not npm package) +fn build_binary_runtime(source: &str, root_dir: &PathBuf) -> Option { + // Try platform-specific binary names + let binary_names = get_platform_binary_names(); + + for name in binary_names { + let binary_path = root_dir.join(&name); + if binary_path.is_file() { + return Some(ZclawRuntime { + source: source.to_string(), + executable: binary_path.clone(), + pre_args: Vec::new(), + display_path: binary_path, + }); + } + } + None +} + +/// Get platform-specific binary names for ZCLAW +fn get_platform_binary_names() -> Vec { + let mut names = Vec::new(); + + if cfg!(target_os = "windows") { + names.push("zclaw.exe".to_string()); + names.push(format!("zclaw-{}.exe", env!("TARGET"))); + } else if cfg!(target_os = "macos") { + if cfg!(target_arch = "aarch64") { + names.push("zclaw-aarch64-apple-darwin".to_string()); + } else { + names.push("zclaw-x86_64-apple-darwin".to_string()); + } + names.push(format!("zclaw-{}", env!("TARGET"))); + names.push("zclaw".to_string()); + } else { + // Linux + if cfg!(target_arch = "aarch64") { + names.push("zclaw-aarch64-unknown-linux-gnu".to_string()); + } else { + names.push("zclaw-x86_64-unknown-linux-gnu".to_string()); + } + names.push(format!("zclaw-{}", env!("TARGET"))); + names.push("zclaw".to_string()); + } + + names +} + +/// Legacy: Build staged runtime using Node.js (for backward compatibility) +fn build_staged_runtime_legacy(source: &str, root_dir: PathBuf) -> Option { + let node_executable = root_dir.join(if cfg!(target_os = "windows") { + "node.exe" + } else { + "node" + }); + let entrypoint = root_dir + .join("node_modules") + .join("zclaw") + .join("zclaw.mjs"); + + if !node_executable.is_file() || !entrypoint.is_file() { + return None; + } + + Some(ZclawRuntime { + source: source.to_string(), + executable: node_executable, + pre_args: vec![entrypoint.display().to_string()], + display_path: root_dir, + }) +} + +/// Build staged runtime - prefers binary, falls back to Node.js for legacy support +fn build_staged_runtime(source: &str, root_dir: PathBuf) -> Option { + // First, try to find the binary directly + if let Some(runtime) = build_binary_runtime(source, &root_dir) { + return Some(runtime); + } + + // Fallback to Node.js-based runtime for backward compatibility + build_staged_runtime_legacy(source, root_dir) +} + +fn push_staged_runtime_candidate(candidates: &mut Vec, source: &str, root_dir: PathBuf) { + if candidates.iter().any(|candidate| candidate.display_path == root_dir) { + return; + } + + if let Some(runtime) = build_staged_runtime(source, root_dir) { + candidates.push(runtime); + } +} + +fn bundled_runtime_candidates(app: &AppHandle) -> Vec { + let mut candidates = Vec::new(); + let sidecar_name = zclaw_sidecar_filename(); + let plain_name = zclaw_plain_filename(); + let platform_names = get_platform_binary_names(); + + if let Ok(resource_dir) = app.path().resource_dir() { + // Primary: zclaw-runtime directory (contains binary + manifest) + push_staged_runtime_candidate( + &mut candidates, + "bundled", + resource_dir.join("zclaw-runtime"), + ); + + // Alternative: binaries directory + for name in &platform_names { + push_runtime_candidate( + &mut candidates, + "bundled", + resource_dir.join("binaries").join(name), + ); + } + + // Alternative: root level binaries + push_runtime_candidate(&mut candidates, "bundled", resource_dir.join(&plain_name)); + push_runtime_candidate(&mut candidates, "bundled", resource_dir.join(&sidecar_name)); + } + + if let Ok(current_exe) = std::env::current_exe() { + if let Some(exe_dir) = current_exe.parent() { + // Windows NSIS installer location + push_staged_runtime_candidate( + &mut candidates, + "bundled", + exe_dir.join("resources").join("zclaw-runtime"), + ); + + // Alternative: binaries next to exe + for name in &platform_names { + push_runtime_candidate( + &mut candidates, + "bundled", + exe_dir.join("binaries").join(name), + ); + } + + push_runtime_candidate(&mut candidates, "bundled", exe_dir.join(&plain_name)); + push_runtime_candidate(&mut candidates, "bundled", exe_dir.join(&sidecar_name)); + } + } + + // Development mode + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + push_staged_runtime_candidate( + &mut candidates, + "development", + manifest_dir.join("resources").join("zclaw-runtime"), + ); + + for name in &platform_names { + push_runtime_candidate( + &mut candidates, + "development", + manifest_dir.join("binaries").join(name), + ); + } + + candidates +} + +/// Resolve ZCLAW runtime location +/// Priority: ZCLAW_BIN env > bundled > system PATH +pub fn resolve_zclaw_runtime(app: &AppHandle) -> ZclawRuntime { + if let Ok(override_path) = std::env::var("ZCLAW_BIN") { + let override_path = PathBuf::from(override_path); + if override_path.is_dir() { + if let Some(runtime) = build_staged_runtime("override", override_path.clone()) { + return runtime; + } + } + + return ZclawRuntime { + source: "override".to_string(), + display_path: override_path.clone(), + executable: override_path, + pre_args: Vec::new(), + }; + } + + if let Some(runtime) = bundled_runtime_candidates(app) + .into_iter() + .find(|candidate| candidate.executable.is_file()) + { + return runtime; + } + + ZclawRuntime { + source: "system".to_string(), + display_path: PathBuf::from("zclaw"), + executable: PathBuf::from("zclaw"), + pre_args: Vec::new(), + } +} + +/// Resolve ZCLAW config path (TOML format) +/// Priority: ZCLAW_HOME env > ~/.zclaw/ +pub fn resolve_zclaw_config_path() -> Option { + if let Ok(value) = std::env::var("ZCLAW_HOME") { + return Some(PathBuf::from(value).join("zclaw.toml")); + } + + if let Ok(value) = std::env::var("HOME") { + return Some(PathBuf::from(value).join(".zclaw").join("zclaw.toml")); + } + + if let Ok(value) = std::env::var("USERPROFILE") { + return Some(PathBuf::from(value).join(".zclaw").join("zclaw.toml")); + } + + None +} diff --git a/desktop/src-tauri/src/health_check.rs b/desktop/src-tauri/src/health_check.rs new file mode 100644 index 0000000..ded29ad --- /dev/null +++ b/desktop/src-tauri/src/health_check.rs @@ -0,0 +1,296 @@ +use serde::Serialize; +use serde_json::Value; +use std::net::{TcpStream, ToSocketAddrs}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use tauri::AppHandle; + +use crate::gateway::io::{parse_json_output, read_gateway_status, run_zclaw, LocalGatewayStatus}; +use crate::gateway::runtime::{resolve_zclaw_runtime, ZCLAW_DEFAULT_PORT}; + +/// Health status enum +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum HealthStatus { + Healthy, + Unhealthy, +} + +/// Port check result +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PortCheckResult { + port: u16, + accessible: bool, + latency_ms: Option, + error: Option, +} + +/// Process health details +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProcessHealthDetails { + pid: Option, + name: Option, + status: Option, + uptime_seconds: Option, + cpu_percent: Option, + memory_mb: Option, +} + +/// Health check response +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct HealthCheckResponse { + status: HealthStatus, + process: ProcessHealthDetails, + port_check: PortCheckResult, + last_check_timestamp: u64, + checks_performed: Vec, + issues: Vec, + runtime_source: Option, +} + +/// Check if a TCP port is accessible +fn check_port_accessibility(host: &str, port: u16, timeout_ms: u64) -> PortCheckResult { + let addr = format!("{}:{}", host, port); + + // Resolve the address + let socket_addr = match addr.to_socket_addrs() { + Ok(mut addrs) => addrs.next(), + Err(e) => { + return PortCheckResult { + port, + accessible: false, + latency_ms: None, + error: Some(format!("Failed to resolve address: {}", e)), + }; + } + }; + + let Some(socket_addr) = socket_addr else { + return PortCheckResult { + port, + accessible: false, + latency_ms: None, + error: Some("Failed to resolve address".to_string()), + }; + }; + + // Try to connect with timeout + let start = Instant::now(); + + // Use a simple TCP connect with timeout simulation + let result = TcpStream::connect_timeout(&socket_addr, Duration::from_millis(timeout_ms)); + + match result { + Ok(_) => { + let latency = start.elapsed().as_millis() as u64; + PortCheckResult { + port, + accessible: true, + latency_ms: Some(latency), + error: None, + } + } + Err(e) => PortCheckResult { + port, + accessible: false, + latency_ms: None, + error: Some(format!("Connection failed: {}", e)), + }, + } +} + +/// Get process uptime from status command +fn get_process_uptime(status: &LocalGatewayStatus) -> Option { + // Try to extract uptime from raw status data + status + .raw + .get("process") + .and_then(|p| p.get("uptimeSeconds")) + .and_then(Value::as_u64) +} + +/// Perform comprehensive health check on ZCLAW Kernel +#[tauri::command] +pub fn zclaw_health_check( + app: AppHandle, + port: Option, + timeout_ms: Option, +) -> Result { + let check_port = port.unwrap_or(ZCLAW_DEFAULT_PORT); + let timeout = timeout_ms.unwrap_or(3000); + let mut checks_performed = Vec::new(); + let mut issues = Vec::new(); + + // Get current timestamp + let last_check_timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + // 1. Check if ZCLAW CLI is available + let runtime = resolve_zclaw_runtime(&app); + let cli_available = runtime.executable.is_file(); + + if !cli_available { + return Ok(HealthCheckResponse { + status: HealthStatus::Unhealthy, + process: ProcessHealthDetails { + pid: None, + name: None, + status: None, + uptime_seconds: None, + cpu_percent: None, + memory_mb: None, + }, + port_check: PortCheckResult { + port: check_port, + accessible: false, + latency_ms: None, + error: Some("ZCLAW CLI not available".to_string()), + }, + last_check_timestamp, + checks_performed: vec!["cli_availability".to_string()], + issues: vec![format!( + "ZCLAW runtime not found at: {}", + runtime.display_path.display() + )], + runtime_source: Some(runtime.source), + }); + } + checks_performed.push("cli_availability".to_string()); + + // 2. Get gateway status + let gateway_status = read_gateway_status(&app)?; + checks_performed.push("gateway_status".to_string()); + + // Check for configuration issues + if !gateway_status.config_ok { + issues.push("Gateway configuration has issues".to_string()); + } + + // 3. Check port accessibility + let port_check = check_port_accessibility("127.0.0.1", check_port, timeout); + checks_performed.push("port_accessibility".to_string()); + + if !port_check.accessible { + issues.push(format!( + "Port {} is not accessible: {}", + check_port, + port_check.error.as_deref().unwrap_or("unknown error") + )); + } + + // 4. Extract process information + let process_health = if !gateway_status.listener_pids.is_empty() { + // Get the first listener PID + let pid = gateway_status.listener_pids[0]; + + // Try to get detailed process info from process list + let process_info = run_zclaw(&app, &["process", "list", "--json"]) + .ok() + .and_then(|result| parse_json_output(&result.stdout).ok()) + .and_then(|json| json.get("processes").and_then(Value::as_array).cloned()); + + let (cpu, memory, uptime) = if let Some(ref processes) = process_info { + let matching = processes + .iter() + .find(|p| p.get("pid").and_then(Value::as_u64) == Some(pid as u64)); + + matching.map_or((None, None, None), |p| { + ( + p.get("cpuPercent").and_then(Value::as_f64), + p.get("memoryMb").and_then(Value::as_f64), + p.get("uptimeSeconds").and_then(Value::as_u64), + ) + }) + } else { + (None, None, get_process_uptime(&gateway_status)) + }; + + ProcessHealthDetails { + pid: Some(pid), + name: Some("zclaw".to_string()), + status: Some( + gateway_status + .service_status + .clone() + .unwrap_or_else(|| "running".to_string()), + ), + uptime_seconds: uptime, + cpu_percent: cpu, + memory_mb: memory, + } + } else { + ProcessHealthDetails { + pid: None, + name: None, + status: gateway_status.service_status.clone(), + uptime_seconds: None, + cpu_percent: None, + memory_mb: None, + } + }; + + // Check if process is running but no listeners + if gateway_status.service_status.as_deref() == Some("running") + && gateway_status.listener_pids.is_empty() + { + issues.push("Service reports running but no listener processes found".to_string()); + } + + // 5. Determine overall health status + let status = if !cli_available { + HealthStatus::Unhealthy + } else if !port_check.accessible { + HealthStatus::Unhealthy + } else if gateway_status.listener_pids.is_empty() { + HealthStatus::Unhealthy + } else if !issues.is_empty() { + // Has some issues but core functionality is working + HealthStatus::Healthy + } else { + HealthStatus::Healthy + }; + + Ok(HealthCheckResponse { + status, + process: process_health, + port_check, + last_check_timestamp, + checks_performed, + issues, + runtime_source: Some(runtime.source), + }) +} + +/// Quick ping to check if ZCLAW is alive (lightweight check) +#[tauri::command] +pub fn zclaw_ping(app: AppHandle) -> Result { + let port_check = check_port_accessibility("127.0.0.1", ZCLAW_DEFAULT_PORT, 1000); + + if port_check.accessible { + return Ok(true); + } + + // Fallback: check via status command + match run_zclaw(&app, &["gateway", "status", "--json", "--no-probe"]) { + Ok(result) => { + if let Ok(status) = parse_json_output(&result.stdout) { + // Check if there are any listener PIDs + let has_listeners = status + .get("port") + .and_then(|p| p.get("listeners")) + .and_then(Value::as_array) + .map(|arr| !arr.is_empty()) + .unwrap_or(false); + + Ok(has_listeners) + } else { + Ok(false) + } + } + Err(_) => Ok(false), + } +} diff --git a/desktop/src-tauri/src/kernel_commands.rs b/desktop/src-tauri/src/kernel_commands.rs deleted file mode 100644 index 90ee86c..0000000 --- a/desktop/src-tauri/src/kernel_commands.rs +++ /dev/null @@ -1,2185 +0,0 @@ -//! ZCLAW Kernel commands for Tauri -//! -//! These commands provide direct access to the internal ZCLAW Kernel, -//! eliminating the need for external ZCLAW process. - -use std::path::PathBuf; -use std::sync::Arc; -use tauri::{AppHandle, Emitter, State}; -use serde::{Deserialize, Serialize}; -use tokio::sync::Mutex; -use zclaw_kernel::Kernel; -use zclaw_types::{AgentConfig, AgentId, AgentInfo, SkillId}; - -use crate::intelligence::validation::{validate_identifier, validate_string_length}; - -/// Kernel state wrapper for Tauri -pub type KernelState = Arc>>; - -/// Scheduler state — holds a reference to the SchedulerService so it can be stopped on shutdown -pub type SchedulerState = Arc>>; - -/// Session-level stream concurrency guard. -/// Prevents two concurrent `agent_chat_stream` calls from interleaving events -/// for the same session_id. -pub type SessionStreamGuard = Arc>>>; - -/// Validate an agent ID string with clear error messages -fn validate_agent_id(agent_id: &str) -> Result { - validate_identifier(agent_id, "agent_id") - .map_err(|e| format!("Invalid agent_id: {}", e))?; - // AgentId is a UUID wrapper — validate UUID format for better error messages - if agent_id.contains('-') { - crate::intelligence::validation::validate_uuid(agent_id, "agent_id") - .map_err(|e| format!("Invalid agent_id: {}", e))?; - } - Ok(agent_id.to_string()) -} - -/// Validate a generic ID string (for skills, hands, triggers, etc.) -fn validate_id(id: &str, field_name: &str) -> Result { - validate_identifier(id, field_name) - .map_err(|e| format!("Invalid {}: {}", field_name, e))?; - Ok(id.to_string()) -} - -/// Agent creation request -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CreateAgentRequest { - /// Agent name - pub name: String, - /// Agent description - #[serde(default)] - pub description: Option, - /// System prompt - #[serde(default)] - pub system_prompt: Option, - /// Model provider - #[serde(default = "default_provider")] - pub provider: String, - /// Model identifier - #[serde(default = "default_model")] - pub model: String, - /// Max tokens - #[serde(default = "default_max_tokens")] - pub max_tokens: u32, - /// Temperature - #[serde(default = "default_temperature")] - pub temperature: f32, - /// Workspace directory for file access tools - #[serde(default)] - pub workspace: Option, -} - -fn default_provider() -> String { "openai".to_string() } -fn default_model() -> String { "gpt-4o-mini".to_string() } -fn default_max_tokens() -> u32 { 4096 } -fn default_temperature() -> f32 { 0.7 } - -/// Agent creation response -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CreateAgentResponse { - pub id: String, - pub name: String, - pub state: String, -} - -/// Chat request -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ChatRequest { - /// Agent ID - pub agent_id: String, - /// Message content - pub message: String, -} - -/// Chat response -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ChatResponse { - pub content: String, - pub input_tokens: u32, - pub output_tokens: u32, -} - -/// Kernel status response -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct KernelStatusResponse { - pub initialized: bool, - pub agent_count: usize, - pub database_url: Option, - pub base_url: Option, - pub model: Option, -} - -/// Kernel configuration request -/// -/// Simple configuration: base_url + api_key + model -/// Model ID is passed directly to the API without any transformation -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct KernelConfigRequest { - /// LLM provider (for preset URLs): anthropic, openai, zhipu, kimi, qwen, deepseek, local, custom - #[serde(default = "default_kernel_provider")] - pub provider: String, - /// Model identifier - passed directly to the API - #[serde(default = "default_kernel_model")] - pub model: String, - /// API key - pub api_key: Option, - /// Base URL (optional, uses provider default if not specified) - pub base_url: Option, - /// API protocol: openai or anthropic - #[serde(default = "default_api_protocol")] - pub api_protocol: String, -} - -fn default_api_protocol() -> String { "openai".to_string() } -fn default_kernel_provider() -> String { "openai".to_string() } -fn default_kernel_model() -> String { "gpt-4o-mini".to_string() } - -/// Initialize the internal ZCLAW Kernel -/// -/// If kernel already exists with the same config, returns existing status. -/// If config changed, reboots kernel with new config. -#[tauri::command] -pub async fn kernel_init( - state: State<'_, KernelState>, - scheduler_state: State<'_, SchedulerState>, - config_request: Option, -) -> Result { - let mut kernel_lock = state.lock().await; - - // Check if we need to reboot kernel with new config - if let Some(kernel) = kernel_lock.as_ref() { - // Get current config from kernel - let current_config = kernel.config(); - - // Check if config changed - let config_changed = if let Some(ref req) = config_request { - let default_base_url = zclaw_kernel::config::KernelConfig::from_provider( - &req.provider, "", &req.model, None, &req.api_protocol - ).llm.base_url; - let request_base_url = req.base_url.clone().unwrap_or(default_base_url.clone()); - - current_config.llm.model != req.model || - current_config.llm.base_url != request_base_url - } else { - false - }; - - if !config_changed { - // Same config, return existing status - return Ok(KernelStatusResponse { - initialized: true, - agent_count: kernel.list_agents().len(), - database_url: None, - base_url: Some(current_config.llm.base_url.clone()), - model: Some(current_config.llm.model.clone()), - }); - } - - // Config changed, need to reboot kernel - // Shutdown old kernel - if let Err(e) = kernel.shutdown().await { - eprintln!("[kernel_init] Warning: Failed to shutdown old kernel: {}", e); - } - *kernel_lock = None; - } - - // Build configuration from request - let config = if let Some(req) = &config_request { - let api_key = req.api_key.as_deref().unwrap_or(""); - let base_url = req.base_url.as_deref(); - - zclaw_kernel::config::KernelConfig::from_provider( - &req.provider, - api_key, - &req.model, - base_url, - &req.api_protocol, - ) - } else { - zclaw_kernel::config::KernelConfig::default() - }; - - // Debug: print skills directory - if let Some(ref skills_dir) = config.skills_dir { - println!("[kernel_init] Skills directory: {} (exists: {})", skills_dir.display(), skills_dir.exists()); - } else { - println!("[kernel_init] No skills directory configured"); - } - - let base_url = config.llm.base_url.clone(); - let model = config.llm.model.clone(); - - // Boot kernel - let mut kernel = Kernel::boot(config.clone()) - .await - .map_err(|e| format!("Failed to initialize kernel: {}", e))?; - - let agent_count = kernel.list_agents().len(); - - // Configure extraction driver so the Growth system can call LLM for memory extraction - let driver = kernel.driver(); - crate::intelligence::extraction_adapter::configure_extraction_driver( - driver.clone(), - model.clone(), - ); - - // Bridge SqliteStorage to Kernel's GrowthIntegration - // This connects the middleware chain (MemoryMiddleware, CompactionMiddleware) - // to the same persistent SqliteStorage used by viking_commands and intelligence_hooks. - { - match crate::viking_commands::get_storage().await { - Ok(sqlite_storage) => { - // Wrap SqliteStorage in VikingAdapter (SqliteStorage implements VikingStorage) - let viking = std::sync::Arc::new(zclaw_runtime::VikingAdapter::new(sqlite_storage)); - kernel.set_viking(viking); - tracing::info!("[kernel_init] Bridged persistent SqliteStorage to Kernel GrowthIntegration"); - } - Err(e) => { - tracing::warn!( - "[kernel_init] Failed to get SqliteStorage, GrowthIntegration will use in-memory storage: {}", - e - ); - } - } - - // Set the LLM extraction driver on the kernel for memory extraction via middleware - let extraction_driver = crate::intelligence::extraction_adapter::TauriExtractionDriver::new( - driver.clone(), - model.clone(), - ); - kernel.set_extraction_driver(std::sync::Arc::new(extraction_driver)); - } - - // Configure summary driver so the Growth system can generate L0/L1 summaries - if let Some(api_key) = config_request.as_ref().and_then(|r| r.api_key.clone()) { - crate::summarizer_adapter::configure_summary_driver( - crate::summarizer_adapter::TauriSummaryDriver::new( - format!("{}/chat/completions", base_url), - api_key, - Some(model.clone()), - ), - ); - } - - *kernel_lock = Some(kernel); - - // Start SchedulerService — periodically checks and fires scheduled triggers - { - let mut sched_lock = scheduler_state.lock().await; - // Stop old scheduler if any - if let Some(ref old) = *sched_lock { - old.stop(); - } - let scheduler = zclaw_kernel::scheduler::SchedulerService::new( - state.inner().clone(), - 60, // check every 60 seconds - ); - scheduler.start(); - tracing::info!("[kernel_init] SchedulerService started (60s interval)"); - *sched_lock = Some(scheduler); - } - - Ok(KernelStatusResponse { - initialized: true, - agent_count, - database_url: Some(config.database_url), - base_url: Some(base_url), - model: Some(model), - }) -} - -/// Get kernel status -#[tauri::command] -pub async fn kernel_status( - state: State<'_, KernelState>, -) -> Result { - let kernel_lock = state.lock().await; - - match kernel_lock.as_ref() { - Some(kernel) => Ok(KernelStatusResponse { - initialized: true, - agent_count: kernel.list_agents().len(), - database_url: Some(kernel.config().database_url.clone()), - base_url: Some(kernel.config().llm.base_url.clone()), - model: Some(kernel.config().llm.model.clone()), - }), - None => Ok(KernelStatusResponse { - initialized: false, - agent_count: 0, - database_url: None, - base_url: None, - model: None, - }), - } -} - -/// Shutdown the kernel -#[tauri::command] -pub async fn kernel_shutdown( - state: State<'_, KernelState>, - scheduler_state: State<'_, SchedulerState>, -) -> Result<(), String> { - // Stop scheduler first - { - let mut sched_lock = scheduler_state.lock().await; - if let Some(scheduler) = sched_lock.take() { - scheduler.stop(); - tracing::info!("[kernel_shutdown] SchedulerService stopped"); - } - } - - let mut kernel_lock = state.lock().await; - - if let Some(kernel) = kernel_lock.take() { - kernel.shutdown().await.map_err(|e| e.to_string())?; - } - - Ok(()) -} - -/// Create a new agent -#[tauri::command] -pub async fn agent_create( - state: State<'_, KernelState>, - request: CreateAgentRequest, -) -> Result { - let kernel_lock = state.lock().await; - - let kernel = kernel_lock.as_ref() - .ok_or_else(|| "Kernel not initialized. Call kernel_init first.".to_string())?; - - // Build agent config - let mut config = AgentConfig::new(&request.name) - .with_description(request.description.unwrap_or_default()) - .with_system_prompt(request.system_prompt.unwrap_or_default()) - .with_model(zclaw_types::ModelConfig { - provider: request.provider, - model: request.model, - api_key_env: None, - base_url: None, - }) - .with_max_tokens(request.max_tokens) - .with_temperature(request.temperature); - - // Set workspace if provided - if let Some(workspace) = request.workspace { - config.workspace = Some(workspace); - } - - let id = kernel.spawn_agent(config) - .await - .map_err(|e| format!("Failed to create agent: {}", e))?; - - Ok(CreateAgentResponse { - id: id.to_string(), - name: request.name, - state: "running".to_string(), - }) -} - -/// List all agents -#[tauri::command] -pub async fn agent_list( - state: State<'_, KernelState>, -) -> Result, 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())?; - - Ok(kernel.list_agents()) -} - -/// Get agent info -#[tauri::command] -pub async fn agent_get( - state: State<'_, KernelState>, - agent_id: String, -) -> Result, String> { - // Validate input - 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())?; - - Ok(kernel.get_agent(&id)) -} - -/// Delete an agent -#[tauri::command] -pub async fn agent_delete( - state: State<'_, KernelState>, - agent_id: String, -) -> Result<(), String> { - // Validate input - 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())?; - - kernel.kill_agent(&id) - .await - .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( - state: State<'_, KernelState>, - agent_id: String, -) -> 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())?; - - let config = kernel.get_agent_config(&id) - .ok_or_else(|| format!("Agent not found: {}", agent_id))?; - - serde_json::to_string_pretty(&config) - .map_err(|e| format!("Failed to serialize agent config: {}", e)) -} - -/// Import an agent from JSON configuration -#[tauri::command] -pub async fn agent_import( - state: State<'_, KernelState>, - config_json: String, -) -> Result { - validate_string_length(&config_json, "config_json", 1_000_000) - .map_err(|e| format!("{}", e))?; - - let mut config: AgentConfig = serde_json::from_str(&config_json) - .map_err(|e| format!("Invalid agent config JSON: {}", e))?; - - // Regenerate ID to avoid collisions - config.id = AgentId::new(); - - 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 new_id = kernel.spawn_agent(config).await - .map_err(|e| format!("Failed to import agent: {}", e))?; - - kernel.get_agent(&new_id) - .ok_or_else(|| "Agent was created but could not be retrieved".to_string()) -} - -/// Send a message to an agent -#[tauri::command] -pub async fn agent_chat( - state: State<'_, KernelState>, - request: ChatRequest, -) -> Result { - // Validate inputs - validate_agent_id(&request.agent_id)?; - validate_string_length(&request.message, "message", 100000) - .map_err(|e| format!("Invalid message: {}", e))?; - - 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 = request.agent_id.parse() - .map_err(|_| "Invalid agent ID format".to_string())?; - - let response = kernel.send_message(&id, request.message) - .await - .map_err(|e| format!("Chat failed: {}", e))?; - - Ok(ChatResponse { - content: response.content, - input_tokens: response.input_tokens, - output_tokens: response.output_tokens, - }) -} - -/// Streaming chat event for Tauri emission -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", tag = "type")] -pub enum StreamChatEvent { - /// Text delta received - Delta { delta: String }, - /// Tool use started - ToolStart { name: String, input: serde_json::Value }, - /// Tool use completed - 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 - Error { message: String }, -} - -/// Streaming chat request -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct StreamChatRequest { - /// Agent ID - pub agent_id: String, - /// Session ID (for event routing) - pub session_id: String, - /// Message content - pub message: String, -} - -/// Send a message to an agent with streaming response -/// -/// This command initiates a streaming chat session. Events are emitted -/// via Tauri's event system with the name "stream:chunk" and include -/// the session_id for routing. -#[tauri::command] -pub async fn agent_chat_stream( - app: AppHandle, - state: State<'_, KernelState>, - identity_state: State<'_, crate::intelligence::IdentityManagerState>, - heartbeat_state: State<'_, crate::intelligence::HeartbeatEngineState>, - reflection_state: State<'_, crate::intelligence::ReflectionEngineState>, - stream_guard: State<'_, SessionStreamGuard>, - request: StreamChatRequest, -) -> Result<(), String> { - // Validate inputs - validate_agent_id(&request.agent_id)?; - validate_string_length(&request.message, "message", 100000) - .map_err(|e| format!("Invalid message: {}", e))?; - - // Parse agent ID first - let id: AgentId = request.agent_id.parse() - .map_err(|_| "Invalid agent ID format".to_string())?; - - let session_id = request.session_id.clone(); - let agent_id_str = request.agent_id.clone(); - let message = request.message.clone(); - - // Session-level concurrency guard: - // Prevents two concurrent streams from interleaving events for the same session. - // Uses try_lock to fail fast instead of queueing — the frontend should not - // send a second message while the first stream is still active. - let session_mutex = stream_guard - .entry(session_id.clone()) - .or_insert_with(|| Arc::new(Mutex::new(()))); - let _session_guard = session_mutex.try_lock() - .map_err(|_| { - tracing::warn!( - "[agent_chat_stream] Session {} already has an active stream — rejecting", - session_id - ); - format!("Session {} already has an active stream", session_id) - })?; - - // AUTO-INIT HEARTBEAT: Ensure heartbeat engine exists for this agent. - // Uses default config (enabled: true, 30min interval) so heartbeat runs - // automatically from the first conversation without manual setup. - { - let mut engines = heartbeat_state.lock().await; - if !engines.contains_key(&request.agent_id) { - let engine = crate::intelligence::heartbeat::HeartbeatEngine::new( - request.agent_id.clone(), - None, // Use default config (enabled: true) - ); - engines.insert(request.agent_id.clone(), engine); - tracing::info!("[agent_chat_stream] Auto-initialized heartbeat for agent: {}", request.agent_id); - } - } - - // PRE-CONVERSATION: Build intelligence-enhanced system prompt - let enhanced_prompt = crate::intelligence_hooks::pre_conversation_hook( - &request.agent_id, - &request.message, - &identity_state, - ).await.unwrap_or_default(); - - // Get the streaming receiver while holding the lock, then release it - let (mut rx, llm_driver) = { - let kernel_lock = state.lock().await; - let kernel = kernel_lock.as_ref() - .ok_or_else(|| "Kernel not initialized. Call kernel_init first.".to_string())?; - - // Clone LLM driver for reflection engine (Arc clone is cheap) - let driver = Some(kernel.driver()); - - // Start the stream - this spawns a background task - // Use intelligence-enhanced system prompt if available - let prompt_arg = if enhanced_prompt.is_empty() { None } else { Some(enhanced_prompt) }; - // Parse session_id for session reuse (carry conversation history across turns) - // Empty session_id means first message in a new conversation — that's valid. - // Non-empty session_id MUST be a valid UUID; if not, return error instead of - // silently losing context by creating a new session. - let session_id_parsed = if session_id.is_empty() { - None - } else { - match uuid::Uuid::parse_str(&session_id) { - Ok(uuid) => Some(zclaw_types::SessionId::from_uuid(uuid)), - Err(e) => { - return Err(format!( - "Invalid session_id '{}': {}. Cannot reuse conversation context.", - session_id, e - )); - } - } - }; - let rx = kernel.send_message_stream_with_prompt(&id, message.clone(), prompt_arg, session_id_parsed) - .await - .map_err(|e| format!("Failed to start streaming: {}", e))?; - (rx, driver) - }; - // Lock is released here - - // Clone Arc references before spawning (State<'_, T> borrows can't enter the spawn) - let hb_state = heartbeat_state.inner().clone(); - let rf_state = reflection_state.inner().clone(); - - // Spawn a task to process stream events with timeout guard - tokio::spawn(async move { - use zclaw_runtime::LoopEvent; - - tracing::debug!("[agent_chat_stream] Starting stream processing for session: {}", session_id); - - // Stream idle timeout: if no event arrives in 5 minutes, terminate. - // This prevents orphaned streams from consuming resources indefinitely. - let stream_timeout = tokio::time::Duration::from_secs(300); - - loop { - match tokio::time::timeout(stream_timeout, rx.recv()).await { - Ok(Some(event)) => { - let stream_event = match &event { - LoopEvent::Delta(delta) => { - tracing::trace!("[agent_chat_stream] Delta: {} bytes", delta.len()); - StreamChatEvent::Delta { delta: delta.clone() } - } - LoopEvent::ToolStart { name, input } => { - tracing::debug!("[agent_chat_stream] ToolStart: {}", name); - // 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); - // 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); - StreamChatEvent::IterationStart { iteration: *iteration, max_iterations: *max_iterations } - } - LoopEvent::Complete(result) => { - tracing::info!("[agent_chat_stream] Complete: input_tokens={}, output_tokens={}", - result.input_tokens, result.output_tokens); - - // POST-CONVERSATION: record interaction + trigger reflection - // Hook failure is non-fatal — internal errors are logged by the hook itself - let agent_id_hook = agent_id_str.clone(); - let message_hook = message.clone(); - let hb = hb_state.clone(); - let rf = rf_state.clone(); - let driver = llm_driver.clone(); - tokio::spawn(async move { - crate::intelligence_hooks::post_conversation_hook( - &agent_id_hook, &message_hook, &hb, &rf, driver, - ).await; - }); - - StreamChatEvent::Complete { - input_tokens: result.input_tokens, - output_tokens: result.output_tokens, - } - } - LoopEvent::Error(message) => { - tracing::warn!("[agent_chat_stream] Error: {}", message); - StreamChatEvent::Error { message: message.clone() } - } - }; - - // Emit the event with session_id for routing - if let Err(e) = app.emit("stream:chunk", serde_json::json!({ - "sessionId": session_id, - "event": stream_event - })) { - tracing::warn!("[agent_chat_stream] Failed to emit event: {}", e); - break; // Frontend likely disconnected - } - - // After Complete or Error, the stream is done - if matches!(event, LoopEvent::Complete(_) | LoopEvent::Error(_)) { - break; - } - } - Ok(None) => { - // Channel closed — stream producer dropped - tracing::info!("[agent_chat_stream] Stream channel closed for session: {}", session_id); - break; - } - Err(_) => { - // Timeout: no event received in 5 minutes - tracing::warn!("[agent_chat_stream] Stream idle timeout for session: {}", session_id); - let _ = app.emit("stream:chunk", serde_json::json!({ - "sessionId": session_id, - "event": StreamChatEvent::Error { - message: "流式响应超时,请重试".to_string() - } - })); - break; - } - } - } - - tracing::debug!("[agent_chat_stream] Stream processing ended for session: {}", session_id); - }); - - Ok(()) -} - -/// Create the kernel state for Tauri -pub fn create_kernel_state() -> KernelState { - Arc::new(Mutex::new(None)) -} - -/// Create the scheduler state for Tauri -pub fn create_scheduler_state() -> SchedulerState { - Arc::new(Mutex::new(None)) -} - -// ============================================================================ -// Skills Commands - Dynamic Discovery -// ============================================================================ - -/// Skill information response for frontend -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SkillInfoResponse { - pub id: String, - pub name: String, - pub description: String, - pub version: String, - pub capabilities: Vec, - pub tags: Vec, - pub mode: String, - pub enabled: bool, - pub triggers: Vec, - pub category: Option, -} - -impl From for SkillInfoResponse { - fn from(manifest: zclaw_skills::SkillManifest) -> Self { - Self { - id: manifest.id.to_string(), - name: manifest.name, - description: manifest.description, - version: manifest.version, - capabilities: manifest.capabilities, - tags: manifest.tags, - mode: format!("{:?}", manifest.mode), - enabled: manifest.enabled, - triggers: manifest.triggers, - category: manifest.category, - } - } -} - -/// List all discovered skills -/// -/// Returns skills from the Kernel's SkillRegistry. -/// Skills are loaded from the skills/ directory during kernel initialization. -#[tauri::command] -pub async fn skill_list( - state: State<'_, KernelState>, -) -> Result, 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 skills = kernel.list_skills().await; - println!("[skill_list] Found {} skills", skills.len()); - for skill in &skills { - println!("[skill_list] - {} ({})", skill.name, skill.id); - } - Ok(skills.into_iter().map(SkillInfoResponse::from).collect()) -} - -/// Refresh skills from a directory -/// -/// Re-scans the skills directory for new or updated skills. -/// Optionally accepts a custom directory path to scan. -#[tauri::command] -pub async fn skill_refresh( - state: State<'_, KernelState>, - skill_dir: Option, -) -> Result, 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())?; - - // Convert optional string to PathBuf - let dir_path = skill_dir.map(PathBuf::from); - - // Refresh skills - kernel.refresh_skills(dir_path) - .await - .map_err(|e| format!("Failed to refresh skills: {}", e))?; - - // Return updated list - let skills = kernel.list_skills().await; - 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 -// ============================================================================ - -/// Skill execution context -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SkillContext { - pub agent_id: String, - pub session_id: String, - pub working_dir: Option, -} - -impl From for zclaw_skills::SkillContext { - fn from(ctx: SkillContext) -> Self { - Self { - agent_id: ctx.agent_id, - session_id: ctx.session_id, - working_dir: ctx.working_dir.map(std::path::PathBuf::from), - env: std::collections::HashMap::new(), - timeout_secs: 300, - network_allowed: true, - file_access_allowed: true, - llm: None, // Injected by Kernel.execute_skill() - } - } -} - -/// Skill execution result -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SkillResult { - pub success: bool, - pub output: serde_json::Value, - pub error: Option, - pub duration_ms: Option, -} - -impl From for SkillResult { - fn from(result: zclaw_skills::SkillResult) -> Self { - Self { - success: result.success, - output: result.output, - error: result.error, - duration_ms: result.duration_ms, - } - } -} - -/// Execute a skill -/// -/// Executes a skill with the given ID and input. -/// Returns the skill result as JSON. -#[tauri::command] -pub async fn skill_execute( - state: State<'_, KernelState>, - id: String, - context: SkillContext, - input: serde_json::Value, - autonomy_level: Option, -) -> Result { - // Validate skill ID - let id = validate_id(&id, "skill_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())?; - - // Autonomy guard: supervised mode creates an approval request for ALL skills - if autonomy_level.as_deref() == Some("supervised") { - let approval = kernel.create_approval(id.clone(), input).await; - return Ok(SkillResult { - success: false, - output: serde_json::json!({ - "status": "pending_approval", - "approval_id": approval.id, - "skill_id": approval.hand_id, - "message": "监督模式下所有技能执行需要用户审批" - }), - error: None, - duration_ms: None, - }); - } - - // Assisted mode: require approval for non-prompt skills (shell/python) that have side effects - if autonomy_level.as_deref() != Some("autonomous") { - let skill_id = SkillId::new(&id); - if let Some(manifest) = kernel.skills().get_manifest(&skill_id).await { - match manifest.mode { - zclaw_skills::SkillMode::Shell | zclaw_skills::SkillMode::Python => { - let approval = kernel.create_approval(id.clone(), input).await; - return Ok(SkillResult { - success: false, - output: serde_json::json!({ - "status": "pending_approval", - "approval_id": approval.id, - "skill_id": approval.hand_id, - "message": format!("技能 '{}' 使用 {:?} 模式,需要用户审批后执行", manifest.name, manifest.mode) - }), - error: None, - duration_ms: None, - }); - } - _ => {} // PromptOnly and other modes are safe to execute directly - } - } - } - - // Execute skill directly - let result = kernel.execute_skill(&id, context.into(), input).await - .map_err(|e| format!("Failed to execute skill: {}", e))?; - - Ok(SkillResult::from(result)) -} - -// ============================================================================ -// Hands Commands - Autonomous Capabilities -// ============================================================================ - -/// Hand information response for frontend -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct HandInfoResponse { - pub id: String, - pub name: String, - pub description: String, - pub status: String, - pub requirements_met: bool, - pub needs_approval: bool, - pub dependencies: Vec, - pub tags: Vec, - pub enabled: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub category: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub icon: Option, - #[serde(default)] - pub tool_count: u32, - #[serde(default)] - pub metric_count: u32, -} - -impl From for HandInfoResponse { - fn from(config: zclaw_hands::HandConfig) -> Self { - // Determine status based on enabled and dependencies - let status = if !config.enabled { - "unavailable".to_string() - } else if config.needs_approval { - "needs_approval".to_string() - } else { - "idle".to_string() - }; - - // Extract category from tags if present - let category = config.tags.iter().find(|t| { - ["research", "automation", "browser", "data", "media", "communication"].contains(&t.as_str()) - }).cloned(); - - // Map tags to icon - let icon = if config.tags.contains(&"browser".to_string()) { - Some("globe".to_string()) - } else if config.tags.contains(&"research".to_string()) { - Some("search".to_string()) - } else if config.tags.contains(&"media".to_string()) { - Some("video".to_string()) - } else if config.tags.contains(&"data".to_string()) { - Some("database".to_string()) - } else if config.tags.contains(&"communication".to_string()) { - Some("message-circle".to_string()) - } else { - Some("zap".to_string()) - }; - - Self { - id: config.id, - name: config.name, - description: config.description, - status, - requirements_met: config.enabled && config.dependencies.is_empty(), - needs_approval: config.needs_approval, - dependencies: config.dependencies, - tags: config.tags, - enabled: config.enabled, - category, - icon, - tool_count: 0, - metric_count: 0, - } - } -} - -/// Hand execution result -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct HandResult { - pub success: bool, - pub output: serde_json::Value, - pub error: Option, - pub duration_ms: Option, -} - -impl From for HandResult { - fn from(result: zclaw_hands::HandResult) -> Self { - Self { - success: result.success, - output: result.output, - error: result.error, - duration_ms: result.duration_ms, - } - } -} - -/// List all registered hands -/// -/// Returns hands from the Kernel's HandRegistry. -/// Hands are registered during kernel initialization. -#[tauri::command] -pub async fn hand_list( - state: State<'_, KernelState>, -) -> Result, 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 hands = kernel.list_hands().await; - Ok(hands.into_iter().map(HandInfoResponse::from).collect()) -} - -/// Execute a hand -/// -/// Executes a hand with the given ID and input. -/// If the hand has `needs_approval = true`, creates a pending approval instead. -/// Returns the hand result as JSON, or a pending status with approval ID. -#[tauri::command] -pub async fn hand_execute( - state: State<'_, KernelState>, - id: String, - input: serde_json::Value, - autonomy_level: Option, -) -> Result { - let kernel_lock = state.lock().await; - - let kernel = kernel_lock.as_ref() - .ok_or_else(|| "Kernel not initialized. Call kernel_init first.".to_string())?; - - // Autonomy guard: supervised mode requires approval for ALL hands - if autonomy_level.as_deref() == Some("supervised") { - let approval = kernel.create_approval(id.clone(), input).await; - return Ok(HandResult { - success: false, - output: serde_json::json!({ - "status": "pending_approval", - "approval_id": approval.id, - "hand_id": approval.hand_id, - "message": "监督模式下所有 Hand 执行需要用户审批" - }), - error: None, - duration_ms: None, - }); - } - - // Check if hand requires approval (assisted mode or no autonomy level specified). - // In autonomous mode, the user has opted in to bypass per-hand approval gates. - if autonomy_level.as_deref() != Some("autonomous") { - let hands = kernel.list_hands().await; - if let Some(hand_config) = hands.iter().find(|h| h.id == id) { - if hand_config.needs_approval { - let approval = kernel.create_approval(id.clone(), input).await; - return Ok(HandResult { - success: false, - output: serde_json::json!({ - "status": "pending_approval", - "approval_id": approval.id, - "hand_id": approval.hand_id, - "message": "This hand requires approval before execution" - }), - error: None, - duration_ms: None, - }); - } - } - } - - // Execute hand directly (returns result + run_id for tracking) - let (result, _run_id) = kernel.execute_hand(&id, input).await - .map_err(|e| format!("Failed to execute hand: {}", e))?; - - Ok(HandResult::from(result)) -} - -// ============================================================ -// Trigger Commands -// ============================================================ - -/// Trigger configuration for creation/update -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct TriggerConfigRequest { - pub id: String, - pub name: String, - pub hand_id: String, - pub trigger_type: TriggerTypeRequest, - #[serde(default = "default_trigger_enabled")] - pub enabled: bool, - #[serde(default)] - pub description: Option, - #[serde(default)] - pub tags: Vec, -} - -fn default_trigger_enabled() -> bool { true } - -/// Trigger type for API -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum TriggerTypeRequest { - Schedule { cron: String }, - Event { pattern: String }, - Webhook { path: String, secret: Option }, - MessagePattern { pattern: String }, - FileSystem { path: String, events: Vec }, - Manual, -} - -/// Trigger response -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct TriggerResponse { - pub id: String, - pub name: String, - pub hand_id: String, - pub trigger_type: TriggerTypeRequest, - pub enabled: bool, - pub created_at: String, - pub modified_at: String, - pub description: Option, - pub tags: Vec, -} - -impl From for TriggerResponse { - fn from(entry: zclaw_kernel::trigger_manager::TriggerEntry) -> Self { - let trigger_type = match entry.config.trigger_type { - zclaw_hands::TriggerType::Schedule { cron } => { - TriggerTypeRequest::Schedule { cron } - } - zclaw_hands::TriggerType::Event { pattern } => { - TriggerTypeRequest::Event { pattern } - } - zclaw_hands::TriggerType::Webhook { path, secret } => { - TriggerTypeRequest::Webhook { path, secret } - } - zclaw_hands::TriggerType::MessagePattern { pattern } => { - TriggerTypeRequest::MessagePattern { pattern } - } - zclaw_hands::TriggerType::FileSystem { path, events } => { - TriggerTypeRequest::FileSystem { - path, - events: events.iter().map(|e| format!("{:?}", e).to_lowercase()).collect(), - } - } - zclaw_hands::TriggerType::Manual => TriggerTypeRequest::Manual, - }; - - Self { - id: entry.config.id, - name: entry.config.name, - hand_id: entry.config.hand_id, - trigger_type, - enabled: entry.config.enabled, - created_at: entry.created_at.to_rfc3339(), - modified_at: entry.modified_at.to_rfc3339(), - description: entry.description, - tags: entry.tags, - } - } -} - -/// List all triggers -#[tauri::command] -pub async fn trigger_list( - state: State<'_, KernelState>, -) -> Result, String> { - let kernel_lock = state.lock().await; - let kernel = kernel_lock.as_ref() - .ok_or_else(|| "Kernel not initialized".to_string())?; - - let triggers = kernel.list_triggers().await; - Ok(triggers.into_iter().map(TriggerResponse::from).collect()) -} - -/// Get a specific trigger -#[tauri::command] -pub async fn trigger_get( - state: State<'_, KernelState>, - id: String, -) -> Result, String> { - // Validate trigger ID - let id = validate_id(&id, "trigger_id")?; - - let kernel_lock = state.lock().await; - let kernel = kernel_lock.as_ref() - .ok_or_else(|| "Kernel not initialized".to_string())?; - - Ok(kernel.get_trigger(&id).await.map(TriggerResponse::from)) -} - -/// Create a new trigger -#[tauri::command] -pub async fn trigger_create( - state: State<'_, KernelState>, - request: TriggerConfigRequest, -) -> Result { - let kernel_lock = state.lock().await; - let kernel = kernel_lock.as_ref() - .ok_or_else(|| "Kernel not initialized".to_string())?; - - // Convert request to config - let trigger_type = match request.trigger_type { - TriggerTypeRequest::Schedule { cron } => { - zclaw_hands::TriggerType::Schedule { cron } - } - TriggerTypeRequest::Event { pattern } => { - zclaw_hands::TriggerType::Event { pattern } - } - TriggerTypeRequest::Webhook { path, secret } => { - zclaw_hands::TriggerType::Webhook { path, secret } - } - TriggerTypeRequest::MessagePattern { pattern } => { - zclaw_hands::TriggerType::MessagePattern { pattern } - } - TriggerTypeRequest::FileSystem { path, events } => { - zclaw_hands::TriggerType::FileSystem { - path, - events: events.iter().filter_map(|e| match e.as_str() { - "created" => Some(zclaw_hands::FileEvent::Created), - "modified" => Some(zclaw_hands::FileEvent::Modified), - "deleted" => Some(zclaw_hands::FileEvent::Deleted), - "any" => Some(zclaw_hands::FileEvent::Any), - _ => None, - }).collect(), - } - } - TriggerTypeRequest::Manual => zclaw_hands::TriggerType::Manual, - }; - - let config = zclaw_hands::TriggerConfig { - id: request.id, - name: request.name, - hand_id: request.hand_id, - trigger_type, - enabled: request.enabled, - max_executions_per_hour: 10, - }; - - let entry = kernel.create_trigger(config).await - .map_err(|e| format!("Failed to create trigger: {}", e))?; - - Ok(TriggerResponse::from(entry)) -} - -/// Update a trigger -#[tauri::command] -pub async fn trigger_update( - state: State<'_, KernelState>, - id: String, - name: Option, - enabled: Option, - hand_id: Option, -) -> Result { - let kernel_lock = state.lock().await; - let kernel = kernel_lock.as_ref() - .ok_or_else(|| "Kernel not initialized".to_string())?; - - let update = zclaw_kernel::trigger_manager::TriggerUpdateRequest { - name, - enabled, - hand_id, - trigger_type: None, - }; - - let entry = kernel.update_trigger(&id, update).await - .map_err(|e| format!("Failed to update trigger: {}", e))?; - - Ok(TriggerResponse::from(entry)) -} - -/// Delete a trigger -#[tauri::command] -pub async fn trigger_delete( - state: State<'_, KernelState>, - id: String, -) -> Result<(), String> { - // Validate trigger ID - let id = validate_id(&id, "trigger_id")?; - - let kernel_lock = state.lock().await; - let kernel = kernel_lock.as_ref() - .ok_or_else(|| "Kernel not initialized".to_string())?; - - kernel.delete_trigger(&id).await - .map_err(|e| format!("Failed to delete trigger: {}", e)) -} - -/// Execute a trigger manually -#[tauri::command] -pub async fn trigger_execute( - state: State<'_, KernelState>, - id: String, - input: serde_json::Value, -) -> Result { - // Validate trigger ID - let id = validate_id(&id, "trigger_id")?; - - let kernel_lock = state.lock().await; - let kernel = kernel_lock.as_ref() - .ok_or_else(|| "Kernel not initialized".to_string())?; - - let result = kernel.execute_trigger(&id, input).await - .map_err(|e| format!("Failed to execute trigger: {}", e))?; - - Ok(serde_json::to_value(result).unwrap_or(serde_json::json!({}))) -} - -// ============================================================ -// Approval Commands -// ============================================================ - -/// Approval response -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ApprovalResponse { - pub id: String, - pub hand_id: String, - pub status: String, - pub created_at: String, - pub input: serde_json::Value, -} - -/// List pending approvals -#[tauri::command] -pub async fn approval_list( - state: State<'_, KernelState>, -) -> Result, String> { - let kernel_lock = state.lock().await; - let kernel = kernel_lock.as_ref() - .ok_or_else(|| "Kernel not initialized".to_string())?; - - let approvals = kernel.list_approvals().await; - Ok(approvals.into_iter().map(|a| ApprovalResponse { - id: a.id, - hand_id: a.hand_id, - status: a.status, - created_at: a.created_at.to_rfc3339(), - input: a.input, - }).collect()) -} - -/// Respond to an approval -/// -/// When approved, the kernel's `respond_to_approval` internally spawns the Hand -/// execution. We additionally emit Tauri events so the frontend can track when -/// the execution finishes, since the kernel layer has no access to the AppHandle. -#[tauri::command] -pub async fn approval_respond( - app: AppHandle, - state: State<'_, KernelState>, - id: String, - approved: bool, - reason: Option, -) -> Result<(), String> { - // Capture hand info before calling respond_to_approval (which mutates the approval) - let hand_id = { - let kernel_lock = state.lock().await; - let kernel = kernel_lock.as_ref() - .ok_or_else(|| "Kernel not initialized".to_string())?; - - let approvals = kernel.list_approvals().await; - let entry = approvals.iter().find(|a| a.id == id && a.status == "pending") - .ok_or_else(|| format!("Approval not found or already resolved: {}", id))?; - entry.hand_id.clone() - }; - - // Call kernel respond_to_approval (this updates status and spawns Hand execution) - { - let kernel_lock = state.lock().await; - let kernel = kernel_lock.as_ref() - .ok_or_else(|| "Kernel not initialized".to_string())?; - - kernel.respond_to_approval(&id, approved, reason).await - .map_err(|e| format!("Failed to respond to approval: {}", e))?; - } - - // When approved, monitor the Hand execution and emit events to the frontend. - // The kernel's respond_to_approval changes status to "approved" immediately, - // then the spawned task sets it to "completed" or "failed" when done. - if approved { - let approval_id = id.clone(); - let kernel_state: KernelState = (*state).clone(); - - tokio::spawn(async move { - let timeout = tokio::time::Duration::from_secs(300); - let poll_interval = tokio::time::Duration::from_millis(500); - - let result = tokio::time::timeout(timeout, async { - loop { - tokio::time::sleep(poll_interval).await; - - let kernel_lock = kernel_state.lock().await; - if let Some(kernel) = kernel_lock.as_ref() { - // Use get_approval to check any status (not just "pending") - if let Some(entry) = kernel.get_approval(&approval_id).await { - match entry.status.as_str() { - "completed" => { - tracing::info!("[approval_respond] Hand '{}' completed for approval {}", hand_id, approval_id); - return (true, None::); - } - "failed" => { - let error_msg = entry.input.get("error") - .and_then(|v| v.as_str()) - .unwrap_or("Unknown error") - .to_string(); - tracing::warn!("[approval_respond] Hand '{}' failed for approval {}: {}", hand_id, approval_id, error_msg); - return (false, Some(error_msg)); - } - _ => {} // "approved" = still running - } - } else { - // Entry disappeared entirely — kernel was likely restarted - return (false, Some("Approval entry disappeared".to_string())); - } - } else { - return (false, Some("Kernel not available".to_string())); - } - } - }).await; - - let (success, error) = match result { - Ok((s, e)) => (s, e), - Err(_) => (false, Some("Hand execution timed out (5 minutes)".to_string())), - }; - - let _ = app.emit("hand-execution-complete", serde_json::json!({ - "approvalId": approval_id, - "handId": hand_id, - "success": success, - "error": error, - })); - }); - } - - Ok(()) -} - -/// Approve a hand execution -/// -/// When approved, the kernel's `respond_to_approval` internally spawns the Hand -/// execution. We additionally emit Tauri events so the frontend can track when -/// the execution finishes. -#[tauri::command] -pub async fn hand_approve( - app: AppHandle, - state: State<'_, KernelState>, - hand_name: String, - run_id: String, - approved: bool, - reason: Option, -) -> Result { - let kernel_lock = state.lock().await; - let kernel = kernel_lock.as_ref() - .ok_or_else(|| "Kernel not initialized".to_string())?; - - tracing::info!( - "[hand_approve] hand={}, run_id={}, approved={}, reason={:?}", - hand_name, run_id, approved, reason - ); - - // Verify the approval belongs to the specified hand before responding. - // This prevents cross-hand approval attacks where a run_id from one hand - // is used to approve a different hand's pending execution. - let approvals = kernel.list_approvals().await; - let entry = approvals.iter().find(|a| a.id == run_id && a.status == "pending") - .ok_or_else(|| format!("Approval not found or already resolved: {}", run_id))?; - - if entry.hand_id != hand_name { - return Err(format!( - "Approval run_id {} belongs to hand '{}', not '{}' as requested", - run_id, entry.hand_id, hand_name - )); - } - - kernel.respond_to_approval(&run_id, approved, reason).await - .map_err(|e| format!("Failed to approve hand: {}", e))?; - - // When approved, monitor the Hand execution and emit events to the frontend - if approved { - let approval_id = run_id.clone(); - let hand_id = hand_name.clone(); - let kernel_state: KernelState = (*state).clone(); - - tokio::spawn(async move { - // Poll the approval status until it transitions from "approved" to - // "completed" or "failed" (set by the kernel's spawned task). - // Timeout after 5 minutes to avoid hanging forever. - let timeout = tokio::time::Duration::from_secs(300); - let poll_interval = tokio::time::Duration::from_millis(500); - - let result = tokio::time::timeout(timeout, async { - loop { - tokio::time::sleep(poll_interval).await; - - let kernel_lock = kernel_state.lock().await; - if let Some(kernel) = kernel_lock.as_ref() { - // Use get_approval to check any status (not just "pending") - if let Some(entry) = kernel.get_approval(&approval_id).await { - match entry.status.as_str() { - "completed" => { - tracing::info!("[hand_approve] Hand '{}' execution completed for approval {}", hand_id, approval_id); - return (true, None::); - } - "failed" => { - let error_msg = entry.input.get("error") - .and_then(|v| v.as_str()) - .unwrap_or("Unknown error") - .to_string(); - tracing::warn!("[hand_approve] Hand '{}' execution failed for approval {}: {}", hand_id, approval_id, error_msg); - return (false, Some(error_msg)); - } - _ => {} // still running (status is "approved") - } - } else { - // Entry disappeared entirely — kernel was likely restarted - return (false, Some("Approval entry disappeared".to_string())); - } - } else { - return (false, Some("Kernel not available".to_string())); - } - } - }).await; - - let (success, error) = match result { - Ok((s, e)) => (s, e), - Err(_) => (false, Some("Hand execution timed out (5 minutes)".to_string())), - }; - - let _ = app.emit("hand-execution-complete", serde_json::json!({ - "approvalId": approval_id, - "handId": hand_id, - "success": success, - "error": error, - })); - }); - } - - Ok(serde_json::json!({ - "status": if approved { "approved" } else { "rejected" }, - "hand_name": hand_name, - })) -} - -/// Cancel a hand execution -#[tauri::command] -pub async fn hand_cancel( - state: State<'_, KernelState>, - hand_name: String, - run_id: String, -) -> Result { - let kernel_lock = state.lock().await; - let kernel = kernel_lock.as_ref() - .ok_or_else(|| "Kernel not initialized".to_string())?; - - tracing::info!( - "[hand_cancel] hand={}, run_id={}", - hand_name, run_id - ); - - // Verify the approval belongs to the specified hand before cancelling - let approvals = kernel.list_approvals().await; - let entry = approvals.iter().find(|a| a.id == run_id && a.status == "pending") - .ok_or_else(|| format!("Approval not found or already resolved: {}", run_id))?; - - if entry.hand_id != hand_name { - return Err(format!( - "Approval run_id {} belongs to hand '{}', not '{}' as requested", - run_id, entry.hand_id, hand_name - )); - } - - kernel.cancel_approval(&run_id).await - .map_err(|e| format!("Failed to cancel hand: {}", e))?; - - Ok(serde_json::json!({ "status": "cancelled", "hand_name": hand_name })) -} - -// ============================================================ -// Hand Stub Commands (not yet fully implemented) -// ============================================================ - -/// Get detailed info for a single hand -#[tauri::command] -pub async fn hand_get( - state: State<'_, KernelState>, - name: String, -) -> Result { - let kernel_lock = state.lock().await; - let kernel = kernel_lock.as_ref() - .ok_or_else(|| "Kernel not initialized".to_string())?; - - let hands = kernel.list_hands().await; - let found = hands.iter().find(|h| h.id == name) - .ok_or_else(|| format!("Hand '{}' not found", name))?; - - Ok(serde_json::to_value(found) - .map_err(|e| format!("Serialization error: {}", e))?) -} - -/// Get status of a specific hand run -#[tauri::command] -pub async fn hand_run_status( - state: State<'_, KernelState>, - run_id: String, -) -> Result { - let kernel_lock = state.lock().await; - let kernel = kernel_lock.as_ref() - .ok_or_else(|| "Kernel not initialized".to_string())?; - - let parsed_id: zclaw_types::HandRunId = run_id.parse() - .map_err(|e| format!("Invalid run ID: {}", e))?; - - let run = kernel.get_hand_run(&parsed_id).await - .map_err(|e| format!("Failed to get hand run: {}", e))?; - - match run { - Some(r) => Ok(serde_json::to_value(r) - .map_err(|e| format!("Serialization error: {}", e))?), - None => Ok(serde_json::json!({ - "status": "not_found", - "run_id": run_id, - "message": "Hand run not found" - })), - } -} - -/// List run history for a hand (or all hands) -#[tauri::command] -pub async fn hand_run_list( - state: State<'_, KernelState>, - hand_name: Option, - status: Option, - limit: Option, - offset: Option, -) -> Result { - let kernel_lock = state.lock().await; - let kernel = kernel_lock.as_ref() - .ok_or_else(|| "Kernel not initialized".to_string())?; - - let filter = zclaw_types::HandRunFilter { - hand_name, - status: status.map(|s| s.parse()).transpose() - .map_err(|e| format!("Invalid status filter: {}", e))?, - limit, - offset, - }; - - let runs = kernel.list_hand_runs(&filter).await - .map_err(|e| format!("Failed to list hand runs: {}", e))?; - let total = kernel.count_hand_runs(&filter).await - .map_err(|e| format!("Failed to count hand runs: {}", e))?; - - Ok(serde_json::json!({ - "runs": runs, - "total": total, - "limit": filter.limit.unwrap_or(20), - "offset": filter.offset.unwrap_or(0), - })) -} - -/// Cancel a running hand execution -#[tauri::command] -pub async fn hand_run_cancel( - state: State<'_, KernelState>, - run_id: String, -) -> Result { - let kernel_lock = state.lock().await; - let kernel = kernel_lock.as_ref() - .ok_or_else(|| "Kernel not initialized".to_string())?; - - let parsed_id: zclaw_types::HandRunId = run_id.parse() - .map_err(|e| format!("Invalid run ID: {}", e))?; - - kernel.cancel_hand_run(&parsed_id).await - .map_err(|e| format!("Failed to cancel hand run: {}", e))?; - - Ok(serde_json::json!({ - "status": "cancelled", - "run_id": run_id - })) -} - -// ============================================================ -// Scheduled Task Commands -// ============================================================ - -/// Request to create a scheduled task (maps to kernel trigger) -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CreateScheduledTaskRequest { - pub name: String, - pub schedule: String, - pub schedule_type: String, - pub target: Option, - pub description: Option, - pub enabled: Option, -} - -/// Target for a scheduled task -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ScheduledTaskTarget { - #[serde(rename = "type")] - pub target_type: String, - pub id: String, -} - -/// Response for scheduled task creation -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ScheduledTaskResponse { - pub id: String, - pub name: String, - pub schedule: String, - pub status: String, -} - -/// Create a scheduled task (backed by kernel TriggerManager) -/// -/// Tasks are automatically executed by the SchedulerService which checks -/// every 60 seconds for due triggers. -#[tauri::command] -pub async fn scheduled_task_create( - state: State<'_, KernelState>, - request: CreateScheduledTaskRequest, -) -> Result { - let kernel_lock = state.lock().await; - let kernel = kernel_lock.as_ref() - .ok_or_else(|| "Kernel not initialized".to_string())?; - - // Build TriggerConfig from request - let trigger_type = match request.schedule_type.as_str() { - "cron" | "schedule" => zclaw_hands::TriggerType::Schedule { - cron: request.schedule.clone(), - }, - "interval" => zclaw_hands::TriggerType::Schedule { - cron: request.schedule.clone(), // interval as simplified cron - }, - "once" => zclaw_hands::TriggerType::Schedule { - cron: request.schedule.clone(), - }, - _ => return Err(format!("Unsupported schedule type: {}", request.schedule_type)), - }; - - let target_id = request.target.as_ref().map(|t| t.id.clone()).unwrap_or_default(); - let task_id = format!("sched_{}", chrono::Utc::now().timestamp_millis()); - - let config = zclaw_hands::TriggerConfig { - id: task_id.clone(), - name: request.name.clone(), - hand_id: target_id, - trigger_type, - enabled: request.enabled.unwrap_or(true), - max_executions_per_hour: 60, - }; - - let entry = kernel.create_trigger(config).await - .map_err(|e| format!("Failed to create scheduled task: {}", e))?; - - Ok(ScheduledTaskResponse { - id: entry.config.id, - name: entry.config.name, - schedule: request.schedule, - status: "active".to_string(), - }) -} - -/// List all scheduled tasks (kernel triggers of Schedule type) -#[tauri::command] -pub async fn scheduled_task_list( - state: State<'_, KernelState>, -) -> Result, String> { - let kernel_lock = state.lock().await; - let kernel = kernel_lock.as_ref() - .ok_or_else(|| "Kernel not initialized".to_string())?; - - let triggers = kernel.list_triggers().await; - let tasks: Vec = triggers - .into_iter() - .filter(|t| matches!(t.config.trigger_type, zclaw_hands::TriggerType::Schedule { .. })) - .map(|t| { - let schedule = match t.config.trigger_type { - zclaw_hands::TriggerType::Schedule { cron } => cron, - _ => String::new(), - }; - ScheduledTaskResponse { - id: t.config.id, - name: t.config.name, - schedule, - status: if t.config.enabled { "active".to_string() } else { "paused".to_string() }, - } - }) - .collect(); - - Ok(tasks) -} - -// ============================================================ -// A2A (Agent-to-Agent) Commands — gated behind multi-agent feature -// ============================================================ - -#[cfg(feature = "multi-agent")] -/// Send a direct A2A message from one agent to another -#[tauri::command] -pub async fn agent_a2a_send( - state: State<'_, KernelState>, - from: String, - to: String, - payload: serde_json::Value, - message_type: Option, -) -> Result<(), 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 from_id: AgentId = from.parse() - .map_err(|_| format!("Invalid from agent ID: {}", from))?; - let to_id: AgentId = to.parse() - .map_err(|_| format!("Invalid to agent ID: {}", to))?; - - let msg_type = message_type.map(|mt| match mt.as_str() { - "request" => zclaw_kernel::A2aMessageType::Request, - "notification" => zclaw_kernel::A2aMessageType::Notification, - "task" => zclaw_kernel::A2aMessageType::Task, - _ => zclaw_kernel::A2aMessageType::Notification, - }); - - kernel.a2a_send(&from_id, &to_id, payload, msg_type).await - .map_err(|e| format!("A2A send failed: {}", e))?; - - Ok(()) -} - -/// Broadcast a message from one agent to all other agents -#[cfg(feature = "multi-agent")] -#[tauri::command] -pub async fn agent_a2a_broadcast( - state: State<'_, KernelState>, - from: String, - payload: serde_json::Value, -) -> Result<(), 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 from_id: AgentId = from.parse() - .map_err(|_| format!("Invalid from agent ID: {}", from))?; - - kernel.a2a_broadcast(&from_id, payload).await - .map_err(|e| format!("A2A broadcast failed: {}", e))?; - - Ok(()) -} - -/// Discover agents with a specific capability -#[cfg(feature = "multi-agent")] -#[tauri::command] -pub async fn agent_a2a_discover( - state: State<'_, KernelState>, - capability: String, -) -> Result, 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 profiles = kernel.a2a_discover(&capability).await - .map_err(|e| format!("A2A discover failed: {}", e))?; - - let result: Vec = profiles.iter() - .filter_map(|p| serde_json::to_value(p).ok()) - .collect(); - - Ok(result) - } - -/// Delegate a task to another agent and wait for response -#[cfg(feature = "multi-agent")] -#[tauri::command] -pub async fn agent_a2a_delegate_task( - state: State<'_, KernelState>, - from: String, - to: String, - task: String, - timeout_ms: Option, -) -> Result { - 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 from_id: AgentId = from.parse() - .map_err(|_| format!("Invalid from agent ID: {}", from))?; - let to_id: AgentId = to.parse() - .map_err(|_| format!("Invalid to agent ID: {}", to))?; - - let timeout = timeout_ms.unwrap_or(30_000); - - // 30 seconds default - - let response = kernel.a2a_delegate_task(&from_id, &to_id, task, timeout).await - .map_err(|e| format!("A2A task delegation failed: {}", e))?; - - Ok(response) - } diff --git a/desktop/src-tauri/src/kernel_commands/a2a.rs b/desktop/src-tauri/src/kernel_commands/a2a.rs new file mode 100644 index 0000000..076487e --- /dev/null +++ b/desktop/src-tauri/src/kernel_commands/a2a.rs @@ -0,0 +1,114 @@ +//! A2A (Agent-to-Agent) commands — gated behind `multi-agent` feature + +use serde_json; +use tauri::State; +use zclaw_types::AgentId; + +use super::KernelState; + +// ============================================================ +// A2A (Agent-to-Agent) Commands — gated behind multi-agent feature +// ============================================================ + +#[cfg(feature = "multi-agent")] +/// Send a direct A2A message from one agent to another +#[tauri::command] +pub async fn agent_a2a_send( + state: State<'_, KernelState>, + from: String, + to: String, + payload: serde_json::Value, + message_type: Option, +) -> Result<(), 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 from_id: AgentId = from.parse() + .map_err(|_| format!("Invalid from agent ID: {}", from))?; + let to_id: AgentId = to.parse() + .map_err(|_| format!("Invalid to agent ID: {}", to))?; + + let msg_type = message_type.map(|mt| match mt.as_str() { + "request" => zclaw_kernel::A2aMessageType::Request, + "notification" => zclaw_kernel::A2aMessageType::Notification, + "task" => zclaw_kernel::A2aMessageType::Task, + _ => zclaw_kernel::A2aMessageType::Notification, + }); + + kernel.a2a_send(&from_id, &to_id, payload, msg_type).await + .map_err(|e| format!("A2A send failed: {}", e))?; + + Ok(()) +} + +/// Broadcast a message from one agent to all other agents +#[cfg(feature = "multi-agent")] +#[tauri::command] +pub async fn agent_a2a_broadcast( + state: State<'_, KernelState>, + from: String, + payload: serde_json::Value, +) -> Result<(), 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 from_id: AgentId = from.parse() + .map_err(|_| format!("Invalid from agent ID: {}", from))?; + + kernel.a2a_broadcast(&from_id, payload).await + .map_err(|e| format!("A2A broadcast failed: {}", e))?; + + Ok(()) +} + +/// Discover agents with a specific capability +#[cfg(feature = "multi-agent")] +#[tauri::command] +pub async fn agent_a2a_discover( + state: State<'_, KernelState>, + capability: String, +) -> Result, 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 profiles = kernel.a2a_discover(&capability).await + .map_err(|e| format!("A2A discover failed: {}", e))?; + + let result: Vec = profiles.iter() + .filter_map(|p| serde_json::to_value(p).ok()) + .collect(); + + Ok(result) + } + +/// Delegate a task to another agent and wait for response +#[cfg(feature = "multi-agent")] +#[tauri::command] +pub async fn agent_a2a_delegate_task( + state: State<'_, KernelState>, + from: String, + to: String, + task: String, + timeout_ms: Option, +) -> Result { + 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 from_id: AgentId = from.parse() + .map_err(|_| format!("Invalid from agent ID: {}", from))?; + let to_id: AgentId = to.parse() + .map_err(|_| format!("Invalid to agent ID: {}", to))?; + + let timeout = timeout_ms.unwrap_or(30_000); + + // 30 seconds default + + let response = kernel.a2a_delegate_task(&from_id, &to_id, task, timeout).await + .map_err(|e| format!("A2A task delegation failed: {}", e))?; + + Ok(response) + } diff --git a/desktop/src-tauri/src/kernel_commands/agent.rs b/desktop/src-tauri/src/kernel_commands/agent.rs new file mode 100644 index 0000000..248b6cb --- /dev/null +++ b/desktop/src-tauri/src/kernel_commands/agent.rs @@ -0,0 +1,257 @@ +//! Agent CRUD commands: create, list, get, delete, update, export, import + +use std::path::PathBuf; +use serde::{Deserialize, Serialize}; +use tauri::State; +use zclaw_types::{AgentConfig, AgentId, AgentInfo}; + +use super::{validate_agent_id, KernelState}; +use crate::intelligence::validation::validate_string_length; + +// --------------------------------------------------------------------------- +// Request / Response types +// --------------------------------------------------------------------------- + +fn default_provider() -> String { "openai".to_string() } +fn default_model() -> String { "gpt-4o-mini".to_string() } +fn default_max_tokens() -> u32 { 4096 } +fn default_temperature() -> f32 { 0.7 } + +/// Agent creation request +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateAgentRequest { + pub name: String, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub system_prompt: Option, + #[serde(default = "default_provider")] + pub provider: String, + #[serde(default = "default_model")] + pub model: String, + #[serde(default = "default_max_tokens")] + pub max_tokens: u32, + #[serde(default = "default_temperature")] + pub temperature: f32, + #[serde(default)] + pub workspace: Option, +} + +/// Agent creation response +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateAgentResponse { + pub id: String, + pub name: String, + pub state: String, +} + +/// 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, +} + +// --------------------------------------------------------------------------- +// Commands +// --------------------------------------------------------------------------- + +/// Create a new agent +#[tauri::command] +pub async fn agent_create( + state: State<'_, KernelState>, + request: CreateAgentRequest, +) -> Result { + 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 mut config = AgentConfig::new(&request.name) + .with_description(request.description.unwrap_or_default()) + .with_system_prompt(request.system_prompt.unwrap_or_default()) + .with_model(zclaw_types::ModelConfig { + provider: request.provider, + model: request.model, + api_key_env: None, + base_url: None, + }) + .with_max_tokens(request.max_tokens) + .with_temperature(request.temperature); + + if let Some(workspace) = request.workspace { + config.workspace = Some(workspace); + } + + let id = kernel.spawn_agent(config) + .await + .map_err(|e| format!("Failed to create agent: {}", e))?; + + Ok(CreateAgentResponse { + id: id.to_string(), + name: request.name, + state: "running".to_string(), + }) +} + +/// List all agents +#[tauri::command] +pub async fn agent_list( + state: State<'_, KernelState>, +) -> Result, 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())?; + + Ok(kernel.list_agents()) +} + +/// Get agent info +#[tauri::command] +pub async fn agent_get( + state: State<'_, KernelState>, + agent_id: String, +) -> Result, String> { + 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())?; + + Ok(kernel.get_agent(&id)) +} + +/// Delete an agent +#[tauri::command] +pub async fn agent_delete( + state: State<'_, KernelState>, + agent_id: String, +) -> Result<(), String> { + 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())?; + + kernel.kill_agent(&id) + .await + .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)) +} + +/// Export an agent configuration as JSON +#[tauri::command] +pub async fn agent_export( + state: State<'_, KernelState>, + agent_id: String, +) -> 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())?; + + let config = kernel.get_agent_config(&id) + .ok_or_else(|| format!("Agent not found: {}", agent_id))?; + + serde_json::to_string_pretty(&config) + .map_err(|e| format!("Failed to serialize agent config: {}", e)) +} + +/// Import an agent from JSON configuration +#[tauri::command] +pub async fn agent_import( + state: State<'_, KernelState>, + config_json: String, +) -> Result { + validate_string_length(&config_json, "config_json", 1_000_000) + .map_err(|e| format!("{}", e))?; + + let mut config: AgentConfig = serde_json::from_str(&config_json) + .map_err(|e| format!("Invalid agent config JSON: {}", e))?; + + // Regenerate ID to avoid collisions + config.id = AgentId::new(); + + 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 new_id = kernel.spawn_agent(config).await + .map_err(|e| format!("Failed to import agent: {}", e))?; + + kernel.get_agent(&new_id) + .ok_or_else(|| "Agent was created but could not be retrieved".to_string()) +} diff --git a/desktop/src-tauri/src/kernel_commands/approval.rs b/desktop/src-tauri/src/kernel_commands/approval.rs new file mode 100644 index 0000000..0e702aa --- /dev/null +++ b/desktop/src-tauri/src/kernel_commands/approval.rs @@ -0,0 +1,140 @@ +//! Approval commands: list and respond +//! +//! When approved, kernel's `respond_to_approval` internally spawns the Hand execution +//! and emits `hand-execution-complete` events to the frontend. + +use serde::{Deserialize, Serialize}; +use serde_json; +use tauri::{AppHandle, Emitter, State}; + +use super::KernelState; + +// ============================================================ +// Approval Commands +// ============================================================ + +/// Approval response +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ApprovalResponse { + pub id: String, + pub hand_id: String, + pub status: String, + pub created_at: String, + pub input: serde_json::Value, +} + +/// List pending approvals +#[tauri::command] +pub async fn approval_list( + state: State<'_, KernelState>, +) -> Result, String> { + let kernel_lock = state.lock().await; + let kernel = kernel_lock.as_ref() + .ok_or_else(|| "Kernel not initialized".to_string())?; + + let approvals = kernel.list_approvals().await; + Ok(approvals.into_iter().map(|a| ApprovalResponse { + id: a.id, + hand_id: a.hand_id, + status: a.status, + created_at: a.created_at.to_rfc3339(), + input: a.input, + }).collect()) +} + +/// Respond to an approval +/// +/// When approved, the kernel's `respond_to_approval` internally spawns the Hand +/// execution. We additionally emit Tauri events so the frontend can track when +/// the execution finishes, since the kernel layer has no access to the AppHandle. +#[tauri::command] +pub async fn approval_respond( + app: AppHandle, + state: State<'_, KernelState>, + id: String, + approved: bool, + reason: Option, +) -> Result<(), String> { + // Capture hand info before calling respond_to_approval (which mutates the approval) + let hand_id = { + let kernel_lock = state.lock().await; + let kernel = kernel_lock.as_ref() + .ok_or_else(|| "Kernel not initialized".to_string())?; + + let approvals = kernel.list_approvals().await; + let entry = approvals.iter().find(|a| a.id == id && a.status == "pending") + .ok_or_else(|| format!("Approval not found or already resolved: {}", id))?; + entry.hand_id.clone() + }; + + // Call kernel respond_to_approval (this updates status and spawns Hand execution) + { + let kernel_lock = state.lock().await; + let kernel = kernel_lock.as_ref() + .ok_or_else(|| "Kernel not initialized".to_string())?; + + kernel.respond_to_approval(&id, approved, reason).await + .map_err(|e| format!("Failed to respond to approval: {}", e))?; + } + + // When approved, monitor the Hand execution and emit events to the frontend. + // The kernel's respond_to_approval changes status to "approved" immediately, + // then the spawned task sets it to "completed" or "failed" when done. + if approved { + let approval_id = id.clone(); + let kernel_state: KernelState = (*state).clone(); + + tokio::spawn(async move { + let timeout = tokio::time::Duration::from_secs(300); + let poll_interval = tokio::time::Duration::from_millis(500); + + let result = tokio::time::timeout(timeout, async { + loop { + tokio::time::sleep(poll_interval).await; + + let kernel_lock = kernel_state.lock().await; + if let Some(kernel) = kernel_lock.as_ref() { + // Use get_approval to check any status (not just "pending") + if let Some(entry) = kernel.get_approval(&approval_id).await { + match entry.status.as_str() { + "completed" => { + tracing::info!("[approval_respond] Hand '{}' completed for approval {}", hand_id, approval_id); + return (true, None::); + } + "failed" => { + let error_msg = entry.input.get("error") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown error") + .to_string(); + tracing::warn!("[approval_respond] Hand '{}' failed for approval {}: {}", hand_id, approval_id, error_msg); + return (false, Some(error_msg)); + } + _ => {} // "approved" = still running + } + } else { + // Entry disappeared entirely — kernel was likely restarted + return (false, Some("Approval entry disappeared".to_string())); + } + } else { + return (false, Some("Kernel not available".to_string())); + } + } + }).await; + + let (success, error) = match result { + Ok((s, e)) => (s, e), + Err(_) => (false, Some("Hand execution timed out (5 minutes)".to_string())), + }; + + let _ = app.emit("hand-execution-complete", serde_json::json!({ + "approvalId": approval_id, + "handId": hand_id, + "success": success, + "error": error, + })); + }); + } + + Ok(()) +} diff --git a/desktop/src-tauri/src/kernel_commands/chat.rs b/desktop/src-tauri/src/kernel_commands/chat.rs new file mode 100644 index 0000000..193d171 --- /dev/null +++ b/desktop/src-tauri/src/kernel_commands/chat.rs @@ -0,0 +1,274 @@ +//! Chat commands: send message, streaming chat + +use std::sync::Arc; +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Emitter, State}; +use tokio::sync::Mutex; +use zclaw_types::AgentId; + +use super::{validate_agent_id, KernelState, SessionStreamGuard}; +use crate::intelligence::validation::validate_string_length; + +// --------------------------------------------------------------------------- +// Request / Response types +// --------------------------------------------------------------------------- + +/// Chat request +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChatRequest { + pub agent_id: String, + pub message: String, +} + +/// Chat response +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChatResponse { + pub content: String, + pub input_tokens: u32, + pub output_tokens: u32, +} + +/// Streaming chat event for Tauri emission +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "type")] +pub enum StreamChatEvent { + Delta { delta: String }, + ToolStart { name: String, input: serde_json::Value }, + ToolEnd { name: String, output: serde_json::Value }, + IterationStart { iteration: usize, max_iterations: usize }, + HandStart { name: String, params: serde_json::Value }, + HandEnd { name: String, result: serde_json::Value }, + Complete { input_tokens: u32, output_tokens: u32 }, + Error { message: String }, +} + +/// Streaming chat request +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StreamChatRequest { + pub agent_id: String, + pub session_id: String, + pub message: String, +} + +// --------------------------------------------------------------------------- +// Commands +// --------------------------------------------------------------------------- + +/// Send a message to an agent +#[tauri::command] +pub async fn agent_chat( + state: State<'_, KernelState>, + request: ChatRequest, +) -> Result { + validate_agent_id(&request.agent_id)?; + validate_string_length(&request.message, "message", 100000) + .map_err(|e| format!("Invalid message: {}", e))?; + + 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 = request.agent_id.parse() + .map_err(|_| "Invalid agent ID format".to_string())?; + + let response = kernel.send_message(&id, request.message) + .await + .map_err(|e| format!("Chat failed: {}", e))?; + + Ok(ChatResponse { + content: response.content, + input_tokens: response.input_tokens, + output_tokens: response.output_tokens, + }) +} + +/// Send a message to an agent with streaming response +/// +/// This command initiates a streaming chat session. Events are emitted +/// via Tauri's event system with the name "stream:chunk" and include +/// the session_id for routing. +#[tauri::command] +pub async fn agent_chat_stream( + app: AppHandle, + state: State<'_, KernelState>, + identity_state: State<'_, crate::intelligence::IdentityManagerState>, + heartbeat_state: State<'_, crate::intelligence::HeartbeatEngineState>, + reflection_state: State<'_, crate::intelligence::ReflectionEngineState>, + stream_guard: State<'_, SessionStreamGuard>, + request: StreamChatRequest, +) -> Result<(), String> { + validate_agent_id(&request.agent_id)?; + validate_string_length(&request.message, "message", 100000) + .map_err(|e| format!("Invalid message: {}", e))?; + + let id: AgentId = request.agent_id.parse() + .map_err(|_| "Invalid agent ID format".to_string())?; + + let session_id = request.session_id.clone(); + let agent_id_str = request.agent_id.clone(); + let message = request.message.clone(); + + // Session-level concurrency guard + let session_mutex = stream_guard + .entry(session_id.clone()) + .or_insert_with(|| Arc::new(Mutex::new(()))); + let _session_guard = session_mutex.try_lock() + .map_err(|_| { + tracing::warn!( + "[agent_chat_stream] Session {} already has an active stream — rejecting", + session_id + ); + format!("Session {} already has an active stream", session_id) + })?; + + // AUTO-INIT HEARTBEAT + { + let mut engines = heartbeat_state.lock().await; + if !engines.contains_key(&request.agent_id) { + let engine = crate::intelligence::heartbeat::HeartbeatEngine::new( + request.agent_id.clone(), + None, + ); + engines.insert(request.agent_id.clone(), engine); + tracing::info!("[agent_chat_stream] Auto-initialized heartbeat for agent: {}", request.agent_id); + } + } + + // PRE-CONVERSATION: Build intelligence-enhanced system prompt + let enhanced_prompt = crate::intelligence_hooks::pre_conversation_hook( + &request.agent_id, + &request.message, + &identity_state, + ).await.unwrap_or_default(); + + // Get the streaming receiver while holding the lock, then release it + let (mut rx, llm_driver) = { + 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 driver = Some(kernel.driver()); + + let prompt_arg = if enhanced_prompt.is_empty() { None } else { Some(enhanced_prompt) }; + + let session_id_parsed = if session_id.is_empty() { + None + } else { + match uuid::Uuid::parse_str(&session_id) { + Ok(uuid) => Some(zclaw_types::SessionId::from_uuid(uuid)), + Err(e) => { + return Err(format!( + "Invalid session_id '{}': {}. Cannot reuse conversation context.", + session_id, e + )); + } + } + }; + let rx = kernel.send_message_stream_with_prompt(&id, message.clone(), prompt_arg, session_id_parsed) + .await + .map_err(|e| format!("Failed to start streaming: {}", e))?; + (rx, driver) + }; + + let hb_state = heartbeat_state.inner().clone(); + let rf_state = reflection_state.inner().clone(); + + // Spawn a task to process stream events with timeout guard + tokio::spawn(async move { + use zclaw_runtime::LoopEvent; + + tracing::debug!("[agent_chat_stream] Starting stream processing for session: {}", session_id); + + let stream_timeout = tokio::time::Duration::from_secs(300); + + loop { + match tokio::time::timeout(stream_timeout, rx.recv()).await { + Ok(Some(event)) => { + let stream_event = match &event { + LoopEvent::Delta(delta) => { + tracing::trace!("[agent_chat_stream] Delta: {} bytes", delta.len()); + StreamChatEvent::Delta { delta: delta.clone() } + } + LoopEvent::ToolStart { name, input } => { + tracing::debug!("[agent_chat_stream] ToolStart: {}", name); + 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); + 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); + StreamChatEvent::IterationStart { iteration: *iteration, max_iterations: *max_iterations } + } + LoopEvent::Complete(result) => { + tracing::info!("[agent_chat_stream] Complete: input_tokens={}, output_tokens={}", + result.input_tokens, result.output_tokens); + + let agent_id_hook = agent_id_str.clone(); + let message_hook = message.clone(); + let hb = hb_state.clone(); + let rf = rf_state.clone(); + let driver = llm_driver.clone(); + tokio::spawn(async move { + crate::intelligence_hooks::post_conversation_hook( + &agent_id_hook, &message_hook, &hb, &rf, driver, + ).await; + }); + + StreamChatEvent::Complete { + input_tokens: result.input_tokens, + output_tokens: result.output_tokens, + } + } + LoopEvent::Error(message) => { + tracing::warn!("[agent_chat_stream] Error: {}", message); + StreamChatEvent::Error { message: message.clone() } + } + }; + + if let Err(e) = app.emit("stream:chunk", serde_json::json!({ + "sessionId": session_id, + "event": stream_event + })) { + tracing::warn!("[agent_chat_stream] Failed to emit event: {}", e); + break; + } + + if matches!(event, LoopEvent::Complete(_) | LoopEvent::Error(_)) { + break; + } + } + Ok(None) => { + tracing::info!("[agent_chat_stream] Stream channel closed for session: {}", session_id); + break; + } + Err(_) => { + tracing::warn!("[agent_chat_stream] Stream idle timeout for session: {}", session_id); + let _ = app.emit("stream:chunk", serde_json::json!({ + "sessionId": session_id, + "event": StreamChatEvent::Error { + message: "流式响应超时,请重试".to_string() + } + })); + break; + } + } + } + + tracing::debug!("[agent_chat_stream] Stream processing ended for session: {}", session_id); + }); + + Ok(()) +} diff --git a/desktop/src-tauri/src/kernel_commands/hand.rs b/desktop/src-tauri/src/kernel_commands/hand.rs new file mode 100644 index 0000000..e53f32d --- /dev/null +++ b/desktop/src-tauri/src/kernel_commands/hand.rs @@ -0,0 +1,431 @@ +//! Hand commands: list, execute, approve, cancel, get, run_status, run_list, run_cancel +//! +//! Hands are autonomous capabilities registered in the Kernel's HandRegistry. +//! Hand execution can require approval depending on autonomy level and config. + +use serde::{Deserialize, Serialize}; +use serde_json; +use tauri::{AppHandle, Emitter, State}; + +use super::KernelState; + +// ============================================================================ +// Hands Commands - Autonomous Capabilities +// ============================================================================ + +/// Hand information response for frontend +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HandInfoResponse { + pub id: String, + pub name: String, + pub description: String, + pub status: String, + pub requirements_met: bool, + pub needs_approval: bool, + pub dependencies: Vec, + pub tags: Vec, + pub enabled: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub category: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub icon: Option, + #[serde(default)] + pub tool_count: u32, + #[serde(default)] + pub metric_count: u32, +} + +impl From for HandInfoResponse { + fn from(config: zclaw_hands::HandConfig) -> Self { + // Determine status based on enabled and dependencies + let status = if !config.enabled { + "unavailable".to_string() + } else if config.needs_approval { + "needs_approval".to_string() + } else { + "idle".to_string() + }; + + // Extract category from tags if present + let category = config.tags.iter().find(|t| { + ["research", "automation", "browser", "data", "media", "communication"].contains(&t.as_str()) + }).cloned(); + + // Map tags to icon + let icon = if config.tags.contains(&"browser".to_string()) { + Some("globe".to_string()) + } else if config.tags.contains(&"research".to_string()) { + Some("search".to_string()) + } else if config.tags.contains(&"media".to_string()) { + Some("video".to_string()) + } else if config.tags.contains(&"data".to_string()) { + Some("database".to_string()) + } else if config.tags.contains(&"communication".to_string()) { + Some("message-circle".to_string()) + } else { + Some("zap".to_string()) + }; + + Self { + id: config.id, + name: config.name, + description: config.description, + status, + requirements_met: config.enabled && config.dependencies.is_empty(), + needs_approval: config.needs_approval, + dependencies: config.dependencies, + tags: config.tags, + enabled: config.enabled, + category, + icon, + tool_count: 0, + metric_count: 0, + } + } +} + +/// Hand execution result +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HandResult { + pub success: bool, + pub output: serde_json::Value, + pub error: Option, + pub duration_ms: Option, +} + +impl From for HandResult { + fn from(result: zclaw_hands::HandResult) -> Self { + Self { + success: result.success, + output: result.output, + error: result.error, + duration_ms: result.duration_ms, + } + } +} + +/// List all registered hands +/// +/// Returns hands from the Kernel's HandRegistry. +/// Hands are registered during kernel initialization. +#[tauri::command] +pub async fn hand_list( + state: State<'_, KernelState>, +) -> Result, 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 hands = kernel.list_hands().await; + Ok(hands.into_iter().map(HandInfoResponse::from).collect()) +} + +/// Execute a hand +/// +/// Executes a hand with the given ID and input. +/// If the hand has `needs_approval = true`, creates a pending approval instead. +/// Returns the hand result as JSON, or a pending status with approval ID. +#[tauri::command] +pub async fn hand_execute( + state: State<'_, KernelState>, + id: String, + input: serde_json::Value, + autonomy_level: Option, +) -> Result { + let kernel_lock = state.lock().await; + + let kernel = kernel_lock.as_ref() + .ok_or_else(|| "Kernel not initialized. Call kernel_init first.".to_string())?; + + // Autonomy guard: supervised mode requires approval for ALL hands + if autonomy_level.as_deref() == Some("supervised") { + let approval = kernel.create_approval(id.clone(), input).await; + return Ok(HandResult { + success: false, + output: serde_json::json!({ + "status": "pending_approval", + "approval_id": approval.id, + "hand_id": approval.hand_id, + "message": "监督模式下所有 Hand 执行需要用户审批" + }), + error: None, + duration_ms: None, + }); + } + + // Check if hand requires approval (assisted mode or no autonomy level specified). + // In autonomous mode, the user has opted in to bypass per-hand approval gates. + if autonomy_level.as_deref() != Some("autonomous") { + let hands = kernel.list_hands().await; + if let Some(hand_config) = hands.iter().find(|h| h.id == id) { + if hand_config.needs_approval { + let approval = kernel.create_approval(id.clone(), input).await; + return Ok(HandResult { + success: false, + output: serde_json::json!({ + "status": "pending_approval", + "approval_id": approval.id, + "hand_id": approval.hand_id, + "message": "This hand requires approval before execution" + }), + error: None, + duration_ms: None, + }); + } + } + } + + // Execute hand directly (returns result + run_id for tracking) + let (result, _run_id) = kernel.execute_hand(&id, input).await + .map_err(|e| format!("Failed to execute hand: {}", e))?; + + Ok(HandResult::from(result)) +} + +/// Approve a hand execution +/// +/// When approved, the kernel's `respond_to_approval` internally spawns the Hand +/// execution. We additionally emit Tauri events so the frontend can track when +/// the execution finishes. +#[tauri::command] +pub async fn hand_approve( + app: AppHandle, + state: State<'_, KernelState>, + hand_name: String, + run_id: String, + approved: bool, + reason: Option, +) -> Result { + let kernel_lock = state.lock().await; + let kernel = kernel_lock.as_ref() + .ok_or_else(|| "Kernel not initialized".to_string())?; + + tracing::info!( + "[hand_approve] hand={}, run_id={}, approved={}, reason={:?}", + hand_name, run_id, approved, reason + ); + + // Verify the approval belongs to the specified hand before responding. + // This prevents cross-hand approval attacks where a run_id from one hand + // is used to approve a different hand's pending execution. + let approvals = kernel.list_approvals().await; + let entry = approvals.iter().find(|a| a.id == run_id && a.status == "pending") + .ok_or_else(|| format!("Approval not found or already resolved: {}", run_id))?; + + if entry.hand_id != hand_name { + return Err(format!( + "Approval run_id {} belongs to hand '{}', not '{}' as requested", + run_id, entry.hand_id, hand_name + )); + } + + kernel.respond_to_approval(&run_id, approved, reason).await + .map_err(|e| format!("Failed to approve hand: {}", e))?; + + // When approved, monitor the Hand execution and emit events to the frontend + if approved { + let approval_id = run_id.clone(); + let hand_id = hand_name.clone(); + let kernel_state: KernelState = (*state).clone(); + + tokio::spawn(async move { + // Poll the approval status until it transitions from "approved" to + // "completed" or "failed" (set by the kernel's spawned task). + // Timeout after 5 minutes to avoid hanging forever. + let timeout = tokio::time::Duration::from_secs(300); + let poll_interval = tokio::time::Duration::from_millis(500); + + let result = tokio::time::timeout(timeout, async { + loop { + tokio::time::sleep(poll_interval).await; + + let kernel_lock = kernel_state.lock().await; + if let Some(kernel) = kernel_lock.as_ref() { + // Use get_approval to check any status (not just "pending") + if let Some(entry) = kernel.get_approval(&approval_id).await { + match entry.status.as_str() { + "completed" => { + tracing::info!("[hand_approve] Hand '{}' execution completed for approval {}", hand_id, approval_id); + return (true, None::); + } + "failed" => { + let error_msg = entry.input.get("error") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown error") + .to_string(); + tracing::warn!("[hand_approve] Hand '{}' execution failed for approval {}: {}", hand_id, approval_id, error_msg); + return (false, Some(error_msg)); + } + _ => {} // still running (status is "approved") + } + } else { + // Entry disappeared entirely — kernel was likely restarted + return (false, Some("Approval entry disappeared".to_string())); + } + } else { + return (false, Some("Kernel not available".to_string())); + } + } + }).await; + + let (success, error) = match result { + Ok((s, e)) => (s, e), + Err(_) => (false, Some("Hand execution timed out (5 minutes)".to_string())), + }; + + let _ = app.emit("hand-execution-complete", serde_json::json!({ + "approvalId": approval_id, + "handId": hand_id, + "success": success, + "error": error, + })); + }); + } + + Ok(serde_json::json!({ + "status": if approved { "approved" } else { "rejected" }, + "hand_name": hand_name, + })) +} + +/// Cancel a hand execution +#[tauri::command] +pub async fn hand_cancel( + state: State<'_, KernelState>, + hand_name: String, + run_id: String, +) -> Result { + let kernel_lock = state.lock().await; + let kernel = kernel_lock.as_ref() + .ok_or_else(|| "Kernel not initialized".to_string())?; + + tracing::info!( + "[hand_cancel] hand={}, run_id={}", + hand_name, run_id + ); + + // Verify the approval belongs to the specified hand before cancelling + let approvals = kernel.list_approvals().await; + let entry = approvals.iter().find(|a| a.id == run_id && a.status == "pending") + .ok_or_else(|| format!("Approval not found or already resolved: {}", run_id))?; + + if entry.hand_id != hand_name { + return Err(format!( + "Approval run_id {} belongs to hand '{}', not '{}' as requested", + run_id, entry.hand_id, hand_name + )); + } + + kernel.cancel_approval(&run_id).await + .map_err(|e| format!("Failed to cancel hand: {}", e))?; + + Ok(serde_json::json!({ "status": "cancelled", "hand_name": hand_name })) +} + +// ============================================================ +// Hand Stub Commands (not yet fully implemented) +// ============================================================ + +/// Get detailed info for a single hand +#[tauri::command] +pub async fn hand_get( + state: State<'_, KernelState>, + name: String, +) -> Result { + let kernel_lock = state.lock().await; + let kernel = kernel_lock.as_ref() + .ok_or_else(|| "Kernel not initialized".to_string())?; + + let hands = kernel.list_hands().await; + let found = hands.iter().find(|h| h.id == name) + .ok_or_else(|| format!("Hand '{}' not found", name))?; + + Ok(serde_json::to_value(found) + .map_err(|e| format!("Serialization error: {}", e))?) +} + +/// Get status of a specific hand run +#[tauri::command] +pub async fn hand_run_status( + state: State<'_, KernelState>, + run_id: String, +) -> Result { + let kernel_lock = state.lock().await; + let kernel = kernel_lock.as_ref() + .ok_or_else(|| "Kernel not initialized".to_string())?; + + let parsed_id: zclaw_types::HandRunId = run_id.parse() + .map_err(|e| format!("Invalid run ID: {}", e))?; + + let run = kernel.get_hand_run(&parsed_id).await + .map_err(|e| format!("Failed to get hand run: {}", e))?; + + match run { + Some(r) => Ok(serde_json::to_value(r) + .map_err(|e| format!("Serialization error: {}", e))?), + None => Ok(serde_json::json!({ + "status": "not_found", + "run_id": run_id, + "message": "Hand run not found" + })), + } +} + +/// List run history for a hand (or all hands) +#[tauri::command] +pub async fn hand_run_list( + state: State<'_, KernelState>, + hand_name: Option, + status: Option, + limit: Option, + offset: Option, +) -> Result { + let kernel_lock = state.lock().await; + let kernel = kernel_lock.as_ref() + .ok_or_else(|| "Kernel not initialized".to_string())?; + + let filter = zclaw_types::HandRunFilter { + hand_name, + status: status.map(|s| s.parse()).transpose() + .map_err(|e| format!("Invalid status filter: {}", e))?, + limit, + offset, + }; + + let runs = kernel.list_hand_runs(&filter).await + .map_err(|e| format!("Failed to list hand runs: {}", e))?; + let total = kernel.count_hand_runs(&filter).await + .map_err(|e| format!("Failed to count hand runs: {}", e))?; + + Ok(serde_json::json!({ + "runs": runs, + "total": total, + "limit": filter.limit.unwrap_or(20), + "offset": filter.offset.unwrap_or(0), + })) +} + +/// Cancel a running hand execution +#[tauri::command] +pub async fn hand_run_cancel( + state: State<'_, KernelState>, + run_id: String, +) -> Result { + let kernel_lock = state.lock().await; + let kernel = kernel_lock.as_ref() + .ok_or_else(|| "Kernel not initialized".to_string())?; + + let parsed_id: zclaw_types::HandRunId = run_id.parse() + .map_err(|e| format!("Invalid run ID: {}", e))?; + + kernel.cancel_hand_run(&parsed_id).await + .map_err(|e| format!("Failed to cancel hand run: {}", e))?; + + Ok(serde_json::json!({ + "status": "cancelled", + "run_id": run_id + })) +} diff --git a/desktop/src-tauri/src/kernel_commands/lifecycle.rs b/desktop/src-tauri/src/kernel_commands/lifecycle.rs new file mode 100644 index 0000000..8a5bbd0 --- /dev/null +++ b/desktop/src-tauri/src/kernel_commands/lifecycle.rs @@ -0,0 +1,251 @@ +//! Kernel lifecycle commands: init, status, shutdown + +use serde::{Deserialize, Serialize}; +use tauri::State; + +use super::{KernelState, SchedulerState}; + +// --------------------------------------------------------------------------- +// Request / Response types +// --------------------------------------------------------------------------- + +fn default_api_protocol() -> String { "openai".to_string() } +fn default_kernel_provider() -> String { "openai".to_string() } +fn default_kernel_model() -> String { "gpt-4o-mini".to_string() } + +/// Kernel configuration request +/// +/// Simple configuration: base_url + api_key + model +/// Model ID is passed directly to the API without any transformation +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct KernelConfigRequest { + /// LLM provider (for preset URLs): anthropic, openai, zhipu, kimi, qwen, deepseek, local, custom + #[serde(default = "default_kernel_provider")] + pub provider: String, + /// Model identifier - passed directly to the API + #[serde(default = "default_kernel_model")] + pub model: String, + /// API key + pub api_key: Option, + /// Base URL (optional, uses provider default if not specified) + pub base_url: Option, + /// API protocol: openai or anthropic + #[serde(default = "default_api_protocol")] + pub api_protocol: String, +} + +/// Kernel status response +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct KernelStatusResponse { + pub initialized: bool, + pub agent_count: usize, + pub database_url: Option, + pub base_url: Option, + pub model: Option, +} + +// --------------------------------------------------------------------------- +// Commands +// --------------------------------------------------------------------------- + +/// Initialize the internal ZCLAW Kernel +/// +/// If kernel already exists with the same config, returns existing status. +/// If config changed, reboots kernel with new config. +#[tauri::command] +pub async fn kernel_init( + state: State<'_, KernelState>, + scheduler_state: State<'_, SchedulerState>, + config_request: Option, +) -> Result { + let mut kernel_lock = state.lock().await; + + // Check if we need to reboot kernel with new config + if let Some(kernel) = kernel_lock.as_ref() { + // Get current config from kernel + let current_config = kernel.config(); + + // Check if config changed + let config_changed = if let Some(ref req) = config_request { + let default_base_url = zclaw_kernel::config::KernelConfig::from_provider( + &req.provider, "", &req.model, None, &req.api_protocol + ).llm.base_url; + let request_base_url = req.base_url.clone().unwrap_or(default_base_url.clone()); + + current_config.llm.model != req.model || + current_config.llm.base_url != request_base_url + } else { + false + }; + + if !config_changed { + // Same config, return existing status + return Ok(KernelStatusResponse { + initialized: true, + agent_count: kernel.list_agents().len(), + database_url: None, + base_url: Some(current_config.llm.base_url.clone()), + model: Some(current_config.llm.model.clone()), + }); + } + + // Config changed, need to reboot kernel + // Shutdown old kernel + if let Err(e) = kernel.shutdown().await { + eprintln!("[kernel_init] Warning: Failed to shutdown old kernel: {}", e); + } + *kernel_lock = None; + } + + // Build configuration from request + let config = if let Some(req) = &config_request { + let api_key = req.api_key.as_deref().unwrap_or(""); + let base_url = req.base_url.as_deref(); + + zclaw_kernel::config::KernelConfig::from_provider( + &req.provider, + api_key, + &req.model, + base_url, + &req.api_protocol, + ) + } else { + zclaw_kernel::config::KernelConfig::default() + }; + + // Debug: print skills directory + if let Some(ref skills_dir) = config.skills_dir { + println!("[kernel_init] Skills directory: {} (exists: {})", skills_dir.display(), skills_dir.exists()); + } else { + println!("[kernel_init] No skills directory configured"); + } + + let base_url = config.llm.base_url.clone(); + let model = config.llm.model.clone(); + + // Boot kernel + let mut kernel = zclaw_kernel::Kernel::boot(config.clone()) + .await + .map_err(|e| format!("Failed to initialize kernel: {}", e))?; + + let agent_count = kernel.list_agents().len(); + + // Configure extraction driver so the Growth system can call LLM for memory extraction + let driver = kernel.driver(); + crate::intelligence::extraction_adapter::configure_extraction_driver( + driver.clone(), + model.clone(), + ); + + // Bridge SqliteStorage to Kernel's GrowthIntegration + { + match crate::viking_commands::get_storage().await { + Ok(sqlite_storage) => { + let viking = std::sync::Arc::new(zclaw_runtime::VikingAdapter::new(sqlite_storage)); + kernel.set_viking(viking); + tracing::info!("[kernel_init] Bridged persistent SqliteStorage to Kernel GrowthIntegration"); + } + Err(e) => { + tracing::warn!( + "[kernel_init] Failed to get SqliteStorage, GrowthIntegration will use in-memory storage: {}", + e + ); + } + } + + // Set the LLM extraction driver on the kernel for memory extraction via middleware + let extraction_driver = crate::intelligence::extraction_adapter::TauriExtractionDriver::new( + driver.clone(), + model.clone(), + ); + kernel.set_extraction_driver(std::sync::Arc::new(extraction_driver)); + } + + // Configure summary driver so the Growth system can generate L0/L1 summaries + if let Some(api_key) = config_request.as_ref().and_then(|r| r.api_key.clone()) { + crate::summarizer_adapter::configure_summary_driver( + crate::summarizer_adapter::TauriSummaryDriver::new( + format!("{}/chat/completions", base_url), + api_key, + Some(model.clone()), + ), + ); + } + + *kernel_lock = Some(kernel); + + // Start SchedulerService — periodically checks and fires scheduled triggers + { + let mut sched_lock = scheduler_state.lock().await; + // Stop old scheduler if any + if let Some(ref old) = *sched_lock { + old.stop(); + } + let scheduler = zclaw_kernel::scheduler::SchedulerService::new( + state.inner().clone(), + 60, // check every 60 seconds + ); + scheduler.start(); + tracing::info!("[kernel_init] SchedulerService started (60s interval)"); + *sched_lock = Some(scheduler); + } + + Ok(KernelStatusResponse { + initialized: true, + agent_count, + database_url: Some(config.database_url), + base_url: Some(base_url), + model: Some(model), + }) +} + +/// Get kernel status +#[tauri::command] +pub async fn kernel_status( + state: State<'_, KernelState>, +) -> Result { + let kernel_lock = state.lock().await; + + match kernel_lock.as_ref() { + Some(kernel) => Ok(KernelStatusResponse { + initialized: true, + agent_count: kernel.list_agents().len(), + database_url: Some(kernel.config().database_url.clone()), + base_url: Some(kernel.config().llm.base_url.clone()), + model: Some(kernel.config().llm.model.clone()), + }), + None => Ok(KernelStatusResponse { + initialized: false, + agent_count: 0, + database_url: None, + base_url: None, + model: None, + }), + } +} + +/// Shutdown the kernel +#[tauri::command] +pub async fn kernel_shutdown( + state: State<'_, KernelState>, + scheduler_state: State<'_, SchedulerState>, +) -> Result<(), String> { + // Stop scheduler first + { + let mut sched_lock = scheduler_state.lock().await; + if let Some(scheduler) = sched_lock.take() { + scheduler.stop(); + tracing::info!("[kernel_shutdown] SchedulerService stopped"); + } + } + + let mut kernel_lock = state.lock().await; + + if let Some(kernel) = kernel_lock.take() { + kernel.shutdown().await.map_err(|e| e.to_string())?; + } + + Ok(()) +} diff --git a/desktop/src-tauri/src/kernel_commands/mod.rs b/desktop/src-tauri/src/kernel_commands/mod.rs new file mode 100644 index 0000000..32abaf6 --- /dev/null +++ b/desktop/src-tauri/src/kernel_commands/mod.rs @@ -0,0 +1,72 @@ +//! ZCLAW Kernel commands for Tauri +//! +//! These commands provide direct access to the internal ZCLAW Kernel, +//! eliminating the need for external ZCLAW process. + +use std::sync::Arc; +use tokio::sync::Mutex; +use zclaw_kernel::Kernel; + +pub mod agent; +pub mod approval; +pub mod chat; +pub mod hand; +pub mod lifecycle; +pub mod scheduled_task; +pub mod skill; +pub mod trigger; + +#[cfg(feature = "multi-agent")] +pub mod a2a; + +// --------------------------------------------------------------------------- +// Shared state types +// --------------------------------------------------------------------------- + +/// Kernel state wrapper for Tauri +pub type KernelState = Arc>>; + +/// Scheduler state — holds a reference to the SchedulerService so it can be stopped on shutdown +pub type SchedulerState = Arc>>; + +/// Session-level stream concurrency guard. +/// Prevents two concurrent `agent_chat_stream` calls from interleaving events +/// for the same session_id. +pub type SessionStreamGuard = Arc>>>; + +// --------------------------------------------------------------------------- +// Shared validation helpers +// --------------------------------------------------------------------------- + +/// Validate an agent ID string with clear error messages +pub(crate) fn validate_agent_id(agent_id: &str) -> Result { + crate::intelligence::validation::validate_identifier(agent_id, "agent_id") + .map_err(|e| format!("Invalid agent_id: {}", e))?; + // AgentId is a UUID wrapper — validate UUID format for better error messages + if agent_id.contains('-') { + crate::intelligence::validation::validate_uuid(agent_id, "agent_id") + .map_err(|e| format!("Invalid agent_id: {}", e))?; + } + Ok(agent_id.to_string()) +} + +/// Validate a generic ID string (for skills, hands, triggers, etc.) +pub(crate) fn validate_id(id: &str, field_name: &str) -> Result { + crate::intelligence::validation::validate_identifier(id, field_name) + .map_err(|e| format!("Invalid {}: {}", field_name, e))?; + Ok(id.to_string()) +} + +// --------------------------------------------------------------------------- +// State constructors +// --------------------------------------------------------------------------- + +/// Create the kernel state for Tauri +pub fn create_kernel_state() -> KernelState { + Arc::new(Mutex::new(None)) +} + +/// Create the scheduler state for Tauri +pub fn create_scheduler_state() -> SchedulerState { + Arc::new(Mutex::new(None)) +} diff --git a/desktop/src-tauri/src/kernel_commands/scheduled_task.rs b/desktop/src-tauri/src/kernel_commands/scheduled_task.rs new file mode 100644 index 0000000..a94172a --- /dev/null +++ b/desktop/src-tauri/src/kernel_commands/scheduled_task.rs @@ -0,0 +1,124 @@ +//! Scheduled task commands +//! +//! Tasks are backed by kernel triggers (Schedule type). +//! The SchedulerService checks every 60 seconds for due triggers. + +use serde::{Deserialize, Serialize}; +use tauri::State; + +use super::KernelState; + +// ============================================================ +// Scheduled Task Commands +// ============================================================ + +/// Request to create a scheduled task (maps to kernel trigger) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateScheduledTaskRequest { + pub name: String, + pub schedule: String, + pub schedule_type: String, + pub target: Option, + pub description: Option, + pub enabled: Option, +} + +/// Target for a scheduled task +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ScheduledTaskTarget { + #[serde(rename = "type")] + pub target_type: String, + pub id: String, +} + +/// Response for scheduled task creation +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ScheduledTaskResponse { + pub id: String, + pub name: String, + pub schedule: String, + pub status: String, +} + +/// Create a scheduled task (backed by kernel TriggerManager) +/// +/// Tasks are automatically executed by the SchedulerService which checks +/// every 60 seconds for due triggers. +#[tauri::command] +pub async fn scheduled_task_create( + state: State<'_, KernelState>, + request: CreateScheduledTaskRequest, +) -> Result { + let kernel_lock = state.lock().await; + let kernel = kernel_lock.as_ref() + .ok_or_else(|| "Kernel not initialized".to_string())?; + + // Build TriggerConfig from request + let trigger_type = match request.schedule_type.as_str() { + "cron" | "schedule" => zclaw_hands::TriggerType::Schedule { + cron: request.schedule.clone(), + }, + "interval" => zclaw_hands::TriggerType::Schedule { + cron: request.schedule.clone(), // interval as simplified cron + }, + "once" => zclaw_hands::TriggerType::Schedule { + cron: request.schedule.clone(), + }, + _ => return Err(format!("Unsupported schedule type: {}", request.schedule_type)), + }; + + let target_id = request.target.as_ref().map(|t| t.id.clone()).unwrap_or_default(); + let task_id = format!("sched_{}", chrono::Utc::now().timestamp_millis()); + + let config = zclaw_hands::TriggerConfig { + id: task_id.clone(), + name: request.name.clone(), + hand_id: target_id, + trigger_type, + enabled: request.enabled.unwrap_or(true), + max_executions_per_hour: 60, + }; + + let entry = kernel.create_trigger(config).await + .map_err(|e| format!("Failed to create scheduled task: {}", e))?; + + Ok(ScheduledTaskResponse { + id: entry.config.id, + name: entry.config.name, + schedule: request.schedule, + status: "active".to_string(), + }) +} + +/// List all scheduled tasks (kernel triggers of Schedule type) +#[tauri::command] +pub async fn scheduled_task_list( + state: State<'_, KernelState>, +) -> Result, String> { + let kernel_lock = state.lock().await; + let kernel = kernel_lock.as_ref() + .ok_or_else(|| "Kernel not initialized".to_string())?; + + let triggers = kernel.list_triggers().await; + let tasks: Vec = triggers + .into_iter() + .filter(|t| matches!(t.config.trigger_type, zclaw_hands::TriggerType::Schedule { .. })) + .map(|t| { + let schedule = match t.config.trigger_type { + zclaw_hands::TriggerType::Schedule { cron } => cron, + _ => String::new(), + }; + ScheduledTaskResponse { + id: t.config.id, + name: t.config.name, + schedule, + status: if t.config.enabled { "active".to_string() } else { "paused".to_string() }, + } + }) + .collect(); + + Ok(tasks) +} diff --git a/desktop/src-tauri/src/kernel_commands/skill.rs b/desktop/src-tauri/src/kernel_commands/skill.rs new file mode 100644 index 0000000..7de59ae --- /dev/null +++ b/desktop/src-tauri/src/kernel_commands/skill.rs @@ -0,0 +1,350 @@ +//! Skill CRUD + execute commands +//! +//! Skills are loaded from the Kernel's SkillRegistry. +//! Skills are registered during kernel initialization. + +use std::path::PathBuf; +use serde::{Deserialize, Serialize}; +use serde_json; +use tauri::State; +use zclaw_types::SkillId; + +use super::{validate_id, KernelState}; +use crate::intelligence::validation::validate_identifier; + +// ============================================================================ +// Skills Commands - Dynamic Discovery +// ============================================================================ + +/// Skill information response for frontend +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SkillInfoResponse { + pub id: String, + pub name: String, + pub description: String, + pub version: String, + pub capabilities: Vec, + pub tags: Vec, + pub mode: String, + pub enabled: bool, + pub triggers: Vec, + pub category: Option, +} + +impl From for SkillInfoResponse { + fn from(manifest: zclaw_skills::SkillManifest) -> Self { + Self { + id: manifest.id.to_string(), + name: manifest.name, + description: manifest.description, + version: manifest.version, + capabilities: manifest.capabilities, + tags: manifest.tags, + mode: format!("{:?}", manifest.mode), + enabled: manifest.enabled, + triggers: manifest.triggers, + category: manifest.category, + } + } +} + +/// List all discovered skills +/// +/// Returns skills from the Kernel's SkillRegistry. +/// Skills are loaded from the skills/ directory during kernel initialization. +#[tauri::command] +pub async fn skill_list( + state: State<'_, KernelState>, +) -> Result, 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 skills = kernel.list_skills().await; + println!("[skill_list] Found {} skills", skills.len()); + for skill in &skills { + println!("[skill_list] - {} ({})", skill.name, skill.id); + } + Ok(skills.into_iter().map(SkillInfoResponse::from).collect()) +} + +/// Refresh skills from a directory +/// +/// Re-scans the skills directory for new or updated skills. +/// Optionally accepts a custom directory path to scan. +#[tauri::command] +pub async fn skill_refresh( + state: State<'_, KernelState>, + skill_dir: Option, +) -> Result, 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())?; + + // Convert optional string to PathBuf + let dir_path = skill_dir.map(PathBuf::from); + + // Refresh skills + kernel.refresh_skills(dir_path) + .await + .map_err(|e| format!("Failed to refresh skills: {}", e))?; + + // Return updated list + let skills = kernel.list_skills().await; + 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 +// ============================================================================ + +/// Skill execution context +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SkillContext { + pub agent_id: String, + pub session_id: String, + pub working_dir: Option, +} + +impl From for zclaw_skills::SkillContext { + fn from(ctx: SkillContext) -> Self { + Self { + agent_id: ctx.agent_id, + session_id: ctx.session_id, + working_dir: ctx.working_dir.map(std::path::PathBuf::from), + env: std::collections::HashMap::new(), + timeout_secs: 300, + network_allowed: true, + file_access_allowed: true, + llm: None, // Injected by Kernel.execute_skill() + } + } +} + +/// Skill execution result +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SkillResult { + pub success: bool, + pub output: serde_json::Value, + pub error: Option, + pub duration_ms: Option, +} + +impl From for SkillResult { + fn from(result: zclaw_skills::SkillResult) -> Self { + Self { + success: result.success, + output: result.output, + error: result.error, + duration_ms: result.duration_ms, + } + } +} + +/// Execute a skill +/// +/// Executes a skill with the given ID and input. +/// Returns the skill result as JSON. +#[tauri::command] +pub async fn skill_execute( + state: State<'_, KernelState>, + id: String, + context: SkillContext, + input: serde_json::Value, + autonomy_level: Option, +) -> Result { + // Validate skill ID + let id = validate_id(&id, "skill_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())?; + + // Autonomy guard: supervised mode creates an approval request for ALL skills + if autonomy_level.as_deref() == Some("supervised") { + let approval = kernel.create_approval(id.clone(), input).await; + return Ok(SkillResult { + success: false, + output: serde_json::json!({ + "status": "pending_approval", + "approval_id": approval.id, + "skill_id": approval.hand_id, + "message": "监督模式下所有技能执行需要用户审批" + }), + error: None, + duration_ms: None, + }); + } + + // Assisted mode: require approval for non-prompt skills (shell/python) that have side effects + if autonomy_level.as_deref() != Some("autonomous") { + let skill_id = SkillId::new(&id); + if let Some(manifest) = kernel.skills().get_manifest(&skill_id).await { + match manifest.mode { + zclaw_skills::SkillMode::Shell | zclaw_skills::SkillMode::Python => { + let approval = kernel.create_approval(id.clone(), input).await; + return Ok(SkillResult { + success: false, + output: serde_json::json!({ + "status": "pending_approval", + "approval_id": approval.id, + "skill_id": approval.hand_id, + "message": format!("技能 '{}' 使用 {:?} 模式,需要用户审批后执行", manifest.name, manifest.mode) + }), + error: None, + duration_ms: None, + }); + } + _ => {} // PromptOnly and other modes are safe to execute directly + } + } + } + + // Execute skill directly + let result = kernel.execute_skill(&id, context.into(), input).await + .map_err(|e| format!("Failed to execute skill: {}", e))?; + + Ok(SkillResult::from(result)) +} diff --git a/desktop/src-tauri/src/kernel_commands/trigger.rs b/desktop/src-tauri/src/kernel_commands/trigger.rs new file mode 100644 index 0000000..016cb4a --- /dev/null +++ b/desktop/src-tauri/src/kernel_commands/trigger.rs @@ -0,0 +1,242 @@ +//! Trigger commands: CRUD + execute +//! +//! Triggers are registered in the Kernel's TriggerManager. + +use serde::{Deserialize, Serialize}; +use serde_json; +use tauri::State; + +use super::{validate_id, KernelState}; + +// ============================================================ +// Trigger Commands +// ============================================================ + +/// Trigger configuration for creation/update +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TriggerConfigRequest { + pub id: String, + pub name: String, + pub hand_id: String, + pub trigger_type: TriggerTypeRequest, + #[serde(default = "default_trigger_enabled")] + pub enabled: bool, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub tags: Vec, +} + +fn default_trigger_enabled() -> bool { true } + +/// Trigger type for API +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum TriggerTypeRequest { + Schedule { cron: String }, + Event { pattern: String }, + Webhook { path: String, secret: Option }, + MessagePattern { pattern: String }, + FileSystem { path: String, events: Vec }, + Manual, +} + +/// Trigger response +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TriggerResponse { + pub id: String, + pub name: String, + pub hand_id: String, + pub trigger_type: TriggerTypeRequest, + pub enabled: bool, + pub created_at: String, + pub modified_at: String, + pub description: Option, + pub tags: Vec, +} + +impl From for TriggerResponse { + fn from(entry: zclaw_kernel::trigger_manager::TriggerEntry) -> Self { + let trigger_type = match entry.config.trigger_type { + zclaw_hands::TriggerType::Schedule { cron } => { + TriggerTypeRequest::Schedule { cron } + } + zclaw_hands::TriggerType::Event { pattern } => { + TriggerTypeRequest::Event { pattern } + } + zclaw_hands::TriggerType::Webhook { path, secret } => { + TriggerTypeRequest::Webhook { path, secret } + } + zclaw_hands::TriggerType::MessagePattern { pattern } => { + TriggerTypeRequest::MessagePattern { pattern } + } + zclaw_hands::TriggerType::FileSystem { path, events } => { + TriggerTypeRequest::FileSystem { + path, + events: events.iter().map(|e| format!("{:?}", e).to_lowercase()).collect(), + } + } + zclaw_hands::TriggerType::Manual => TriggerTypeRequest::Manual, + }; + + Self { + id: entry.config.id, + name: entry.config.name, + hand_id: entry.config.hand_id, + trigger_type, + enabled: entry.config.enabled, + created_at: entry.created_at.to_rfc3339(), + modified_at: entry.modified_at.to_rfc3339(), + description: entry.description, + tags: entry.tags, + } + } +} + +/// List all triggers +#[tauri::command] +pub async fn trigger_list( + state: State<'_, KernelState>, +) -> Result, String> { + let kernel_lock = state.lock().await; + let kernel = kernel_lock.as_ref() + .ok_or_else(|| "Kernel not initialized".to_string())?; + + let triggers = kernel.list_triggers().await; + Ok(triggers.into_iter().map(TriggerResponse::from).collect()) +} + +/// Get a specific trigger +#[tauri::command] +pub async fn trigger_get( + state: State<'_, KernelState>, + id: String, +) -> Result, String> { + // Validate trigger ID + let id = validate_id(&id, "trigger_id")?; + + let kernel_lock = state.lock().await; + let kernel = kernel_lock.as_ref() + .ok_or_else(|| "Kernel not initialized".to_string())?; + + Ok(kernel.get_trigger(&id).await.map(TriggerResponse::from)) +} + +/// Create a new trigger +#[tauri::command] +pub async fn trigger_create( + state: State<'_, KernelState>, + request: TriggerConfigRequest, +) -> Result { + let kernel_lock = state.lock().await; + let kernel = kernel_lock.as_ref() + .ok_or_else(|| "Kernel not initialized".to_string())?; + + // Convert request to config + let trigger_type = match request.trigger_type { + TriggerTypeRequest::Schedule { cron } => { + zclaw_hands::TriggerType::Schedule { cron } + } + TriggerTypeRequest::Event { pattern } => { + zclaw_hands::TriggerType::Event { pattern } + } + TriggerTypeRequest::Webhook { path, secret } => { + zclaw_hands::TriggerType::Webhook { path, secret } + } + TriggerTypeRequest::MessagePattern { pattern } => { + zclaw_hands::TriggerType::MessagePattern { pattern } + } + TriggerTypeRequest::FileSystem { path, events } => { + zclaw_hands::TriggerType::FileSystem { + path, + events: events.iter().filter_map(|e| match e.as_str() { + "created" => Some(zclaw_hands::FileEvent::Created), + "modified" => Some(zclaw_hands::FileEvent::Modified), + "deleted" => Some(zclaw_hands::FileEvent::Deleted), + "any" => Some(zclaw_hands::FileEvent::Any), + _ => None, + }).collect(), + } + } + TriggerTypeRequest::Manual => zclaw_hands::TriggerType::Manual, + }; + + let config = zclaw_hands::TriggerConfig { + id: request.id, + name: request.name, + hand_id: request.hand_id, + trigger_type, + enabled: request.enabled, + max_executions_per_hour: 10, + }; + + let entry = kernel.create_trigger(config).await + .map_err(|e| format!("Failed to create trigger: {}", e))?; + + Ok(TriggerResponse::from(entry)) +} + +/// Update a trigger +#[tauri::command] +pub async fn trigger_update( + state: State<'_, KernelState>, + id: String, + name: Option, + enabled: Option, + hand_id: Option, +) -> Result { + let kernel_lock = state.lock().await; + let kernel = kernel_lock.as_ref() + .ok_or_else(|| "Kernel not initialized".to_string())?; + + let update = zclaw_kernel::trigger_manager::TriggerUpdateRequest { + name, + enabled, + hand_id, + trigger_type: None, + }; + + let entry = kernel.update_trigger(&id, update).await + .map_err(|e| format!("Failed to update trigger: {}", e))?; + + Ok(TriggerResponse::from(entry)) +} + +/// Delete a trigger +#[tauri::command] +pub async fn trigger_delete( + state: State<'_, KernelState>, + id: String, +) -> Result<(), String> { + // Validate trigger ID + let id = validate_id(&id, "trigger_id")?; + + let kernel_lock = state.lock().await; + let kernel = kernel_lock.as_ref() + .ok_or_else(|| "Kernel not initialized".to_string())?; + + kernel.delete_trigger(&id).await + .map_err(|e| format!("Failed to delete trigger: {}", e)) +} + +/// Execute a trigger manually +#[tauri::command] +pub async fn trigger_execute( + state: State<'_, KernelState>, + id: String, + input: serde_json::Value, +) -> Result { + // Validate trigger ID + let id = validate_id(&id, "trigger_id")?; + + let kernel_lock = state.lock().await; + let kernel = kernel_lock.as_ref() + .ok_or_else(|| "Kernel not initialized".to_string())?; + + let result = kernel.execute_trigger(&id, input).await + .map_err(|e| format!("Failed to execute trigger: {}", e))?; + + Ok(serde_json::to_value(result).unwrap_or(serde_json::json!({}))) +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 27b2b9e..2dec273 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -34,1229 +34,16 @@ mod kernel_commands; // Pipeline commands (DSL-based workflows) mod pipeline_commands; +// Gateway sub-modules (runtime, config, io, commands) +mod gateway; + +// Health check commands (top-level module) +mod health_check; + // Development server (optional, for web debugging) #[cfg(feature = "dev-server")] mod dev_server; -use serde::Serialize; -use serde_json::{json, Value}; -use std::fs; -use std::net::{TcpStream, ToSocketAddrs}; -use std::path::PathBuf; -use std::process::Command; -use std::thread; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; -use tauri::{AppHandle, Manager}; - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct LocalGatewayStatus { - supported: bool, - cli_available: bool, - runtime_source: Option, - runtime_path: Option, - service_label: Option, - service_loaded: bool, - service_status: Option, - config_ok: bool, - port: Option, - port_status: Option, - probe_url: Option, - listener_pids: Vec, - error: Option, - raw: Value, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct LocalGatewayAuth { - config_path: Option, - gateway_token: Option, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct LocalGatewayPrepareResult { - config_path: Option, - origins_updated: bool, - gateway_restarted: bool, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct LocalGatewayPairingApprovalResult { - approved: bool, - request_id: Option, - device_id: Option, -} - -struct ZclawRuntime { - source: String, - executable: PathBuf, - pre_args: Vec, - display_path: PathBuf, -} - -struct ZclawCommandOutput { - stdout: String, - runtime: ZclawRuntime, -} - -/// Default ZCLAW Kernel port -const ZCLAW_DEFAULT_PORT: u16 = 4200; - -const TAURI_ALLOWED_ORIGINS: [&str; 2] = ["http://tauri.localhost", "tauri://localhost"]; - -fn command_error(runtime: &ZclawRuntime, error: std::io::Error) -> String { - if error.kind() == std::io::ErrorKind::NotFound { - match runtime.source.as_str() { - "bundled" => format!( - "未找到 ZCLAW 内置运行时:{}", - runtime.display_path.display() - ), - "development" => format!( - "未找到开发态运行时:{}", - runtime.display_path.display() - ), - "override" => format!( - "未找到 ZCLAW_BIN 指定的运行时:{}", - runtime.display_path.display() - ), - _ => "未找到运行时。请重新安装 ZCLAW,或在开发环境中安装 ZCLAW CLI。" - .to_string(), - } - } else { - format!("运行 ZCLAW 失败: {error}") - } -} - -fn runtime_path_string(runtime: &ZclawRuntime) -> String { - runtime.display_path.display().to_string() -} - -fn binary_extension() -> &'static str { - if cfg!(target_os = "windows") { - ".exe" - } else { - "" - } -} - -fn zclaw_sidecar_filename() -> String { - format!("zclaw-{}{}", env!("TARGET"), binary_extension()) -} - -fn zclaw_plain_filename() -> String { - format!("zclaw{}", binary_extension()) -} - -fn push_runtime_candidate(candidates: &mut Vec, source: &str, executable: PathBuf) { - if candidates.iter().any(|candidate| candidate.display_path == executable) { - return; - } - - candidates.push(ZclawRuntime { - source: source.to_string(), - display_path: executable.clone(), - executable, - pre_args: Vec::new(), - }); -} - -/// Build binary runtime (ZCLAW is a single binary, not npm package) -fn build_binary_runtime(source: &str, root_dir: &PathBuf) -> Option { - // Try platform-specific binary names - let binary_names = get_platform_binary_names(); - - for name in binary_names { - let binary_path = root_dir.join(&name); - if binary_path.is_file() { - return Some(ZclawRuntime { - source: source.to_string(), - executable: binary_path.clone(), - pre_args: Vec::new(), - display_path: binary_path, - }); - } - } - None -} - -/// Get platform-specific binary names for ZCLAW -fn get_platform_binary_names() -> Vec { - let mut names = Vec::new(); - - if cfg!(target_os = "windows") { - names.push("zclaw.exe".to_string()); - names.push(format!("zclaw-{}.exe", env!("TARGET"))); - } else if cfg!(target_os = "macos") { - if cfg!(target_arch = "aarch64") { - names.push("zclaw-aarch64-apple-darwin".to_string()); - } else { - names.push("zclaw-x86_64-apple-darwin".to_string()); - } - names.push(format!("zclaw-{}", env!("TARGET"))); - names.push("zclaw".to_string()); - } else { - // Linux - if cfg!(target_arch = "aarch64") { - names.push("zclaw-aarch64-unknown-linux-gnu".to_string()); - } else { - names.push("zclaw-x86_64-unknown-linux-gnu".to_string()); - } - names.push(format!("zclaw-{}", env!("TARGET"))); - names.push("zclaw".to_string()); - } - - names -} - -/// Legacy: Build staged runtime using Node.js (for backward compatibility) -fn build_staged_runtime_legacy(source: &str, root_dir: PathBuf) -> Option { - let node_executable = root_dir.join(if cfg!(target_os = "windows") { - "node.exe" - } else { - "node" - }); - let entrypoint = root_dir - .join("node_modules") - .join("zclaw") - .join("zclaw.mjs"); - - if !node_executable.is_file() || !entrypoint.is_file() { - return None; - } - - Some(ZclawRuntime { - source: source.to_string(), - executable: node_executable, - pre_args: vec![entrypoint.display().to_string()], - display_path: root_dir, - }) -} - -/// Build staged runtime - prefers binary, falls back to Node.js for legacy support -fn build_staged_runtime(source: &str, root_dir: PathBuf) -> Option { - // First, try to find the binary directly - if let Some(runtime) = build_binary_runtime(source, &root_dir) { - return Some(runtime); - } - - // Fallback to Node.js-based runtime for backward compatibility - build_staged_runtime_legacy(source, root_dir) -} - -fn push_staged_runtime_candidate(candidates: &mut Vec, source: &str, root_dir: PathBuf) { - if candidates.iter().any(|candidate| candidate.display_path == root_dir) { - return; - } - - if let Some(runtime) = build_staged_runtime(source, root_dir) { - candidates.push(runtime); - } -} - -fn bundled_runtime_candidates(app: &AppHandle) -> Vec { - let mut candidates = Vec::new(); - let sidecar_name = zclaw_sidecar_filename(); - let plain_name = zclaw_plain_filename(); - let platform_names = get_platform_binary_names(); - - if let Ok(resource_dir) = app.path().resource_dir() { - // Primary: zclaw-runtime directory (contains binary + manifest) - push_staged_runtime_candidate( - &mut candidates, - "bundled", - resource_dir.join("zclaw-runtime"), - ); - - // Alternative: binaries directory - for name in &platform_names { - push_runtime_candidate( - &mut candidates, - "bundled", - resource_dir.join("binaries").join(name), - ); - } - - // Alternative: root level binaries - push_runtime_candidate(&mut candidates, "bundled", resource_dir.join(&plain_name)); - push_runtime_candidate(&mut candidates, "bundled", resource_dir.join(&sidecar_name)); - } - - if let Ok(current_exe) = std::env::current_exe() { - if let Some(exe_dir) = current_exe.parent() { - // Windows NSIS installer location - push_staged_runtime_candidate( - &mut candidates, - "bundled", - exe_dir.join("resources").join("zclaw-runtime"), - ); - - // Alternative: binaries next to exe - for name in &platform_names { - push_runtime_candidate( - &mut candidates, - "bundled", - exe_dir.join("binaries").join(name), - ); - } - - push_runtime_candidate(&mut candidates, "bundled", exe_dir.join(&plain_name)); - push_runtime_candidate(&mut candidates, "bundled", exe_dir.join(&sidecar_name)); - } - } - - // Development mode - let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - push_staged_runtime_candidate( - &mut candidates, - "development", - manifest_dir.join("resources").join("zclaw-runtime"), - ); - - for name in &platform_names { - push_runtime_candidate( - &mut candidates, - "development", - manifest_dir.join("binaries").join(name), - ); - } - - candidates -} - -/// Resolve ZCLAW runtime location -/// Priority: ZCLAW_BIN env > bundled > system PATH -fn resolve_zclaw_runtime(app: &AppHandle) -> ZclawRuntime { - if let Ok(override_path) = std::env::var("ZCLAW_BIN") { - let override_path = PathBuf::from(override_path); - if override_path.is_dir() { - if let Some(runtime) = build_staged_runtime("override", override_path.clone()) { - return runtime; - } - } - - return ZclawRuntime { - source: "override".to_string(), - display_path: override_path.clone(), - executable: override_path, - pre_args: Vec::new(), - }; - } - - if let Some(runtime) = bundled_runtime_candidates(app) - .into_iter() - .find(|candidate| candidate.executable.is_file()) - { - return runtime; - } - - ZclawRuntime { - source: "system".to_string(), - display_path: PathBuf::from("zclaw"), - executable: PathBuf::from("zclaw"), - pre_args: Vec::new(), - } -} - -/// Resolve ZCLAW config path (TOML format) -/// Priority: ZCLAW_HOME env > ~/.zclaw/ -fn resolve_zclaw_config_path() -> Option { - if let Ok(value) = std::env::var("ZCLAW_HOME") { - return Some(PathBuf::from(value).join("zclaw.toml")); - } - - if let Ok(value) = std::env::var("HOME") { - return Some(PathBuf::from(value).join(".zclaw").join("zclaw.toml")); - } - - if let Ok(value) = std::env::var("USERPROFILE") { - return Some(PathBuf::from(value).join(".zclaw").join("zclaw.toml")); - } - - None -} - -/// Parse TOML config and extract gateway token -fn read_local_gateway_auth() -> Result { - let config_path = resolve_zclaw_config_path() - .ok_or_else(|| "未找到 ZCLAW 配置目录。".to_string())?; - let config_text = fs::read_to_string(&config_path) - .map_err(|error| format!("读取 ZCLAW 配置失败: {error}"))?; - - // Parse TOML format - simple extraction for gateway.token - let gateway_token = extract_toml_token(&config_text); - - Ok(LocalGatewayAuth { - config_path: Some(config_path.display().to_string()), - gateway_token, - }) -} - -/// Extract gateway.token from TOML config text -fn extract_toml_token(config_text: &str) -> Option { - // Simple TOML parsing for gateway.token - // Format: token = "value" under [gateway] section - let mut in_gateway_section = false; - for line in config_text.lines() { - let trimmed = line.trim(); - if trimmed.starts_with("[gateway") { - in_gateway_section = true; - continue; - } - if trimmed.starts_with('[') && !trimmed.starts_with("[gateway") { - in_gateway_section = false; - continue; - } - if in_gateway_section && trimmed.starts_with("token") { - if let Some(eq_pos) = trimmed.find('=') { - let value = trimmed[eq_pos + 1..].trim(); - // Remove quotes - let value = value.trim_matches('"').trim_matches('\''); - if !value.is_empty() { - return Some(value.to_string()); - } - } - } - } - None -} - -/// Ensure Tauri origins are allowed in ZCLAW config -fn ensure_tauri_allowed_origins(config_text: &str) -> (String, bool) { - let mut lines: Vec = config_text.lines().map(|s| s.to_string()).collect(); - let mut changed = false; - let mut in_control_ui = false; - let mut has_allowed_origins = false; - - // Find or create [gateway.controlUi] section with allowedOrigins - for i in 0..lines.len() { - let trimmed = lines[i].trim(); - - if trimmed.starts_with("[gateway.controlUi") || trimmed == "[gateway.controlUi]" { - in_control_ui = true; - } else if trimmed.starts_with('[') && in_control_ui { - in_control_ui = false; - } - - if in_control_ui && trimmed.starts_with("allowedOrigins") { - has_allowed_origins = true; - // Check if all required origins are present - for origin in TAURI_ALLOWED_ORIGINS { - if !lines[i].contains(origin) { - // Append origin to the array - // This is a simple approach - for production, use proper TOML parsing - if lines[i].ends_with(']') { - let insert_pos = lines[i].len() - 1; - lines[i].insert_str(insert_pos, &format!(", \"{}\"", origin)); - changed = true; - } - } - } - } - } - - // If no allowedOrigins found, add the section - if !has_allowed_origins { - // Find [gateway] section and add controlUi after it - for i in 0..lines.len() { - if lines[i].trim().starts_with("[gateway]") || lines[i].trim() == "[gateway]" { - // Insert controlUi section after gateway - let origins: String = TAURI_ALLOWED_ORIGINS - .iter() - .map(|s| format!("\"{}\"", s)) - .collect::>() - .join(", "); - lines.insert(i + 1, format!("[gateway.controlUi]")); - lines.insert(i + 2, format!("allowedOrigins = [{}]", origins)); - changed = true; - break; - } - } - - // If no [gateway] section found, create it - if !changed { - let origins: String = TAURI_ALLOWED_ORIGINS - .iter() - .map(|s| format!("\"{}\"", s)) - .collect::>() - .join(", "); - lines.push("[gateway]".to_string()); - lines.push("[gateway.controlUi]".to_string()); - lines.push(format!("allowedOrigins = [{}]", origins)); - changed = true; - } - } - - (lines.join("\n"), changed) -} - -fn ensure_local_gateway_ready_for_tauri(app: &AppHandle) -> Result { - let config_path = resolve_zclaw_config_path() - .ok_or_else(|| "未找到 ZCLAW 配置目录。".to_string())?; - let config_text = fs::read_to_string(&config_path) - .map_err(|error| format!("读取 ZCLAW 配置失败: {error}"))?; - - let (updated_config, origins_updated) = ensure_tauri_allowed_origins(&config_text); - - if origins_updated { - fs::write(&config_path, format!("{}\n", updated_config)) - .map_err(|error| format!("写入 ZCLAW 配置失败: {error}"))?; - } - - let mut gateway_restarted = false; - if origins_updated { - if let Ok(status) = read_gateway_status(app) { - if status.port_status.as_deref() == Some("busy") || !status.listener_pids.is_empty() { - run_zclaw(app, &["gateway", "restart", "--json"])?; - thread::sleep(Duration::from_millis(1200)); - gateway_restarted = true; - } - } - } - - Ok(LocalGatewayPrepareResult { - config_path: Some(config_path.display().to_string()), - origins_updated, - gateway_restarted, - }) -} - -fn approve_local_device_pairing( - app: &AppHandle, - device_id: &str, - public_key_base64: &str, - url: Option<&str>, -) -> Result { - let local_auth = read_local_gateway_auth()?; - let gateway_token = local_auth - .gateway_token - .ok_or_else(|| "本地 Gateway token 不可用,无法自动批准设备配对。".to_string())?; - - let devices_output = run_zclaw(app, &["devices", "list", "--json"])?; - let devices_json = parse_json_output(&devices_output.stdout)?; - let pending = devices_json - .get("pending") - .and_then(Value::as_array) - .ok_or_else(|| "设备列表输出缺少 pending 数组。".to_string())?; - - let pending_request = pending.iter().find(|entry| { - entry.get("deviceId").and_then(Value::as_str) == Some(device_id) - && entry.get("publicKey").and_then(Value::as_str) == Some(public_key_base64) - }); - - let Some(request) = pending_request else { - return Ok(LocalGatewayPairingApprovalResult { - approved: false, - request_id: None, - device_id: Some(device_id.to_string()), - }); - }; - - let request_id = request - .get("requestId") - .and_then(Value::as_str) - .ok_or_else(|| "待批准设备缺少 requestId。".to_string())? - .to_string(); - - // Use ZCLAW default port 4200 - let gateway_url = url.unwrap_or("ws://127.0.0.1:4200").to_string(); - let args = vec![ - "devices".to_string(), - "approve".to_string(), - request_id.clone(), - "--json".to_string(), - "--token".to_string(), - gateway_token, - "--url".to_string(), - gateway_url, - ]; - let arg_refs = args.iter().map(|value| value.as_str()).collect::>(); - run_zclaw(app, &arg_refs)?; - thread::sleep(Duration::from_millis(300)); - - Ok(LocalGatewayPairingApprovalResult { - approved: true, - request_id: Some(request_id), - device_id: Some(device_id.to_string()), - }) -} - -fn run_zclaw(app: &AppHandle, args: &[&str]) -> Result { - let runtime = resolve_zclaw_runtime(app); - let mut command = Command::new(&runtime.executable); - command.args(&runtime.pre_args).args(args); - let output = command.output().map_err(|error| command_error(&runtime, error))?; - - if output.status.success() { - Ok(ZclawCommandOutput { - stdout: String::from_utf8_lossy(&output.stdout).trim().to_string(), - runtime, - }) - } else { - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - let message = if stderr.is_empty() { - stdout - } else if stdout.is_empty() { - stderr - } else { - format!("{stderr}\n{stdout}") - }; - - if message.is_empty() { - Err(format!("ZCLAW {:?} 执行失败: {}", args, output.status)) - } else { - Err(message) - } - } -} - -fn parse_json_output(stdout: &str) -> Result { - if let Ok(raw) = serde_json::from_str::(stdout) { - return Ok(raw); - } - - if let Some(index) = stdout.find('{') { - let trimmed = &stdout[index..]; - return serde_json::from_str::(trimmed) - .map_err(|error| format!("解析 Gateway 状态失败: {error}")); - } - - Err("Gateway 状态输出不包含可解析的 JSON。".to_string()) -} - -fn unavailable_status(error: String, runtime: Option<&ZclawRuntime>) -> LocalGatewayStatus { - LocalGatewayStatus { - supported: true, - cli_available: false, - runtime_source: runtime.map(|value| value.source.clone()), - runtime_path: runtime.map(runtime_path_string), - service_label: None, - service_loaded: false, - service_status: None, - config_ok: false, - port: None, - port_status: None, - probe_url: None, - listener_pids: Vec::new(), - error: Some(error), - raw: json!({}), - } -} - -fn parse_gateway_status(raw: Value, runtime: &ZclawRuntime) -> LocalGatewayStatus { - let listener_pids = raw - .get("port") - .and_then(|port| port.get("listeners")) - .and_then(Value::as_array) - .map(|listeners| { - listeners - .iter() - .filter_map(|listener| listener.get("pid").and_then(Value::as_u64)) - .filter_map(|pid| u32::try_from(pid).ok()) - .collect::>() - }) - .unwrap_or_default(); - - LocalGatewayStatus { - supported: true, - cli_available: true, - runtime_source: Some(runtime.source.clone()), - runtime_path: Some(runtime_path_string(runtime)), - service_label: raw - .get("service") - .and_then(|service| service.get("label")) - .and_then(Value::as_str) - .map(ToOwned::to_owned), - service_loaded: raw - .get("service") - .and_then(|service| service.get("loaded")) - .and_then(Value::as_bool) - .unwrap_or(false), - service_status: raw - .get("service") - .and_then(|service| service.get("runtime")) - .and_then(|runtime| runtime.get("status")) - .and_then(Value::as_str) - .map(ToOwned::to_owned), - config_ok: raw - .get("service") - .and_then(|service| service.get("configAudit")) - .and_then(|config_audit| config_audit.get("ok")) - .and_then(Value::as_bool) - .unwrap_or(false), - port: raw - .get("gateway") - .and_then(|gateway| gateway.get("port")) - .and_then(Value::as_u64) - .and_then(|port| u16::try_from(port).ok()) - .or(Some(ZCLAW_DEFAULT_PORT)), - port_status: raw - .get("port") - .and_then(|port| port.get("status")) - .and_then(Value::as_str) - .map(ToOwned::to_owned), - probe_url: raw - .get("gateway") - .and_then(|gateway| gateway.get("probeUrl")) - .and_then(Value::as_str) - .map(ToOwned::to_owned), - listener_pids, - error: None, - raw, - } -} - -fn read_gateway_status(app: &AppHandle) -> Result { - match run_zclaw(app, &["gateway", "status", "--json", "--no-probe"]) { - Ok(result) => { - let raw = parse_json_output(&result.stdout)?; - Ok(parse_gateway_status(raw, &result.runtime)) - } - Err(error) => { - let runtime = resolve_zclaw_runtime(app); - Ok(unavailable_status(error, Some(&runtime))) - } - } -} - -// ============================================================================ -// Tauri Commands - ZCLAW (with backward-compatible aliases) -// ============================================================================ - -/// Get ZCLAW Kernel status -#[tauri::command] -fn zclaw_status(app: AppHandle) -> Result { - read_gateway_status(&app) -} - -/// Start ZCLAW Kernel -#[tauri::command] -fn zclaw_start(app: AppHandle) -> Result { - ensure_local_gateway_ready_for_tauri(&app)?; - run_zclaw(&app, &["gateway", "start", "--json"])?; - thread::sleep(Duration::from_millis(800)); - read_gateway_status(&app) -} - -/// Stop ZCLAW Kernel -#[tauri::command] -fn zclaw_stop(app: AppHandle) -> Result { - run_zclaw(&app, &["gateway", "stop", "--json"])?; - thread::sleep(Duration::from_millis(800)); - read_gateway_status(&app) -} - -/// Restart ZCLAW Kernel -#[tauri::command] -fn zclaw_restart(app: AppHandle) -> Result { - ensure_local_gateway_ready_for_tauri(&app)?; - run_zclaw(&app, &["gateway", "restart", "--json"])?; - thread::sleep(Duration::from_millis(1200)); - read_gateway_status(&app) -} - -/// Get local auth token from ZCLAW config -#[tauri::command] -fn zclaw_local_auth() -> Result { - read_local_gateway_auth() -} - -/// Prepare ZCLAW for Tauri (update allowed origins) -#[tauri::command] -fn zclaw_prepare_for_tauri(app: AppHandle) -> Result { - ensure_local_gateway_ready_for_tauri(&app) -} - -/// Approve device pairing request -#[tauri::command] -fn zclaw_approve_device_pairing( - app: AppHandle, - device_id: String, - public_key_base64: String, - url: Option, -) -> Result { - approve_local_device_pairing(&app, &device_id, &public_key_base64, url.as_deref()) -} - -/// Run ZCLAW doctor to diagnose issues -#[tauri::command] -fn zclaw_doctor(app: AppHandle) -> Result { - let result = run_zclaw(&app, &["doctor", "--json"])?; - Ok(result.stdout) -} - -// ============================================================================ -// Process Monitoring Commands -// ============================================================================ - -/// Process information structure -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct ProcessInfo { - pid: u32, - name: String, - status: String, - cpu_percent: Option, - memory_mb: Option, - uptime_seconds: Option, -} - -/// Process list response -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct ProcessListResponse { - processes: Vec, - total_count: usize, - runtime_source: Option, -} - -/// Process logs response -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct ProcessLogsResponse { - pid: Option, - logs: String, - lines: usize, - runtime_source: Option, -} - -/// Version information response -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct VersionResponse { - version: String, - commit: Option, - build_date: Option, - runtime_source: Option, - raw: Value, -} - -/// List ZCLAW processes -#[tauri::command] -fn zclaw_process_list(app: AppHandle) -> Result { - let result = run_zclaw(&app, &["process", "list", "--json"])?; - - let raw = parse_json_output(&result.stdout).unwrap_or_else(|_| json!({"processes": []})); - - let processes: Vec = raw - .get("processes") - .and_then(Value::as_array) - .map(|arr| { - arr.iter() - .filter_map(|p| { - Some(ProcessInfo { - pid: p.get("pid").and_then(Value::as_u64)?.try_into().ok()?, - name: p.get("name").and_then(Value::as_str)?.to_string(), - status: p - .get("status") - .and_then(Value::as_str) - .unwrap_or("unknown") - .to_string(), - cpu_percent: p.get("cpuPercent").and_then(Value::as_f64), - memory_mb: p.get("memoryMb").and_then(Value::as_f64), - uptime_seconds: p.get("uptimeSeconds").and_then(Value::as_u64), - }) - }) - .collect() - }) - .unwrap_or_default(); - - Ok(ProcessListResponse { - total_count: processes.len(), - processes, - runtime_source: Some(result.runtime.source), - }) -} - -/// Get ZCLAW process logs -#[tauri::command] -fn zclaw_process_logs( - app: AppHandle, - pid: Option, - lines: Option, -) -> Result { - let line_count = lines.unwrap_or(100); - let lines_str = line_count.to_string(); - - // Build owned strings first to avoid lifetime issues - let args: Vec = if let Some(pid_value) = pid { - vec![ - "process".to_string(), - "logs".to_string(), - "--pid".to_string(), - pid_value.to_string(), - "--lines".to_string(), - lines_str, - "--json".to_string(), - ] - } else { - vec![ - "process".to_string(), - "logs".to_string(), - "--lines".to_string(), - lines_str, - "--json".to_string(), - ] - }; - - // Convert to &str for the command - let args_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); - let result = run_zclaw(&app, &args_refs)?; - - // Parse the logs - could be JSON array or plain text - let logs = if let Ok(json) = parse_json_output(&result.stdout) { - // If JSON format, extract logs array or convert to string - if let Some(log_lines) = json.get("logs").and_then(Value::as_array) { - log_lines - .iter() - .filter_map(|l| l.as_str()) - .collect::>() - .join("\n") - } else if let Some(log_text) = json.get("log").and_then(Value::as_str) { - log_text.to_string() - } else { - result.stdout.clone() - } - } else { - result.stdout.clone() - }; - - let log_lines_count = logs.lines().count(); - - Ok(ProcessLogsResponse { - pid, - logs, - lines: log_lines_count, - runtime_source: Some(result.runtime.source), - }) -} - -/// Get ZCLAW version information -#[tauri::command] -fn zclaw_version(app: AppHandle) -> Result { - let result = run_zclaw(&app, &["--version", "--json"])?; - - let raw = parse_json_output(&result.stdout).unwrap_or_else(|_| { - // Fallback: try to parse plain text version output - json!({ - "version": result.stdout.trim(), - "raw": result.stdout.trim() - }) - }); - - let version = raw - .get("version") - .and_then(Value::as_str) - .unwrap_or("unknown") - .to_string(); - - let commit = raw.get("commit").and_then(Value::as_str).map(ToOwned::to_owned); - let build_date = raw.get("buildDate").and_then(Value::as_str).map(ToOwned::to_owned); - - Ok(VersionResponse { - version, - commit, - build_date, - runtime_source: Some(result.runtime.source), - raw, - }) -} - -// ============================================================================ -// Health Check Commands -// ============================================================================ - -/// Health status enum -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "lowercase")] -enum HealthStatus { - Healthy, - Unhealthy, -} - -/// Port check result -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct PortCheckResult { - port: u16, - accessible: bool, - latency_ms: Option, - error: Option, -} - -/// Process health details -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct ProcessHealthDetails { - pid: Option, - name: Option, - status: Option, - uptime_seconds: Option, - cpu_percent: Option, - memory_mb: Option, -} - -/// Health check response -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct HealthCheckResponse { - status: HealthStatus, - process: ProcessHealthDetails, - port_check: PortCheckResult, - last_check_timestamp: u64, - checks_performed: Vec, - issues: Vec, - runtime_source: Option, -} - -/// Check if a TCP port is accessible -fn check_port_accessibility(host: &str, port: u16, timeout_ms: u64) -> PortCheckResult { - let addr = format!("{}:{}", host, port); - - // Resolve the address - let socket_addr = match addr.to_socket_addrs() { - Ok(mut addrs) => addrs.next(), - Err(e) => { - return PortCheckResult { - port, - accessible: false, - latency_ms: None, - error: Some(format!("Failed to resolve address: {}", e)), - }; - } - }; - - let Some(socket_addr) = socket_addr else { - return PortCheckResult { - port, - accessible: false, - latency_ms: None, - error: Some("Failed to resolve address".to_string()), - }; - }; - - // Try to connect with timeout - let start = Instant::now(); - - // Use a simple TCP connect with timeout simulation - let result = TcpStream::connect_timeout(&socket_addr, Duration::from_millis(timeout_ms)); - - match result { - Ok(_) => { - let latency = start.elapsed().as_millis() as u64; - PortCheckResult { - port, - accessible: true, - latency_ms: Some(latency), - error: None, - } - } - Err(e) => PortCheckResult { - port, - accessible: false, - latency_ms: None, - error: Some(format!("Connection failed: {}", e)), - }, - } -} - -/// Get process uptime from status command -fn get_process_uptime(status: &LocalGatewayStatus) -> Option { - // Try to extract uptime from raw status data - status - .raw - .get("process") - .and_then(|p| p.get("uptimeSeconds")) - .and_then(Value::as_u64) -} - -/// Perform comprehensive health check on ZCLAW Kernel -#[tauri::command] -fn zclaw_health_check( - app: AppHandle, - port: Option, - timeout_ms: Option, -) -> Result { - let check_port = port.unwrap_or(ZCLAW_DEFAULT_PORT); - let timeout = timeout_ms.unwrap_or(3000); - let mut checks_performed = Vec::new(); - let mut issues = Vec::new(); - - // Get current timestamp - let last_check_timestamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); - - // 1. Check if ZCLAW CLI is available - let runtime = resolve_zclaw_runtime(&app); - let cli_available = runtime.executable.is_file(); - - if !cli_available { - return Ok(HealthCheckResponse { - status: HealthStatus::Unhealthy, - process: ProcessHealthDetails { - pid: None, - name: None, - status: None, - uptime_seconds: None, - cpu_percent: None, - memory_mb: None, - }, - port_check: PortCheckResult { - port: check_port, - accessible: false, - latency_ms: None, - error: Some("ZCLAW CLI not available".to_string()), - }, - last_check_timestamp, - checks_performed: vec!["cli_availability".to_string()], - issues: vec![format!( - "ZCLAW runtime not found at: {}", - runtime.display_path.display() - )], - runtime_source: Some(runtime.source), - }); - } - checks_performed.push("cli_availability".to_string()); - - // 2. Get gateway status - let gateway_status = read_gateway_status(&app)?; - checks_performed.push("gateway_status".to_string()); - - // Check for configuration issues - if !gateway_status.config_ok { - issues.push("Gateway configuration has issues".to_string()); - } - - // 3. Check port accessibility - let port_check = check_port_accessibility("127.0.0.1", check_port, timeout); - checks_performed.push("port_accessibility".to_string()); - - if !port_check.accessible { - issues.push(format!( - "Port {} is not accessible: {}", - check_port, - port_check.error.as_deref().unwrap_or("unknown error") - )); - } - - // 4. Extract process information - let process_health = if !gateway_status.listener_pids.is_empty() { - // Get the first listener PID - let pid = gateway_status.listener_pids[0]; - - // Try to get detailed process info from process list - let process_info = run_zclaw(&app, &["process", "list", "--json"]) - .ok() - .and_then(|result| parse_json_output(&result.stdout).ok()) - .and_then(|json| json.get("processes").and_then(Value::as_array).cloned()); - - let (cpu, memory, uptime) = if let Some(ref processes) = process_info { - let matching = processes - .iter() - .find(|p| p.get("pid").and_then(Value::as_u64) == Some(pid as u64)); - - matching.map_or((None, None, None), |p| { - ( - p.get("cpuPercent").and_then(Value::as_f64), - p.get("memoryMb").and_then(Value::as_f64), - p.get("uptimeSeconds").and_then(Value::as_u64), - ) - }) - } else { - (None, None, get_process_uptime(&gateway_status)) - }; - - ProcessHealthDetails { - pid: Some(pid), - name: Some("zclaw".to_string()), - status: Some( - gateway_status - .service_status - .clone() - .unwrap_or_else(|| "running".to_string()), - ), - uptime_seconds: uptime, - cpu_percent: cpu, - memory_mb: memory, - } - } else { - ProcessHealthDetails { - pid: None, - name: None, - status: gateway_status.service_status.clone(), - uptime_seconds: None, - cpu_percent: None, - memory_mb: None, - } - }; - - // Check if process is running but no listeners - if gateway_status.service_status.as_deref() == Some("running") - && gateway_status.listener_pids.is_empty() - { - issues.push("Service reports running but no listener processes found".to_string()); - } - - // 5. Determine overall health status - let status = if !cli_available { - HealthStatus::Unhealthy - } else if !port_check.accessible { - HealthStatus::Unhealthy - } else if gateway_status.listener_pids.is_empty() { - HealthStatus::Unhealthy - } else if !issues.is_empty() { - // Has some issues but core functionality is working - HealthStatus::Healthy - } else { - HealthStatus::Healthy - }; - - Ok(HealthCheckResponse { - status, - process: process_health, - port_check, - last_check_timestamp, - checks_performed, - issues, - runtime_source: Some(runtime.source), - }) -} - -/// Quick ping to check if ZCLAW is alive (lightweight check) -#[tauri::command] -fn zclaw_ping(app: AppHandle) -> Result { - let port_check = check_port_accessibility("127.0.0.1", ZCLAW_DEFAULT_PORT, 1000); - - if port_check.accessible { - return Ok(true); - } - - // Fallback: check via status command - match run_zclaw(&app, &["gateway", "status", "--json", "--no-probe"]) { - Ok(result) => { - if let Ok(status) = parse_json_output(&result.stdout) { - // Check if there are any listener PIDs - let has_listeners = status - .get("port") - .and_then(|p| p.get("listeners")) - .and_then(Value::as_array) - .map(|arr| !arr.is_empty()) - .unwrap_or(false); - - Ok(has_listeners) - } else { - Ok(false) - } - } - Err(_) => Ok(false), - } -} - #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { // Start development server when dev-server feature is enabled @@ -1325,78 +112,80 @@ pub fn run() { .manage(pipeline_state) .invoke_handler(tauri::generate_handler![ // Internal ZCLAW Kernel commands (preferred) - kernel_commands::kernel_init, - kernel_commands::kernel_status, - kernel_commands::kernel_shutdown, - kernel_commands::agent_create, - 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, - kernel_commands::agent_chat_stream, + kernel_commands::lifecycle::kernel_init, + kernel_commands::lifecycle::kernel_status, + kernel_commands::lifecycle::kernel_shutdown, + kernel_commands::agent::agent_create, + kernel_commands::agent::agent_list, + kernel_commands::agent::agent_get, + kernel_commands::agent::agent_delete, + kernel_commands::agent::agent_update, + kernel_commands::agent::agent_export, + kernel_commands::agent::agent_import, + kernel_commands::chat::agent_chat, + kernel_commands::chat::agent_chat_stream, // Skills commands (dynamic discovery) - kernel_commands::skill_list, - kernel_commands::skill_refresh, - kernel_commands::skill_execute, - kernel_commands::skill_create, - kernel_commands::skill_update, - kernel_commands::skill_delete, + kernel_commands::skill::skill_list, + kernel_commands::skill::skill_refresh, + kernel_commands::skill::skill_execute, + kernel_commands::skill::skill_create, + kernel_commands::skill::skill_update, + kernel_commands::skill::skill_delete, // Hands commands (autonomous capabilities) - kernel_commands::hand_list, - kernel_commands::hand_execute, - kernel_commands::hand_approve, - kernel_commands::hand_cancel, - kernel_commands::hand_get, - kernel_commands::hand_run_status, - kernel_commands::hand_run_list, - kernel_commands::hand_run_cancel, + kernel_commands::hand::hand_list, + kernel_commands::hand::hand_execute, + kernel_commands::hand::hand_approve, + kernel_commands::hand::hand_cancel, + kernel_commands::hand::hand_get, + kernel_commands::hand::hand_run_status, + kernel_commands::hand::hand_run_list, + kernel_commands::hand::hand_run_cancel, // Scheduled task commands - kernel_commands::scheduled_task_create, - kernel_commands::scheduled_task_list, + kernel_commands::scheduled_task::scheduled_task_create, + kernel_commands::scheduled_task::scheduled_task_list, // A2A commands gated behind multi-agent feature #[cfg(feature = "multi-agent")] - kernel_commands::agent_a2a_send, + kernel_commands::a2a::agent_a2a_send, #[cfg(feature = "multi-agent")] - kernel_commands::agent_a2a_broadcast, + kernel_commands::a2a::agent_a2a_broadcast, #[cfg(feature = "multi-agent")] - kernel_commands::agent_a2a_discover, + kernel_commands::a2a::agent_a2a_discover, #[cfg(feature = "multi-agent")] - kernel_commands::agent_a2a_delegate_task, + kernel_commands::a2a::agent_a2a_delegate_task, // 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, - pipeline_commands::pipeline_result, - pipeline_commands::pipeline_runs, - pipeline_commands::pipeline_refresh, - pipeline_commands::route_intent, - pipeline_commands::analyze_presentation, - // ZCLAW commands (new naming) - zclaw_status, - zclaw_start, - zclaw_stop, - zclaw_restart, - zclaw_local_auth, - zclaw_prepare_for_tauri, - zclaw_approve_device_pairing, - zclaw_doctor, - zclaw_health_check, - // Process monitoring commands - zclaw_process_list, - zclaw_process_logs, - zclaw_version, + pipeline_commands::discovery::pipeline_list, + pipeline_commands::discovery::pipeline_get, + pipeline_commands::discovery::pipeline_run, + pipeline_commands::discovery::pipeline_progress, + pipeline_commands::discovery::pipeline_cancel, + pipeline_commands::discovery::pipeline_result, + pipeline_commands::discovery::pipeline_runs, + pipeline_commands::discovery::pipeline_refresh, + pipeline_commands::crud::pipeline_create, + pipeline_commands::crud::pipeline_update, + pipeline_commands::crud::pipeline_delete, + pipeline_commands::intent_router::route_intent, + pipeline_commands::presentation::analyze_presentation, + pipeline_commands::presentation::pipeline_templates, + // ZCLAW gateway commands + gateway::commands::zclaw_status, + gateway::commands::zclaw_start, + gateway::commands::zclaw_stop, + gateway::commands::zclaw_restart, + gateway::commands::zclaw_local_auth, + gateway::commands::zclaw_prepare_for_tauri, + gateway::commands::zclaw_approve_device_pairing, + gateway::commands::zclaw_doctor, // Health check commands - zclaw_ping, + health_check::zclaw_health_check, + // Process monitoring commands + gateway::commands::zclaw_process_list, + gateway::commands::zclaw_process_logs, + gateway::commands::zclaw_version, + // Health check commands + health_check::zclaw_ping, // OpenViking CLI sidecar commands viking_commands::viking_status, viking_commands::viking_add, @@ -1455,15 +244,15 @@ pub fn run() { memory_commands::memory_search, memory_commands::memory_delete, // Trigger management commands - kernel_commands::trigger_list, - kernel_commands::trigger_get, - kernel_commands::trigger_create, - kernel_commands::trigger_update, - kernel_commands::trigger_delete, - kernel_commands::trigger_execute, + kernel_commands::trigger::trigger_list, + kernel_commands::trigger::trigger_get, + kernel_commands::trigger::trigger_create, + kernel_commands::trigger::trigger_update, + kernel_commands::trigger::trigger_delete, + kernel_commands::trigger::trigger_execute, // Approval management commands - kernel_commands::approval_list, - kernel_commands::approval_respond, + kernel_commands::approval::approval_list, + kernel_commands::approval::approval_respond, memory_commands::memory_delete_all, memory_commands::memory_stats, memory_commands::memory_export, diff --git a/desktop/src-tauri/src/pipeline_commands.rs b/desktop/src-tauri/src/pipeline_commands.rs deleted file mode 100644 index 4db06dd..0000000 --- a/desktop/src-tauri/src/pipeline_commands.rs +++ /dev/null @@ -1,1391 +0,0 @@ -//! Pipeline commands for Tauri -//! -//! Commands for discovering, running, and monitoring Pipelines. - -use std::collections::HashMap; -use std::path::PathBuf; -use std::sync::Arc; -use tauri::{AppHandle, Emitter, State}; -use serde::{Deserialize, Serialize}; -use tokio::sync::RwLock; -use serde_json::Value; -use async_trait::async_trait; -use secrecy::SecretString; - -use zclaw_pipeline::{ - Pipeline, RunStatus, - parse_pipeline_yaml, - PipelineExecutor, - ActionRegistry, - LlmActionDriver, - SkillActionDriver, - HandActionDriver, - PipelineMetadata, - PipelineSpec, - PipelineStep, - Action, - ErrorStrategy, -}; -use zclaw_runtime::{LlmDriver, CompletionRequest}; -use zclaw_skills::SkillContext; - -use crate::kernel_commands::KernelState; - -/// Adapter to connect zclaw-runtime LlmDriver to zclaw-pipeline LlmActionDriver -pub struct RuntimeLlmAdapter { - driver: Arc, - default_model: String, -} - -impl RuntimeLlmAdapter { - pub fn new(driver: Arc, default_model: Option) -> Self { - Self { - driver, - default_model: default_model.unwrap_or_else(|| "claude-3-sonnet-20240229".to_string()), - } - } -} - -#[async_trait] -impl LlmActionDriver for RuntimeLlmAdapter { - async fn generate( - &self, - prompt: String, - input: HashMap, - model: Option, - temperature: Option, - max_tokens: Option, - json_mode: bool, - ) -> Result { - tracing::debug!("[RuntimeLlmAdapter] generate called with prompt length: {}", prompt.len()); - tracing::debug!("[RuntimeLlmAdapter] input HashMap contents:"); - for (k, v) in &input { - println!(" {} => {}", k, v); - } - - // Build user content from prompt and input - let user_content = if input.is_empty() { - tracing::debug!("[RuntimeLlmAdapter] WARNING: input is empty, using raw prompt"); - prompt.clone() - } else { - // Inject input values into prompt - // Support multiple placeholder formats: {{key}}, {{ key }}, ${key}, ${inputs.key} - let mut rendered = prompt.clone(); - tracing::debug!("[RuntimeLlmAdapter] Original prompt (first 500 chars): {}", &prompt[..prompt.len().min(500)]); - for (key, value) in &input { - let str_value = if let Some(s) = value.as_str() { - s.to_string() - } else { - value.to_string() - }; - - tracing::debug!("[RuntimeLlmAdapter] Replacing '{}' with '{}'", key, str_value); - - // Replace all common placeholder formats - rendered = rendered.replace(&format!("{{{{{key}}}}}"), &str_value); // {{key}} - rendered = rendered.replace(&format!("{{{{ {key} }}}}"), &str_value); // {{ key }} - rendered = rendered.replace(&format!("${{{key}}}"), &str_value); // ${key} - rendered = rendered.replace(&format!("${{inputs.{key}}}"), &str_value); // ${inputs.key} - } - tracing::debug!("[RuntimeLlmAdapter] Rendered prompt (first 500 chars): {}", &rendered[..rendered.len().min(500)]); - rendered - }; - - // Create message using zclaw_types::Message enum - let messages = vec![zclaw_types::Message::user(user_content)]; - - let request = CompletionRequest { - model: model.unwrap_or_else(|| self.default_model.clone()), - system: None, - messages, - tools: Vec::new(), - max_tokens, - temperature, - stop: Vec::new(), - stream: false, - }; - - let response = self.driver.complete(request) - .await - .map_err(|e| format!("LLM completion failed: {}", e))?; - - // Extract text from response - let text = response.content.iter() - .find_map(|block| match block { - zclaw_runtime::ContentBlock::Text { text } => Some(text.clone()), - _ => None, - }) - .unwrap_or_default(); - - // Safe truncation for UTF-8 strings - let truncated: String = text.chars().take(1000).collect(); - tracing::debug!("[RuntimeLlmAdapter] LLM response text (first 1000 chars): {}", truncated); - - // Parse as JSON if json_mode, otherwise return as string - if json_mode { - // Try to extract JSON from the response (LLM might wrap it in markdown code blocks) - let json_text = if text.contains("```json") { - // Extract JSON from markdown code block - let start = text.find("```json").map(|i| i + 7).unwrap_or(0); - let end = text.rfind("```").unwrap_or(text.len()); - text[start..end].trim().to_string() - } else if text.contains("```") { - // Extract from generic code block - let start = text.find("```").map(|i| i + 3).unwrap_or(0); - let end = text.rfind("```").unwrap_or(text.len()); - text[start..end].trim().to_string() - } else { - text.clone() - }; - - // Safe truncation for UTF-8 strings - let truncated_json: String = json_text.chars().take(500).collect(); - tracing::debug!("[RuntimeLlmAdapter] JSON text to parse (first 500 chars): {}", truncated_json); - - serde_json::from_str(&json_text) - .map_err(|e| { - tracing::debug!("[RuntimeLlmAdapter] JSON parse error: {}", e); - format!("Failed to parse LLM response as JSON: {}\nResponse: {}", e, json_text) - }) - } else { - Ok(Value::String(text)) - } - } -} - -/// Adapter to bridge Kernel skill execution into Pipeline SkillActionDriver -pub struct PipelineSkillDriver { - kernel_state: KernelState, -} - -impl PipelineSkillDriver { - pub fn new(kernel_state: KernelState) -> Self { - Self { kernel_state } - } -} - -#[async_trait] -impl SkillActionDriver for PipelineSkillDriver { - async fn execute( - &self, - skill_id: &str, - input: HashMap, - ) -> Result { - let kernel_lock = self.kernel_state.lock().await; - let kernel = kernel_lock.as_ref() - .ok_or_else(|| "Kernel 未初始化,无法执行技能".to_string())?; - - let context = SkillContext::default(); - let input_value = Value::Object(input.into_iter().collect()); - - tracing::debug!("[PipelineSkillDriver] Executing skill: {}", skill_id); - let result = kernel.execute_skill(skill_id, context, input_value).await - .map_err(|e| format!("技能执行失败: {}", e))?; - - Ok(result.output) - } -} - -/// Adapter to bridge Kernel hand execution into Pipeline HandActionDriver -pub struct PipelineHandDriver { - kernel_state: KernelState, -} - -impl PipelineHandDriver { - pub fn new(kernel_state: KernelState) -> Self { - Self { kernel_state } - } -} - -#[async_trait] -impl HandActionDriver for PipelineHandDriver { - async fn execute( - &self, - hand_id: &str, - action: &str, - params: HashMap, - ) -> Result { - let kernel_lock = self.kernel_state.lock().await; - let kernel = kernel_lock.as_ref() - .ok_or_else(|| "Kernel 未初始化,无法执行 Hand".to_string())?; - - // Build hand input combining action and params - let mut input_map = serde_json::Map::new(); - input_map.insert("action".to_string(), Value::String(action.to_string())); - for (k, v) in params { - input_map.insert(k, v); - } - let input_value = Value::Object(input_map); - - tracing::debug!("[PipelineHandDriver] Executing hand: {} / {}", hand_id, action); - let (result, _run_id) = kernel.execute_hand(hand_id, input_value).await - .map_err(|e| format!("Hand 执行失败: {}", e))?; - - Ok(result.output) - } -} - -/// Pipeline state wrapper for Tauri -pub struct PipelineState { - /// Pipeline executor - pub executor: Arc, - /// Discovered pipelines (id -> Pipeline) - pub pipelines: RwLock>, - /// Pipeline file paths (id -> path) - pub pipeline_paths: RwLock>, -} - -impl PipelineState { - pub fn new(action_registry: Arc) -> Self { - Self { - executor: Arc::new(PipelineExecutor::new(action_registry)), - pipelines: RwLock::new(HashMap::new()), - pipeline_paths: RwLock::new(HashMap::new()), - } - } -} - -/// Pipeline info for list display -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PipelineInfo { - /// Pipeline ID (name) - pub id: String, - /// Display name - pub display_name: String, - /// Description - pub description: String, - /// Category (functional classification) - pub category: String, - /// Industry classification (e.g., "internet", "finance", "healthcare") - pub industry: String, - /// Tags - pub tags: Vec, - /// Icon (emoji) - pub icon: String, - /// Version - pub version: String, - /// Author - pub author: String, - /// Input parameters - pub inputs: Vec, -} - -/// Pipeline input parameter info -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PipelineInputInfo { - /// Parameter name - pub name: String, - /// Input type - pub input_type: String, - /// Is required - pub required: bool, - /// Label - pub label: String, - /// Placeholder - pub placeholder: Option, - /// Default value - pub default: Option, - /// Options (for select/multi-select) - pub options: Vec, -} - -/// Run pipeline request -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct RunPipelineRequest { - /// Pipeline ID - pub pipeline_id: String, - /// Input values - pub inputs: HashMap, -} - -/// Run pipeline response -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct RunPipelineResponse { - /// Run ID - pub run_id: String, - /// Pipeline ID - pub pipeline_id: String, - /// Status - pub status: String, -} - -/// Pipeline run status response -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PipelineRunResponse { - /// Run ID - pub run_id: String, - /// Pipeline ID - pub pipeline_id: String, - /// Status - pub status: String, - /// Current step - pub current_step: Option, - /// Progress percentage - pub percentage: u8, - /// Message - pub message: String, - /// Outputs (if completed) - pub outputs: Option, - /// Error (if failed) - pub error: Option, - /// Started at - pub started_at: String, - /// Ended at - pub ended_at: Option, -} - -/// Discover and list all available pipelines -#[tauri::command] -pub async fn pipeline_list( - state: State<'_, Arc>, - category: Option, - industry: Option, -) -> Result, String> { - // Get pipelines directory - let pipelines_dir = get_pipelines_directory()?; - - tracing::debug!("[pipeline_list] Scanning directory: {:?}", pipelines_dir); - tracing::debug!("[pipeline_list] Filters - category: {:?}, industry: {:?}", category, industry); - - // Scan for pipeline files (returns both info and paths) - let mut pipelines_with_paths: Vec<(PipelineInfo, PathBuf)> = Vec::new(); - if pipelines_dir.exists() { - scan_pipelines_with_paths(&pipelines_dir, category.as_deref(), industry.as_deref(), &mut pipelines_with_paths)?; - } else { - tracing::warn!("[WARN pipeline_list] Pipelines directory does not exist: {:?}", pipelines_dir); - } - - tracing::debug!("[pipeline_list] Found {} pipelines", pipelines_with_paths.len()); - - // Debug: log all pipelines with their industry values - for (info, _) in &pipelines_with_paths { - tracing::debug!("[pipeline_list] Pipeline: {} -> category: {}, industry: '{}'", info.id, info.category, info.industry); - } - - // Update state - let mut state_pipelines = state.pipelines.write().await; - let mut state_paths = state.pipeline_paths.write().await; - - let mut result = Vec::new(); - for (info, path) in &pipelines_with_paths { - // Load full pipeline into state - if let Ok(content) = std::fs::read_to_string(path) { - if let Ok(pipeline) = parse_pipeline_yaml(&content) { - state_pipelines.insert(info.id.clone(), pipeline); - state_paths.insert(info.id.clone(), path.clone()); - } - } - result.push(info.clone()); - } - - Ok(result) -} - -/// Get pipeline details -#[tauri::command] -pub async fn pipeline_get( - state: State<'_, Arc>, - pipeline_id: String, -) -> Result { - let pipelines = state.pipelines.read().await; - - let pipeline = pipelines.get(&pipeline_id) - .ok_or_else(|| format!("Pipeline not found: {}", pipeline_id))?; - - Ok(pipeline_to_info(pipeline)) -} - -/// Run a pipeline -#[tauri::command] -pub async fn pipeline_run( - app: AppHandle, - state: State<'_, Arc>, - kernel_state: State<'_, KernelState>, - request: RunPipelineRequest, -) -> Result { - tracing::debug!("[pipeline_run] Received request for pipeline_id: {}", request.pipeline_id); - - // Get pipeline - let pipelines = state.pipelines.read().await; - tracing::debug!("[pipeline_run] State has {} pipelines loaded", pipelines.len()); - - // Debug: list all loaded pipeline IDs - for (id, _) in pipelines.iter() { - tracing::debug!("[pipeline_run] Loaded pipeline: {}", id); - } - - let pipeline = pipelines.get(&request.pipeline_id) - .ok_or_else(|| { - println!("[ERROR pipeline_run] Pipeline '{}' not found in state. Available: {:?}", - request.pipeline_id, - pipelines.keys().collect::>()); - format!("Pipeline not found: {}", request.pipeline_id) - })? - .clone(); - drop(pipelines); - - // Try to get LLM driver from Kernel - let (llm_driver, skill_driver, hand_driver) = { - let kernel_lock = kernel_state.lock().await; - if let Some(kernel) = kernel_lock.as_ref() { - tracing::debug!("[pipeline_run] Got LLM driver from Kernel"); - let llm = Some(Arc::new(RuntimeLlmAdapter::new( - kernel.driver(), - Some(kernel.config().llm.model.clone()), - )) as Arc); - let kernel_arc = (*kernel_state).clone(); - let skill = Some(Arc::new(PipelineSkillDriver::new(kernel_arc.clone())) - as Arc); - let hand = Some(Arc::new(PipelineHandDriver::new(kernel_arc)) - as Arc); - (llm, skill, hand) - } else { - tracing::debug!("[pipeline_run] Kernel not initialized, no drivers available"); - (None, None, None) - } - }; - - // Create executor with all available drivers - let executor = if let Some(driver) = llm_driver { - let mut registry = ActionRegistry::new().with_llm_driver(driver); - if let Some(skill) = skill_driver { - registry = registry.with_skill_registry(skill); - } - if let Some(hand) = hand_driver { - registry = registry.with_hand_registry(hand); - } - Arc::new(PipelineExecutor::new(Arc::new(registry))) - } else { - state.executor.clone() - }; - - // Generate run ID upfront so we can return it to the caller - let run_id = uuid::Uuid::new_v4().to_string(); - let pipeline_id = request.pipeline_id.clone(); - let inputs = request.inputs.clone(); - - // Clone for async task - let run_id_for_spawn = run_id.clone(); - - // Run pipeline in background with the known run_id - tokio::spawn(async move { - tracing::debug!("[pipeline_run] Starting execution with run_id: {}", run_id_for_spawn); - let result = executor.execute_with_id(&pipeline, inputs, &run_id_for_spawn).await; - - tracing::debug!("[pipeline_run] Execution completed for run_id: {}, status: {:?}", - run_id_for_spawn, - result.as_ref().map(|r| r.status.clone()).unwrap_or(RunStatus::Failed)); - - // Emit completion event - let _ = app.emit("pipeline-complete", &PipelineRunResponse { - run_id: run_id_for_spawn.clone(), - pipeline_id: pipeline_id.clone(), - status: match &result { - Ok(r) => r.status.to_string(), - Err(_) => "failed".to_string(), - }, - current_step: None, - percentage: 100, - message: match &result { - Ok(_) => "Pipeline completed".to_string(), - Err(e) => e.to_string(), - }, - outputs: result.as_ref().ok().and_then(|r| r.outputs.clone()), - error: result.as_ref().err().map(|e| e.to_string()), - started_at: chrono::Utc::now().to_rfc3339(), - ended_at: Some(chrono::Utc::now().to_rfc3339()), - }); - }); - - // Return immediately with the known run ID - tracing::debug!("[pipeline_run] Returning run_id: {} to caller", run_id); - Ok(RunPipelineResponse { - run_id, - pipeline_id: request.pipeline_id, - status: "running".to_string(), - }) -} - -/// Get pipeline run progress -#[tauri::command] -pub async fn pipeline_progress( - state: State<'_, Arc>, - run_id: String, -) -> Result { - let progress = state.executor.get_progress(&run_id).await - .ok_or_else(|| format!("Run not found: {}", run_id))?; - - let run = state.executor.get_run(&run_id).await; - - Ok(PipelineRunResponse { - run_id: progress.run_id, - pipeline_id: run.as_ref().map(|r| r.pipeline_id.clone()).unwrap_or_default(), - status: progress.status.to_string(), - current_step: Some(progress.current_step), - percentage: progress.percentage, - message: progress.message, - outputs: run.as_ref().and_then(|r| r.outputs.clone()), - error: run.and_then(|r| r.error), - started_at: chrono::Utc::now().to_rfc3339(), // TODO: use actual time - ended_at: None, - }) -} - -/// Cancel a pipeline run -#[tauri::command] -pub async fn pipeline_cancel( - state: State<'_, Arc>, - run_id: String, -) -> Result<(), String> { - state.executor.cancel(&run_id).await; - Ok(()) -} - -/// Get pipeline run result -#[tauri::command] -pub async fn pipeline_result( - state: State<'_, Arc>, - run_id: String, -) -> Result { - let run = state.executor.get_run(&run_id).await - .ok_or_else(|| format!("Run not found: {}", run_id))?; - - let current_step = run.current_step.clone(); - let status = run.status.clone(); - - Ok(PipelineRunResponse { - run_id: run.id, - pipeline_id: run.pipeline_id, - status: status.to_string(), - current_step: current_step.clone(), - percentage: if status == RunStatus::Completed { 100 } else { 0 }, - message: current_step.unwrap_or_default(), - outputs: run.outputs, - error: run.error, - started_at: run.started_at.to_rfc3339(), - ended_at: run.ended_at.map(|t| t.to_rfc3339()), - }) -} - -/// List all runs -#[tauri::command] -pub async fn pipeline_runs( - state: State<'_, Arc>, -) -> Result, String> { - let runs = state.executor.list_runs().await; - - Ok(runs.into_iter().map(|run| { - let current_step = run.current_step.clone(); - let status = run.status.clone(); - PipelineRunResponse { - run_id: run.id, - pipeline_id: run.pipeline_id, - status: status.to_string(), - current_step: current_step.clone(), - percentage: if status == RunStatus::Completed { 100 } else if status == RunStatus::Running { 50 } else { 0 }, - message: current_step.unwrap_or_default(), - outputs: run.outputs, - error: run.error, - started_at: run.started_at.to_rfc3339(), - ended_at: run.ended_at.map(|t| t.to_rfc3339()), - } - }).collect()) -} - -/// Refresh pipeline discovery -#[tauri::command] -pub async fn pipeline_refresh( - state: State<'_, Arc>, -) -> Result, 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))?; - } - - let mut state_pipelines = state.pipelines.write().await; - let mut state_paths = state.pipeline_paths.write().await; - - // Clear existing - state_pipelines.clear(); - state_paths.clear(); - - // Scan and load all pipelines (synchronous) - let mut pipelines = Vec::new(); - scan_pipelines_full_sync(&pipelines_dir, &mut pipelines)?; - - for (path, pipeline) in &pipelines { - let id = pipeline.metadata.name.clone(); - state_pipelines.insert(id.clone(), pipeline.clone()); - state_paths.insert(id, path.clone()); - } - - 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 { - // Try to find pipelines directory - // Priority: ZCLAW_PIPELINES_DIR env > workspace pipelines/ > ~/.zclaw/pipelines/ - - if let Ok(dir) = std::env::var("ZCLAW_PIPELINES_DIR") { - return Ok(PathBuf::from(dir)); - } - - // Try workspace directory - let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - let workspace_pipelines = manifest_dir - .parent() - .and_then(|p| p.parent()) - .map(|p| p.join("pipelines")); - - if let Some(ref dir) = workspace_pipelines { - if dir.exists() { - return Ok(dir.clone()); - } - } - - // Fallback to user home directory - if let Some(home) = dirs::home_dir() { - let dir = home.join(".zclaw").join("pipelines"); - return Ok(dir); - } - - Err("Could not determine pipelines directory".to_string()) -} - -/// Scan pipelines with paths (returns both info and file paths) -fn scan_pipelines_with_paths( - dir: &PathBuf, - category_filter: Option<&str>, - industry_filter: Option<&str>, - pipelines: &mut Vec<(PipelineInfo, PathBuf)>, -) -> Result<(), String> { - tracing::debug!("[scan] Entering directory: {:?}", dir); - let entries = std::fs::read_dir(dir) - .map_err(|e| format!("Failed to read pipelines directory: {}", e))?; - - for entry in entries { - let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?; - let path = entry.path(); - - if path.is_dir() { - // Recursively scan subdirectory - scan_pipelines_with_paths(&path, category_filter, industry_filter, pipelines)?; - } else if path.extension().map(|e| e == "yaml" || e == "yml").unwrap_or(false) { - // Try to parse pipeline file - tracing::debug!("[scan] Found YAML file: {:?}", path); - if let Ok(content) = std::fs::read_to_string(&path) { - tracing::debug!("[scan] File content length: {} bytes", content.len()); - match parse_pipeline_yaml(&content) { - Ok(pipeline) => { - tracing::debug!( - "[scan] Parsed YAML: {} -> category: {:?}, industry: {:?}", - pipeline.metadata.name, - pipeline.metadata.category, - pipeline.metadata.industry - ); - - // Apply category filter - if let Some(filter) = category_filter { - if pipeline.metadata.category.as_deref() != Some(filter) { - continue; - } - } - - // Apply industry filter - if let Some(filter) = industry_filter { - if pipeline.metadata.industry.as_deref() != Some(filter) { - continue; - } - } - - tracing::debug!("[scan] Found pipeline: {} at {:?}", pipeline.metadata.name, path); - pipelines.push((pipeline_to_info(&pipeline), path)); - } - Err(e) => { - tracing::error!("[scan] Failed to parse pipeline at {:?}: {}", path, e); - } - } - } - } - } - - Ok(()) -} - -fn scan_pipelines_full_sync( - dir: &PathBuf, - pipelines: &mut Vec<(PathBuf, Pipeline)>, -) -> Result<(), String> { - let entries = std::fs::read_dir(dir) - .map_err(|e| format!("Failed to read pipelines directory: {}", e))?; - - for entry in entries { - let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?; - let path = entry.path(); - - if path.is_dir() { - scan_pipelines_full_sync(&path, pipelines)?; - } else if path.extension().map(|e| e == "yaml" || e == "yml").unwrap_or(false) { - if let Ok(content) = std::fs::read_to_string(&path) { - if let Ok(pipeline) = parse_pipeline_yaml(&content) { - pipelines.push((path, pipeline)); - } - } - } - } - - Ok(()) -} - -fn pipeline_to_info(pipeline: &Pipeline) -> PipelineInfo { - let industry = pipeline.metadata.industry.clone().unwrap_or_default(); - tracing::debug!( - "[pipeline_to_info] Pipeline: {}, category: {:?}, industry: {:?}", - pipeline.metadata.name, - pipeline.metadata.category, - pipeline.metadata.industry - ); - - PipelineInfo { - id: pipeline.metadata.name.clone(), - display_name: pipeline.metadata.display_name.clone() - .unwrap_or_else(|| pipeline.metadata.name.clone()), - description: pipeline.metadata.description.clone().unwrap_or_default(), - category: pipeline.metadata.category.clone().unwrap_or_default(), - industry, - tags: pipeline.metadata.tags.clone(), - icon: pipeline.metadata.icon.clone().unwrap_or_else(|| "📦".to_string()), - version: pipeline.metadata.version.clone(), - author: pipeline.metadata.author.clone().unwrap_or_default(), - inputs: pipeline.spec.inputs.iter().map(|input| { - PipelineInputInfo { - name: input.name.clone(), - input_type: match input.input_type { - zclaw_pipeline::InputType::String => "string".to_string(), - zclaw_pipeline::InputType::Number => "number".to_string(), - zclaw_pipeline::InputType::Boolean => "boolean".to_string(), - zclaw_pipeline::InputType::Select => "select".to_string(), - zclaw_pipeline::InputType::MultiSelect => "multi-select".to_string(), - zclaw_pipeline::InputType::File => "file".to_string(), - zclaw_pipeline::InputType::Text => "text".to_string(), - }, - required: input.required, - label: input.label.clone().unwrap_or_else(|| input.name.clone()), - placeholder: input.placeholder.clone(), - default: input.default.clone(), - options: input.options.clone(), - } - }).collect(), - } -} - -/// Create pipeline state with default action registry -pub fn create_pipeline_state() -> Arc { - // Try to create an LLM driver from environment/config - let action_registry = if let Some(driver) = create_llm_driver_from_config() { - tracing::debug!("[create_pipeline_state] LLM driver configured successfully"); - Arc::new(ActionRegistry::new().with_llm_driver(driver)) - } else { - tracing::debug!("[create_pipeline_state] No LLM driver configured - pipelines requiring LLM will fail"); - Arc::new(ActionRegistry::new()) - }; - Arc::new(PipelineState::new(action_registry)) -} - -// === Intent Router Commands === - -/// Route result for frontend -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum RouteResultResponse { - Matched { - pipeline_id: String, - display_name: Option, - mode: String, - params: HashMap, - confidence: f32, - missing_params: Vec, - }, - Ambiguous { - candidates: Vec, - }, - NoMatch { - suggestions: Vec, - }, - NeedMoreInfo { - prompt: String, - related_pipeline: Option, - }, -} - -/// Missing parameter info -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct MissingParamInfo { - pub name: String, - pub label: Option, - pub param_type: String, - pub required: bool, - pub default: Option, -} - -/// Pipeline candidate info -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PipelineCandidateInfo { - pub id: String, - pub display_name: Option, - pub description: Option, - pub icon: Option, - pub category: Option, - pub match_reason: Option, -} - -/// Route user input to matching pipeline -#[tauri::command] -pub async fn route_intent( - state: State<'_, Arc>, - kernel_state: State<'_, KernelState>, - user_input: String, -) -> Result { - use zclaw_pipeline::{TriggerParser, Trigger, TriggerParam, compile_trigger}; - - tracing::debug!("[route_intent] Routing user input: {}", user_input); - - // Build trigger parser from loaded pipelines - let pipelines = state.pipelines.read().await; - let mut parser = TriggerParser::new(); - - for (id, pipeline) in pipelines.iter() { - // Derive trigger info from pipeline metadata (tags as keywords, description) - let trigger = Trigger { - keywords: pipeline.metadata.tags.clone(), - patterns: vec![], // Patterns not defined in Pipeline struct - description: pipeline.metadata.description.clone(), - examples: vec![], // Examples not defined in Pipeline struct - }; - - // Convert pipeline inputs to trigger params - let param_defs: Vec = pipeline.spec.inputs.iter().map(|input| { - TriggerParam { - name: input.name.clone(), - param_type: match input.input_type { - zclaw_pipeline::InputType::String => "string".to_string(), - zclaw_pipeline::InputType::Number => "number".to_string(), - zclaw_pipeline::InputType::Boolean => "boolean".to_string(), - zclaw_pipeline::InputType::Select => "select".to_string(), - zclaw_pipeline::InputType::MultiSelect => "multi-select".to_string(), - zclaw_pipeline::InputType::File => "file".to_string(), - zclaw_pipeline::InputType::Text => "text".to_string(), - }, - required: input.required, - label: input.label.clone(), - default: input.default.clone(), - } - }).collect(); - - match compile_trigger( - id.clone(), - pipeline.metadata.display_name.clone(), - &trigger, - param_defs, - ) { - Ok(compiled) => parser.register(compiled), - Err(e) => { - tracing::warn!("[WARN route_intent] Failed to compile trigger for {}: {}", id, e); - } - } - } - - // Quick match - if let Some(match_result) = parser.quick_match(&user_input) { - let trigger = parser.get_trigger(&match_result.pipeline_id); - - // Determine input mode - let mode = if let Some(t) = &trigger { - let required_count = t.param_defs.iter().filter(|p| p.required).count(); - if required_count > 3 || t.param_defs.len() > 5 { - "form" - } else if t.param_defs.is_empty() { - "conversation" - } else { - "conversation" - } - } else { - "auto" - }; - - // Find missing params - let missing_params: Vec = trigger - .map(|t| { - t.param_defs.iter() - .filter(|p| p.required && !match_result.params.contains_key(&p.name) && p.default.is_none()) - .map(|p| MissingParamInfo { - name: p.name.clone(), - label: p.label.clone(), - param_type: p.param_type.clone(), - required: p.required, - default: p.default.clone(), - }) - .collect() - }) - .unwrap_or_default(); - - return Ok(RouteResultResponse::Matched { - pipeline_id: match_result.pipeline_id, - display_name: trigger.and_then(|t| t.display_name.clone()), - mode: mode.to_string(), - params: match_result.params, - confidence: match_result.confidence, - missing_params, - }); - } - - // Semantic match via LLM (if kernel is initialized) - let triggers = parser.triggers(); - if !triggers.is_empty() { - let llm_driver = { - let kernel_lock = kernel_state.lock().await; - kernel_lock.as_ref().map(|k| k.driver()) - }; - - if let Some(driver) = llm_driver { - use zclaw_pipeline::{RuntimeLlmIntentDriver, LlmIntentDriver}; - let intent_driver = RuntimeLlmIntentDriver::new(driver); - - if let Some(result) = intent_driver.semantic_match(&user_input, &triggers).await { - tracing::debug!( - "[route_intent] Semantic match: pipeline={}, confidence={}", - result.pipeline_id, result.confidence - ); - - let trigger = parser.get_trigger(&result.pipeline_id); - let mode = "auto".to_string(); - - let missing_params: Vec = trigger - .map(|t| { - t.param_defs.iter() - .filter(|p| p.required && !result.params.contains_key(&p.name) && p.default.is_none()) - .map(|p| MissingParamInfo { - name: p.name.clone(), - label: p.label.clone(), - param_type: p.param_type.clone(), - required: p.required, - default: p.default.clone(), - }) - .collect() - }) - .unwrap_or_default(); - - return Ok(RouteResultResponse::Matched { - pipeline_id: result.pipeline_id, - display_name: trigger.and_then(|t| t.display_name.clone()), - mode, - params: result.params, - confidence: result.confidence, - missing_params, - }); - } - } - } - - // No match - return suggestions - let suggestions: Vec = parser.triggers() - .iter() - .take(3) - .map(|t| PipelineCandidateInfo { - id: t.pipeline_id.clone(), - display_name: t.display_name.clone(), - description: t.description.clone(), - icon: None, - category: None, - match_reason: Some("推荐".to_string()), - }) - .collect(); - - Ok(RouteResultResponse::NoMatch { suggestions }) -} - -/// Create an LLM driver from configuration file or environment variables -fn create_llm_driver_from_config() -> Option> { - // Try to read config file - let config_path = dirs::config_dir() - .map(|p| p.join("zclaw").join("config.toml"))?; - - if !config_path.exists() { - tracing::debug!("[create_llm_driver] Config file not found at {:?}", config_path); - return None; - } - - // Read and parse config - let config_content = std::fs::read_to_string(&config_path).ok()?; - let config: toml::Value = toml::from_str(&config_content).ok()?; - - // Extract LLM config - let llm_config = config.get("llm")?; - - let provider = llm_config.get("provider")?.as_str()?.to_string(); - let api_key = llm_config.get("api_key")?.as_str()?.to_string(); - let base_url = llm_config.get("base_url").and_then(|v| v.as_str()).map(|s| s.to_string()); - let model = llm_config.get("model").and_then(|v| v.as_str()).map(|s| s.to_string()); - - tracing::debug!("[create_llm_driver] Found LLM config: provider={}, model={:?}", provider, model); - - // Convert api_key to SecretString - let secret_key = SecretString::new(api_key); - - // Create the runtime driver — use with_base_url when a custom endpoint is configured - // (essential for Chinese providers like doubao, qwen, deepseek, kimi) - let runtime_driver: Arc = match provider.as_str() { - "anthropic" => { - if let Some(url) = base_url { - Arc::new(zclaw_runtime::AnthropicDriver::with_base_url(secret_key, url)) - } else { - Arc::new(zclaw_runtime::AnthropicDriver::new(secret_key)) - } - } - "openai" | "doubao" | "qwen" | "deepseek" | "kimi" | "zhipu" => { - // Chinese providers typically need a custom base_url - if let Some(url) = base_url { - Arc::new(zclaw_runtime::OpenAiDriver::with_base_url(secret_key, url)) - } else { - Arc::new(zclaw_runtime::OpenAiDriver::new(secret_key)) - } - } - "gemini" => { - if let Some(url) = base_url { - Arc::new(zclaw_runtime::GeminiDriver::with_base_url(secret_key, url)) - } else { - Arc::new(zclaw_runtime::GeminiDriver::new(secret_key)) - } - } - "local" | "ollama" => { - let url = base_url.unwrap_or_else(|| "http://localhost:11434".to_string()); - Arc::new(zclaw_runtime::LocalDriver::new(&url)) - } - _ => { - tracing::warn!("[WARN create_llm_driver] Unknown provider: {}", provider); - return None; - } - }; - - Some(Arc::new(RuntimeLlmAdapter::new(runtime_driver, model))) -} - -/// Analyze presentation data -#[tauri::command] -pub async fn analyze_presentation( - data: Value, -) -> Result { - use zclaw_pipeline::presentation::PresentationAnalyzer; - - let analyzer = PresentationAnalyzer::new(); - let analysis = analyzer.analyze(&data); - - // Convert analysis to JSON - serde_json::to_value(&analysis).map_err(|e| e.to_string()) -} - -/// Pipeline template metadata -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PipelineTemplateInfo { - pub id: String, - pub display_name: String, - pub description: String, - pub category: String, - pub industry: String, - pub tags: Vec, - pub icon: String, - pub version: String, - pub author: String, - pub inputs: Vec, -} - -/// List available pipeline templates from the `_templates/` directory. -/// -/// Templates are pipeline YAML files that users can browse and instantiate. -/// They live in `pipelines/_templates/` and are not directly runnable -/// (they serve as blueprints). -#[tauri::command] -pub async fn pipeline_templates( - state: State<'_, Arc>, -) -> Result, String> { - let pipelines = state.pipelines.read().await; - - // Filter pipelines that have `is_template: true` in metadata - // or are in the _templates directory - let templates: Vec = pipelines.iter() - .filter_map(|(_id, pipeline)| { - // Check if this pipeline has template metadata - let is_template = pipeline.metadata.annotations - .as_ref() - .and_then(|a| a.get("is_template")) - .and_then(|v| v.as_bool()) - .unwrap_or(false); - - if !is_template { - return None; - } - - Some(PipelineTemplateInfo { - id: pipeline.metadata.name.clone(), - display_name: pipeline.metadata.display_name.clone() - .unwrap_or_else(|| pipeline.metadata.name.clone()), - description: pipeline.metadata.description.clone().unwrap_or_default(), - category: pipeline.metadata.category.clone().unwrap_or_default(), - industry: pipeline.metadata.industry.clone().unwrap_or_default(), - tags: pipeline.metadata.tags.clone(), - icon: pipeline.metadata.icon.clone().unwrap_or_else(|| "📦".to_string()), - version: pipeline.metadata.version.clone(), - author: pipeline.metadata.author.clone().unwrap_or_default(), - inputs: pipeline.spec.inputs.iter().map(|input| { - PipelineInputInfo { - name: input.name.clone(), - input_type: match input.input_type { - zclaw_pipeline::InputType::String => "string".to_string(), - zclaw_pipeline::InputType::Number => "number".to_string(), - zclaw_pipeline::InputType::Boolean => "boolean".to_string(), - zclaw_pipeline::InputType::Select => "select".to_string(), - zclaw_pipeline::InputType::MultiSelect => "multi-select".to_string(), - zclaw_pipeline::InputType::File => "file".to_string(), - zclaw_pipeline::InputType::Text => "text".to_string(), - }, - required: input.required, - label: input.label.clone().unwrap_or_else(|| input.name.clone()), - placeholder: input.placeholder.clone(), - default: input.default.clone(), - options: input.options.clone(), - } - }).collect(), - }) - }) - .collect(); - - tracing::debug!("[pipeline_templates] Found {} templates", templates.len()); - Ok(templates) -} diff --git a/desktop/src-tauri/src/pipeline_commands/adapters.rs b/desktop/src-tauri/src/pipeline_commands/adapters.rs new file mode 100644 index 0000000..8d2cdaf --- /dev/null +++ b/desktop/src-tauri/src/pipeline_commands/adapters.rs @@ -0,0 +1,210 @@ +//! Adapter structs to bridge zclaw-runtime/zclaw-kernel drivers into zclaw-pipeline action drivers. + +use std::collections::HashMap; +use std::sync::Arc; +use async_trait::async_trait; +use serde_json::Value; +use zclaw_runtime::{LlmDriver, CompletionRequest}; +use zclaw_skills::SkillContext; + +use zclaw_pipeline::{ + LlmActionDriver, + SkillActionDriver, + HandActionDriver, +}; + +use crate::kernel_commands::KernelState; + +/// Adapter to connect zclaw-runtime LlmDriver to zclaw-pipeline LlmActionDriver +pub struct RuntimeLlmAdapter { + driver: Arc, + default_model: String, +} + +impl RuntimeLlmAdapter { + pub fn new(driver: Arc, default_model: Option) -> Self { + Self { + driver, + default_model: default_model.unwrap_or_else(|| "claude-3-sonnet-20240229".to_string()), + } + } +} + +#[async_trait] +impl LlmActionDriver for RuntimeLlmAdapter { + async fn generate( + &self, + prompt: String, + input: HashMap, + model: Option, + temperature: Option, + max_tokens: Option, + json_mode: bool, + ) -> Result { + tracing::debug!("[RuntimeLlmAdapter] generate called with prompt length: {}", prompt.len()); + tracing::debug!("[RuntimeLlmAdapter] input HashMap contents:"); + for (k, v) in &input { + println!(" {} => {}", k, v); + } + + // Build user content from prompt and input + let user_content = if input.is_empty() { + tracing::debug!("[RuntimeLlmAdapter] WARNING: input is empty, using raw prompt"); + prompt.clone() + } else { + // Inject input values into prompt + // Support multiple placeholder formats: {{key}}, {{ key }}, ${key}, ${inputs.key} + let mut rendered = prompt.clone(); + tracing::debug!("[RuntimeLlmAdapter] Original prompt (first 500 chars): {}", &prompt[..prompt.len().min(500)]); + for (key, value) in &input { + let str_value = if let Some(s) = value.as_str() { + s.to_string() + } else { + value.to_string() + }; + + tracing::debug!("[RuntimeLlmAdapter] Replacing '{}' with '{}'", key, str_value); + + // Replace all common placeholder formats + rendered = rendered.replace(&format!("{{{{{key}}}}}"), &str_value); // {{key}} + rendered = rendered.replace(&format!("{{{{ {key} }}}}"), &str_value); // {{ key }} + rendered = rendered.replace(&format!("${{{key}}}"), &str_value); // ${key} + rendered = rendered.replace(&format!("${{inputs.{key}}}"), &str_value); // ${inputs.key} + } + tracing::debug!("[RuntimeLlmAdapter] Rendered prompt (first 500 chars): {}", &rendered[..rendered.len().min(500)]); + rendered + }; + + // Create message using zclaw_types::Message enum + let messages = vec![zclaw_types::Message::user(user_content)]; + + let request = CompletionRequest { + model: model.unwrap_or_else(|| self.default_model.clone()), + system: None, + messages, + tools: Vec::new(), + max_tokens, + temperature, + stop: Vec::new(), + stream: false, + }; + + let response = self.driver.complete(request) + .await + .map_err(|e| format!("LLM completion failed: {}", e))?; + + // Extract text from response + let text = response.content.iter() + .find_map(|block| match block { + zclaw_runtime::ContentBlock::Text { text } => Some(text.clone()), + _ => None, + }) + .unwrap_or_default(); + + // Safe truncation for UTF-8 strings + let truncated: String = text.chars().take(1000).collect(); + tracing::debug!("[RuntimeLlmAdapter] LLM response text (first 1000 chars): {}", truncated); + + // Parse as JSON if json_mode, otherwise return as string + if json_mode { + // Try to extract JSON from the response (LLM might wrap it in markdown code blocks) + let json_text = if text.contains("```json") { + // Extract JSON from markdown code block + let start = text.find("```json").map(|i| i + 7).unwrap_or(0); + let end = text.rfind("```").unwrap_or(text.len()); + text[start..end].trim().to_string() + } else if text.contains("```") { + // Extract from generic code block + let start = text.find("```").map(|i| i + 3).unwrap_or(0); + let end = text.rfind("```").unwrap_or(text.len()); + text[start..end].trim().to_string() + } else { + text.clone() + }; + + // Safe truncation for UTF-8 strings + let truncated_json: String = json_text.chars().take(500).collect(); + tracing::debug!("[RuntimeLlmAdapter] JSON text to parse (first 500 chars): {}", truncated_json); + + serde_json::from_str(&json_text) + .map_err(|e| { + tracing::debug!("[RuntimeLlmAdapter] JSON parse error: {}", e); + format!("Failed to parse LLM response as JSON: {}\nResponse: {}", e, json_text) + }) + } else { + Ok(Value::String(text)) + } + } +} + +/// Adapter to bridge Kernel skill execution into Pipeline SkillActionDriver +pub struct PipelineSkillDriver { + kernel_state: KernelState, +} + +impl PipelineSkillDriver { + pub fn new(kernel_state: KernelState) -> Self { + Self { kernel_state } + } +} + +#[async_trait] +impl SkillActionDriver for PipelineSkillDriver { + async fn execute( + &self, + skill_id: &str, + input: HashMap, + ) -> Result { + let kernel_lock = self.kernel_state.lock().await; + let kernel = kernel_lock.as_ref() + .ok_or_else(|| "Kernel 未初始化,无法执行技能".to_string())?; + + let context = SkillContext::default(); + let input_value = Value::Object(input.into_iter().collect()); + + tracing::debug!("[PipelineSkillDriver] Executing skill: {}", skill_id); + let result = kernel.execute_skill(skill_id, context, input_value).await + .map_err(|e| format!("技能执行失败: {}", e))?; + + Ok(result.output) + } +} + +/// Adapter to bridge Kernel hand execution into Pipeline HandActionDriver +pub struct PipelineHandDriver { + kernel_state: KernelState, +} + +impl PipelineHandDriver { + pub fn new(kernel_state: KernelState) -> Self { + Self { kernel_state } + } +} + +#[async_trait] +impl HandActionDriver for PipelineHandDriver { + async fn execute( + &self, + hand_id: &str, + action: &str, + params: HashMap, + ) -> Result { + let kernel_lock = self.kernel_state.lock().await; + let kernel = kernel_lock.as_ref() + .ok_or_else(|| "Kernel 未初始化,无法执行 Hand".to_string())?; + + // Build hand input combining action and params + let mut input_map = serde_json::Map::new(); + input_map.insert("action".to_string(), Value::String(action.to_string())); + for (k, v) in params { + input_map.insert(k, v); + } + let input_value = Value::Object(input_map); + + tracing::debug!("[PipelineHandDriver] Executing hand: {} / {}", hand_id, action); + let (result, _run_id) = kernel.execute_hand(hand_id, input_value).await + .map_err(|e| format!("Hand 执行失败: {}", e))?; + + Ok(result.output) + } +} diff --git a/desktop/src-tauri/src/pipeline_commands/crud.rs b/desktop/src-tauri/src/pipeline_commands/crud.rs new file mode 100644 index 0000000..11f22e6 --- /dev/null +++ b/desktop/src-tauri/src/pipeline_commands/crud.rs @@ -0,0 +1,230 @@ +//! Pipeline CRUD commands (Create / Update / Delete). + +use std::collections::HashMap; +use std::sync::Arc; +use tauri::State; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use zclaw_pipeline::{ + Pipeline, + PipelineMetadata, + PipelineSpec, + PipelineStep, + Action, + ErrorStrategy, +}; + +use super::{PipelineState, PipelineInfo}; +use super::helpers::{get_pipelines_directory, pipeline_to_info}; + +/// 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(()) +} diff --git a/desktop/src-tauri/src/pipeline_commands/discovery.rs b/desktop/src-tauri/src/pipeline_commands/discovery.rs new file mode 100644 index 0000000..88b4930 --- /dev/null +++ b/desktop/src-tauri/src/pipeline_commands/discovery.rs @@ -0,0 +1,310 @@ +//! Pipeline discovery, listing, running, and monitoring commands. + +use std::sync::Arc; +use tauri::{AppHandle, Emitter, State}; + +use zclaw_pipeline::{ + RunStatus, + parse_pipeline_yaml, + PipelineExecutor, + ActionRegistry, + LlmActionDriver, + SkillActionDriver, + HandActionDriver, +}; + +use super::{PipelineState, PipelineInfo, PipelineRunResponse, RunPipelineResponse, RunPipelineRequest}; +use super::adapters::{RuntimeLlmAdapter, PipelineSkillDriver, PipelineHandDriver}; +use super::helpers::{get_pipelines_directory, scan_pipelines_with_paths, scan_pipelines_full_sync, pipeline_to_info}; + +use crate::kernel_commands::KernelState; + +/// Discover and list all available pipelines +#[tauri::command] +pub async fn pipeline_list( + state: State<'_, Arc>, + category: Option, + industry: Option, +) -> Result, String> { + // Get pipelines directory + let pipelines_dir = get_pipelines_directory()?; + + tracing::debug!("[pipeline_list] Scanning directory: {:?}", pipelines_dir); + tracing::debug!("[pipeline_list] Filters - category: {:?}, industry: {:?}", category, industry); + + // Scan for pipeline files (returns both info and paths) + let mut pipelines_with_paths: Vec<(PipelineInfo, std::path::PathBuf)> = Vec::new(); + if pipelines_dir.exists() { + scan_pipelines_with_paths(&pipelines_dir, category.as_deref(), industry.as_deref(), &mut pipelines_with_paths)?; + } else { + tracing::warn!("[WARN pipeline_list] Pipelines directory does not exist: {:?}", pipelines_dir); + } + + tracing::debug!("[pipeline_list] Found {} pipelines", pipelines_with_paths.len()); + + // Debug: log all pipelines with their industry values + for (info, _) in &pipelines_with_paths { + tracing::debug!("[pipeline_list] Pipeline: {} -> category: {}, industry: '{}'", info.id, info.category, info.industry); + } + + // Update state + let mut state_pipelines = state.pipelines.write().await; + let mut state_paths = state.pipeline_paths.write().await; + + let mut result = Vec::new(); + for (info, path) in &pipelines_with_paths { + // Load full pipeline into state + if let Ok(content) = std::fs::read_to_string(path) { + if let Ok(pipeline) = parse_pipeline_yaml(&content) { + state_pipelines.insert(info.id.clone(), pipeline); + state_paths.insert(info.id.clone(), path.clone()); + } + } + result.push(info.clone()); + } + + Ok(result) +} + +/// Get pipeline details +#[tauri::command] +pub async fn pipeline_get( + state: State<'_, Arc>, + pipeline_id: String, +) -> Result { + let pipelines = state.pipelines.read().await; + + let pipeline = pipelines.get(&pipeline_id) + .ok_or_else(|| format!("Pipeline not found: {}", pipeline_id))?; + + Ok(pipeline_to_info(pipeline)) +} + +/// Run a pipeline +#[tauri::command] +pub async fn pipeline_run( + app: AppHandle, + state: State<'_, Arc>, + kernel_state: State<'_, KernelState>, + request: RunPipelineRequest, +) -> Result { + tracing::debug!("[pipeline_run] Received request for pipeline_id: {}", request.pipeline_id); + + // Get pipeline + let pipelines = state.pipelines.read().await; + tracing::debug!("[pipeline_run] State has {} pipelines loaded", pipelines.len()); + + // Debug: list all loaded pipeline IDs + for (id, _) in pipelines.iter() { + tracing::debug!("[pipeline_run] Loaded pipeline: {}", id); + } + + let pipeline = pipelines.get(&request.pipeline_id) + .ok_or_else(|| { + println!("[ERROR pipeline_run] Pipeline '{}' not found in state. Available: {:?}", + request.pipeline_id, + pipelines.keys().collect::>()); + format!("Pipeline not found: {}", request.pipeline_id) + })? + .clone(); + drop(pipelines); + + // Try to get LLM driver from Kernel + let (llm_driver, skill_driver, hand_driver) = { + let kernel_lock = kernel_state.lock().await; + if let Some(kernel) = kernel_lock.as_ref() { + tracing::debug!("[pipeline_run] Got LLM driver from Kernel"); + let llm = Some(Arc::new(RuntimeLlmAdapter::new( + kernel.driver(), + Some(kernel.config().llm.model.clone()), + )) as Arc); + let kernel_arc = (*kernel_state).clone(); + let skill = Some(Arc::new(PipelineSkillDriver::new(kernel_arc.clone())) + as Arc); + let hand = Some(Arc::new(PipelineHandDriver::new(kernel_arc)) + as Arc); + (llm, skill, hand) + } else { + tracing::debug!("[pipeline_run] Kernel not initialized, no drivers available"); + (None, None, None) + } + }; + + // Create executor with all available drivers + let executor = if let Some(driver) = llm_driver { + let mut registry = ActionRegistry::new().with_llm_driver(driver); + if let Some(skill) = skill_driver { + registry = registry.with_skill_registry(skill); + } + if let Some(hand) = hand_driver { + registry = registry.with_hand_registry(hand); + } + Arc::new(PipelineExecutor::new(Arc::new(registry))) + } else { + state.executor.clone() + }; + + // Generate run ID upfront so we can return it to the caller + let run_id = uuid::Uuid::new_v4().to_string(); + let pipeline_id = request.pipeline_id.clone(); + let inputs = request.inputs.clone(); + + // Clone for async task + let run_id_for_spawn = run_id.clone(); + + // Run pipeline in background with the known run_id + tokio::spawn(async move { + tracing::debug!("[pipeline_run] Starting execution with run_id: {}", run_id_for_spawn); + let result = executor.execute_with_id(&pipeline, inputs, &run_id_for_spawn).await; + + tracing::debug!("[pipeline_run] Execution completed for run_id: {}, status: {:?}", + run_id_for_spawn, + result.as_ref().map(|r| r.status.clone()).unwrap_or(RunStatus::Failed)); + + // Emit completion event + let _ = app.emit("pipeline-complete", &PipelineRunResponse { + run_id: run_id_for_spawn.clone(), + pipeline_id: pipeline_id.clone(), + status: match &result { + Ok(r) => r.status.to_string(), + Err(_) => "failed".to_string(), + }, + current_step: None, + percentage: 100, + message: match &result { + Ok(_) => "Pipeline completed".to_string(), + Err(e) => e.to_string(), + }, + outputs: result.as_ref().ok().and_then(|r| r.outputs.clone()), + error: result.as_ref().err().map(|e| e.to_string()), + started_at: result.as_ref().map(|r| r.started_at.to_rfc3339()).unwrap_or_else(|_| chrono::Utc::now().to_rfc3339()), + ended_at: result.as_ref().map(|r| r.ended_at.map(|t| t.to_rfc3339())).unwrap_or_else(|_| Some(chrono::Utc::now().to_rfc3339())), + }); + }); + + // Return immediately with the known run ID + tracing::debug!("[pipeline_run] Returning run_id: {} to caller", run_id); + Ok(RunPipelineResponse { + run_id, + pipeline_id: request.pipeline_id, + status: "running".to_string(), + }) +} + +/// Get pipeline run progress +#[tauri::command] +pub async fn pipeline_progress( + state: State<'_, Arc>, + run_id: String, +) -> Result { + let progress = state.executor.get_progress(&run_id).await + .ok_or_else(|| format!("Run not found: {}", run_id))?; + + let run = state.executor.get_run(&run_id).await; + + Ok(PipelineRunResponse { + run_id: progress.run_id, + pipeline_id: run.as_ref().map(|r| r.pipeline_id.clone()).unwrap_or_default(), + status: progress.status.to_string(), + current_step: Some(progress.current_step), + percentage: progress.percentage, + message: progress.message, + outputs: run.as_ref().and_then(|r| r.outputs.clone()), + error: run.as_ref().and_then(|r| r.error.clone()), + started_at: run.as_ref().map(|r| r.started_at.to_rfc3339()).unwrap_or_default(), + ended_at: run.as_ref().and_then(|r| r.ended_at.map(|t| t.to_rfc3339())), + }) +} + +/// Cancel a pipeline run +#[tauri::command] +pub async fn pipeline_cancel( + state: State<'_, Arc>, + run_id: String, +) -> Result<(), String> { + state.executor.cancel(&run_id).await; + Ok(()) +} + +/// Get pipeline run result +#[tauri::command] +pub async fn pipeline_result( + state: State<'_, Arc>, + run_id: String, +) -> Result { + let run = state.executor.get_run(&run_id).await + .ok_or_else(|| format!("Run not found: {}", run_id))?; + + let current_step = run.current_step.clone(); + let status = run.status.clone(); + + Ok(PipelineRunResponse { + run_id: run.id, + pipeline_id: run.pipeline_id, + status: status.to_string(), + current_step: current_step.clone(), + percentage: if status == RunStatus::Completed { 100 } else { 0 }, + message: current_step.unwrap_or_default(), + outputs: run.outputs, + error: run.error, + started_at: run.started_at.to_rfc3339(), + ended_at: run.ended_at.map(|t| t.to_rfc3339()), + }) +} + +/// List all runs +#[tauri::command] +pub async fn pipeline_runs( + state: State<'_, Arc>, +) -> Result, String> { + let runs = state.executor.list_runs().await; + + Ok(runs.into_iter().map(|run| { + let current_step = run.current_step.clone(); + let status = run.status.clone(); + PipelineRunResponse { + run_id: run.id, + pipeline_id: run.pipeline_id, + status: status.to_string(), + current_step: current_step.clone(), + percentage: if status == RunStatus::Completed { 100 } else if status == RunStatus::Running { 50 } else { 0 }, + message: current_step.unwrap_or_default(), + outputs: run.outputs, + error: run.error, + started_at: run.started_at.to_rfc3339(), + ended_at: run.ended_at.map(|t| t.to_rfc3339()), + } + }).collect()) +} + +/// Refresh pipeline discovery +#[tauri::command] +pub async fn pipeline_refresh( + state: State<'_, Arc>, +) -> Result, 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))?; + } + + let mut state_pipelines = state.pipelines.write().await; + let mut state_paths = state.pipeline_paths.write().await; + + // Clear existing + state_pipelines.clear(); + state_paths.clear(); + + // Scan and load all pipelines (synchronous) + let mut pipelines = Vec::new(); + scan_pipelines_full_sync(&pipelines_dir, &mut pipelines)?; + + for (path, pipeline) in &pipelines { + let id = pipeline.metadata.name.clone(); + state_pipelines.insert(id.clone(), pipeline.clone()); + state_paths.insert(id, path.clone()); + } + + Ok(pipelines.into_iter().map(|(_, p)| pipeline_to_info(&p)).collect()) +} diff --git a/desktop/src-tauri/src/pipeline_commands/helpers.rs b/desktop/src-tauri/src/pipeline_commands/helpers.rs new file mode 100644 index 0000000..fafd173 --- /dev/null +++ b/desktop/src-tauri/src/pipeline_commands/helpers.rs @@ -0,0 +1,167 @@ +//! Helper functions for Pipeline commands. + +use std::path::PathBuf; + +use zclaw_pipeline::{ + Pipeline, + parse_pipeline_yaml, +}; + +use super::types::{PipelineInfo, PipelineInputInfo}; + +pub(crate) fn get_pipelines_directory() -> Result { + // Try to find pipelines directory + // Priority: ZCLAW_PIPELINES_DIR env > workspace pipelines/ > ~/.zclaw/pipelines/ + + if let Ok(dir) = std::env::var("ZCLAW_PIPELINES_DIR") { + return Ok(PathBuf::from(dir)); + } + + // Try workspace directory + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let workspace_pipelines = manifest_dir + .parent() + .and_then(|p| p.parent()) + .map(|p| p.join("pipelines")); + + if let Some(ref dir) = workspace_pipelines { + if dir.exists() { + return Ok(dir.clone()); + } + } + + // Fallback to user home directory + if let Some(home) = dirs::home_dir() { + let dir = home.join(".zclaw").join("pipelines"); + return Ok(dir); + } + + Err("Could not determine pipelines directory".to_string()) +} + +/// Scan pipelines with paths (returns both info and file paths) +pub(crate) fn scan_pipelines_with_paths( + dir: &PathBuf, + category_filter: Option<&str>, + industry_filter: Option<&str>, + pipelines: &mut Vec<(PipelineInfo, PathBuf)>, +) -> Result<(), String> { + tracing::debug!("[scan] Entering directory: {:?}", dir); + let entries = std::fs::read_dir(dir) + .map_err(|e| format!("Failed to read pipelines directory: {}", e))?; + + for entry in entries { + let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?; + let path = entry.path(); + + if path.is_dir() { + // Recursively scan subdirectory + scan_pipelines_with_paths(&path, category_filter, industry_filter, pipelines)?; + } else if path.extension().map(|e| e == "yaml" || e == "yml").unwrap_or(false) { + // Try to parse pipeline file + tracing::debug!("[scan] Found YAML file: {:?}", path); + if let Ok(content) = std::fs::read_to_string(&path) { + tracing::debug!("[scan] File content length: {} bytes", content.len()); + match parse_pipeline_yaml(&content) { + Ok(pipeline) => { + tracing::debug!( + "[scan] Parsed YAML: {} -> category: {:?}, industry: {:?}", + pipeline.metadata.name, + pipeline.metadata.category, + pipeline.metadata.industry + ); + + // Apply category filter + if let Some(filter) = category_filter { + if pipeline.metadata.category.as_deref() != Some(filter) { + continue; + } + } + + // Apply industry filter + if let Some(filter) = industry_filter { + if pipeline.metadata.industry.as_deref() != Some(filter) { + continue; + } + } + + tracing::debug!("[scan] Found pipeline: {} at {:?}", pipeline.metadata.name, path); + pipelines.push((pipeline_to_info(&pipeline), path)); + } + Err(e) => { + tracing::error!("[scan] Failed to parse pipeline at {:?}: {}", path, e); + } + } + } + } + } + + Ok(()) +} + +pub(crate) fn scan_pipelines_full_sync( + dir: &PathBuf, + pipelines: &mut Vec<(PathBuf, Pipeline)>, +) -> Result<(), String> { + let entries = std::fs::read_dir(dir) + .map_err(|e| format!("Failed to read pipelines directory: {}", e))?; + + for entry in entries { + let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?; + let path = entry.path(); + + if path.is_dir() { + scan_pipelines_full_sync(&path, pipelines)?; + } else if path.extension().map(|e| e == "yaml" || e == "yml").unwrap_or(false) { + if let Ok(content) = std::fs::read_to_string(&path) { + if let Ok(pipeline) = parse_pipeline_yaml(&content) { + pipelines.push((path, pipeline)); + } + } + } + } + + Ok(()) +} + +pub(crate) fn pipeline_to_info(pipeline: &Pipeline) -> PipelineInfo { + let industry = pipeline.metadata.industry.clone().unwrap_or_default(); + tracing::debug!( + "[pipeline_to_info] Pipeline: {}, category: {:?}, industry: {:?}", + pipeline.metadata.name, + pipeline.metadata.category, + pipeline.metadata.industry + ); + + PipelineInfo { + id: pipeline.metadata.name.clone(), + display_name: pipeline.metadata.display_name.clone() + .unwrap_or_else(|| pipeline.metadata.name.clone()), + description: pipeline.metadata.description.clone().unwrap_or_default(), + category: pipeline.metadata.category.clone().unwrap_or_default(), + industry, + tags: pipeline.metadata.tags.clone(), + icon: pipeline.metadata.icon.clone().unwrap_or_else(|| "📦".to_string()), + version: pipeline.metadata.version.clone(), + author: pipeline.metadata.author.clone().unwrap_or_default(), + inputs: pipeline.spec.inputs.iter().map(|input| { + PipelineInputInfo { + name: input.name.clone(), + input_type: match input.input_type { + zclaw_pipeline::InputType::String => "string".to_string(), + zclaw_pipeline::InputType::Number => "number".to_string(), + zclaw_pipeline::InputType::Boolean => "boolean".to_string(), + zclaw_pipeline::InputType::Select => "select".to_string(), + zclaw_pipeline::InputType::MultiSelect => "multi-select".to_string(), + zclaw_pipeline::InputType::File => "file".to_string(), + zclaw_pipeline::InputType::Text => "text".to_string(), + }, + required: input.required, + label: input.label.clone().unwrap_or_else(|| input.name.clone()), + placeholder: input.placeholder.clone(), + default: input.default.clone(), + options: input.options.clone(), + } + }).collect(), + } +} diff --git a/desktop/src-tauri/src/pipeline_commands/intent_router.rs b/desktop/src-tauri/src/pipeline_commands/intent_router.rs new file mode 100644 index 0000000..1b7c1ec --- /dev/null +++ b/desktop/src-tauri/src/pipeline_commands/intent_router.rs @@ -0,0 +1,293 @@ +//! Intent routing commands and LLM driver creation from config. + +use std::collections::HashMap; +use std::sync::Arc; +use tauri::State; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use secrecy::SecretString; + +use zclaw_pipeline::LlmActionDriver; + +use super::adapters::RuntimeLlmAdapter; +use super::PipelineState; + +use crate::kernel_commands::KernelState; + +/// Route result for frontend +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum RouteResultResponse { + Matched { + pipeline_id: String, + display_name: Option, + mode: String, + params: HashMap, + confidence: f32, + missing_params: Vec, + }, + Ambiguous { + candidates: Vec, + }, + NoMatch { + suggestions: Vec, + }, + NeedMoreInfo { + prompt: String, + related_pipeline: Option, + }, +} + +/// Missing parameter info +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MissingParamInfo { + pub name: String, + pub label: Option, + pub param_type: String, + pub required: bool, + pub default: Option, +} + +/// Pipeline candidate info +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PipelineCandidateInfo { + pub id: String, + pub display_name: Option, + pub description: Option, + pub icon: Option, + pub category: Option, + pub match_reason: Option, +} + +/// Route user input to matching pipeline +#[tauri::command] +pub async fn route_intent( + state: State<'_, Arc>, + kernel_state: State<'_, KernelState>, + user_input: String, +) -> Result { + use zclaw_pipeline::{TriggerParser, Trigger, TriggerParam, compile_trigger}; + + tracing::debug!("[route_intent] Routing user input: {}", user_input); + + // Build trigger parser from loaded pipelines + let pipelines = state.pipelines.read().await; + let mut parser = TriggerParser::new(); + + for (id, pipeline) in pipelines.iter() { + // Derive trigger info from pipeline metadata (tags as keywords, description) + let trigger = Trigger { + keywords: pipeline.metadata.tags.clone(), + patterns: vec![], // Patterns not defined in Pipeline struct + description: pipeline.metadata.description.clone(), + examples: vec![], // Examples not defined in Pipeline struct + }; + + // Convert pipeline inputs to trigger params + let param_defs: Vec = pipeline.spec.inputs.iter().map(|input| { + TriggerParam { + name: input.name.clone(), + param_type: match input.input_type { + zclaw_pipeline::InputType::String => "string".to_string(), + zclaw_pipeline::InputType::Number => "number".to_string(), + zclaw_pipeline::InputType::Boolean => "boolean".to_string(), + zclaw_pipeline::InputType::Select => "select".to_string(), + zclaw_pipeline::InputType::MultiSelect => "multi-select".to_string(), + zclaw_pipeline::InputType::File => "file".to_string(), + zclaw_pipeline::InputType::Text => "text".to_string(), + }, + required: input.required, + label: input.label.clone(), + default: input.default.clone(), + } + }).collect(); + + match compile_trigger( + id.clone(), + pipeline.metadata.display_name.clone(), + &trigger, + param_defs, + ) { + Ok(compiled) => parser.register(compiled), + Err(e) => { + tracing::warn!("[WARN route_intent] Failed to compile trigger for {}: {}", id, e); + } + } + } + + // Quick match + if let Some(match_result) = parser.quick_match(&user_input) { + let trigger = parser.get_trigger(&match_result.pipeline_id); + + // Determine input mode + let mode = if let Some(t) = &trigger { + let required_count = t.param_defs.iter().filter(|p| p.required).count(); + if required_count > 3 || t.param_defs.len() > 5 { + "form" + } else if t.param_defs.is_empty() { + "conversation" + } else { + "conversation" + } + } else { + "auto" + }; + + // Find missing params + let missing_params: Vec = trigger + .map(|t| { + t.param_defs.iter() + .filter(|p| p.required && !match_result.params.contains_key(&p.name) && p.default.is_none()) + .map(|p| MissingParamInfo { + name: p.name.clone(), + label: p.label.clone(), + param_type: p.param_type.clone(), + required: p.required, + default: p.default.clone(), + }) + .collect() + }) + .unwrap_or_default(); + + return Ok(RouteResultResponse::Matched { + pipeline_id: match_result.pipeline_id, + display_name: trigger.and_then(|t| t.display_name.clone()), + mode: mode.to_string(), + params: match_result.params, + confidence: match_result.confidence, + missing_params, + }); + } + + // Semantic match via LLM (if kernel is initialized) + let triggers = parser.triggers(); + if !triggers.is_empty() { + let llm_driver = { + let kernel_lock = kernel_state.lock().await; + kernel_lock.as_ref().map(|k| k.driver()) + }; + + if let Some(driver) = llm_driver { + use zclaw_pipeline::{RuntimeLlmIntentDriver, LlmIntentDriver}; + let intent_driver = RuntimeLlmIntentDriver::new(driver); + + if let Some(result) = intent_driver.semantic_match(&user_input, &triggers).await { + tracing::debug!( + "[route_intent] Semantic match: pipeline={}, confidence={}", + result.pipeline_id, result.confidence + ); + + let trigger = parser.get_trigger(&result.pipeline_id); + let mode = "auto".to_string(); + + let missing_params: Vec = trigger + .map(|t| { + t.param_defs.iter() + .filter(|p| p.required && !result.params.contains_key(&p.name) && p.default.is_none()) + .map(|p| MissingParamInfo { + name: p.name.clone(), + label: p.label.clone(), + param_type: p.param_type.clone(), + required: p.required, + default: p.default.clone(), + }) + .collect() + }) + .unwrap_or_default(); + + return Ok(RouteResultResponse::Matched { + pipeline_id: result.pipeline_id, + display_name: trigger.and_then(|t| t.display_name.clone()), + mode, + params: result.params, + confidence: result.confidence, + missing_params, + }); + } + } + } + + // No match - return suggestions + let suggestions: Vec = parser.triggers() + .iter() + .take(3) + .map(|t| PipelineCandidateInfo { + id: t.pipeline_id.clone(), + display_name: t.display_name.clone(), + description: t.description.clone(), + icon: None, + category: None, + match_reason: Some("推荐".to_string()), + }) + .collect(); + + Ok(RouteResultResponse::NoMatch { suggestions }) +} + +/// Create an LLM driver from configuration file or environment variables +pub(crate) fn create_llm_driver_from_config() -> Option> { + // Try to read config file + let config_path = dirs::config_dir() + .map(|p| p.join("zclaw").join("config.toml"))?; + + if !config_path.exists() { + tracing::debug!("[create_llm_driver] Config file not found at {:?}", config_path); + return None; + } + + // Read and parse config + let config_content = std::fs::read_to_string(&config_path).ok()?; + let config: toml::Value = toml::from_str(&config_content).ok()?; + + // Extract LLM config + let llm_config = config.get("llm")?; + + let provider = llm_config.get("provider")?.as_str()?.to_string(); + let api_key = llm_config.get("api_key")?.as_str()?.to_string(); + let base_url = llm_config.get("base_url").and_then(|v| v.as_str()).map(|s| s.to_string()); + let model = llm_config.get("model").and_then(|v| v.as_str()).map(|s| s.to_string()); + + tracing::debug!("[create_llm_driver] Found LLM config: provider={}, model={:?}", provider, model); + + // Convert api_key to SecretString + let secret_key = SecretString::new(api_key); + + // Create the runtime driver — use with_base_url when a custom endpoint is configured + // (essential for Chinese providers like doubao, qwen, deepseek, kimi) + let runtime_driver: Arc = match provider.as_str() { + "anthropic" => { + if let Some(url) = base_url { + Arc::new(zclaw_runtime::AnthropicDriver::with_base_url(secret_key, url)) + } else { + Arc::new(zclaw_runtime::AnthropicDriver::new(secret_key)) + } + } + "openai" | "doubao" | "qwen" | "deepseek" | "kimi" | "zhipu" => { + // Chinese providers typically need a custom base_url + if let Some(url) = base_url { + Arc::new(zclaw_runtime::OpenAiDriver::with_base_url(secret_key, url)) + } else { + Arc::new(zclaw_runtime::OpenAiDriver::new(secret_key)) + } + } + "gemini" => { + if let Some(url) = base_url { + Arc::new(zclaw_runtime::GeminiDriver::with_base_url(secret_key, url)) + } else { + Arc::new(zclaw_runtime::GeminiDriver::new(secret_key)) + } + } + "local" | "ollama" => { + let url = base_url.unwrap_or_else(|| "http://localhost:11434".to_string()); + Arc::new(zclaw_runtime::LocalDriver::new(&url)) + } + _ => { + tracing::warn!("[WARN create_llm_driver] Unknown provider: {}", provider); + return None; + } + }; + + Some(Arc::new(RuntimeLlmAdapter::new(runtime_driver, model))) +} diff --git a/desktop/src-tauri/src/pipeline_commands/mod.rs b/desktop/src-tauri/src/pipeline_commands/mod.rs new file mode 100644 index 0000000..9e9d2f5 --- /dev/null +++ b/desktop/src-tauri/src/pipeline_commands/mod.rs @@ -0,0 +1,63 @@ +//! Pipeline commands for Tauri +//! +//! Commands for discovering, running, and monitoring Pipelines. + +pub mod adapters; +pub mod types; +pub mod discovery; +pub mod crud; +pub mod helpers; +pub mod intent_router; +pub mod presentation; + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::sync::RwLock; + +use zclaw_pipeline::{Pipeline, PipelineExecutor, ActionRegistry}; + +// Re-export key types from sub-modules for external consumers +#[allow(unused_imports)] +pub use adapters::{RuntimeLlmAdapter, PipelineSkillDriver, PipelineHandDriver}; +#[allow(unused_imports)] +pub use types::{PipelineInfo, PipelineInputInfo, RunPipelineRequest, RunPipelineResponse, PipelineRunResponse}; +#[allow(unused_imports)] +pub use crud::{CreatePipelineRequest, UpdatePipelineRequest, WorkflowStepInput}; +#[allow(unused_imports)] +pub use intent_router::{RouteResultResponse, MissingParamInfo, PipelineCandidateInfo}; +#[allow(unused_imports)] +pub use presentation::PipelineTemplateInfo; + +/// Pipeline state wrapper for Tauri +pub struct PipelineState { + /// Pipeline executor + pub executor: Arc, + /// Discovered pipelines (id -> Pipeline) + pub pipelines: RwLock>, + /// Pipeline file paths (id -> path) + pub pipeline_paths: RwLock>, +} + +impl PipelineState { + pub fn new(action_registry: Arc) -> Self { + Self { + executor: Arc::new(PipelineExecutor::new(action_registry)), + pipelines: RwLock::new(HashMap::new()), + pipeline_paths: RwLock::new(HashMap::new()), + } + } +} + +/// Create pipeline state with default action registry +pub fn create_pipeline_state() -> Arc { + // Try to create an LLM driver from environment/config + let action_registry = if let Some(driver) = intent_router::create_llm_driver_from_config() { + tracing::debug!("[create_pipeline_state] LLM driver configured successfully"); + Arc::new(ActionRegistry::new().with_llm_driver(driver)) + } else { + tracing::debug!("[create_pipeline_state] No LLM driver configured - pipelines requiring LLM will fail"); + Arc::new(ActionRegistry::new()) + }; + Arc::new(PipelineState::new(action_registry)) +} diff --git a/desktop/src-tauri/src/pipeline_commands/presentation.rs b/desktop/src-tauri/src/pipeline_commands/presentation.rs new file mode 100644 index 0000000..5c920c9 --- /dev/null +++ b/desktop/src-tauri/src/pipeline_commands/presentation.rs @@ -0,0 +1,103 @@ +//! Presentation analysis and template listing commands. + +use std::sync::Arc; +use tauri::State; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use super::types::PipelineInputInfo; +use super::PipelineState; + +/// Analyze presentation data +#[tauri::command] +pub async fn analyze_presentation( + data: Value, +) -> Result { + use zclaw_pipeline::presentation::PresentationAnalyzer; + + let analyzer = PresentationAnalyzer::new(); + let analysis = analyzer.analyze(&data); + + // Convert analysis to JSON + serde_json::to_value(&analysis).map_err(|e| e.to_string()) +} + +/// Pipeline template metadata +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PipelineTemplateInfo { + pub id: String, + pub display_name: String, + pub description: String, + pub category: String, + pub industry: String, + pub tags: Vec, + pub icon: String, + pub version: String, + pub author: String, + pub inputs: Vec, +} + +/// List available pipeline templates from the `_templates/` directory. +/// +/// Templates are pipeline YAML files that users can browse and instantiate. +/// They live in `pipelines/_templates/` and are not directly runnable +/// (they serve as blueprints). +#[tauri::command] +pub async fn pipeline_templates( + state: State<'_, Arc>, +) -> Result, String> { + let pipelines = state.pipelines.read().await; + + // Filter pipelines that have `is_template: true` in metadata + // or are in the _templates directory + let templates: Vec = pipelines.iter() + .filter_map(|(_id, pipeline)| { + // Check if this pipeline has template metadata + let is_template = pipeline.metadata.annotations + .as_ref() + .and_then(|a| a.get("is_template")) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + if !is_template { + return None; + } + + Some(PipelineTemplateInfo { + id: pipeline.metadata.name.clone(), + display_name: pipeline.metadata.display_name.clone() + .unwrap_or_else(|| pipeline.metadata.name.clone()), + description: pipeline.metadata.description.clone().unwrap_or_default(), + category: pipeline.metadata.category.clone().unwrap_or_default(), + industry: pipeline.metadata.industry.clone().unwrap_or_default(), + tags: pipeline.metadata.tags.clone(), + icon: pipeline.metadata.icon.clone().unwrap_or_else(|| "📦".to_string()), + version: pipeline.metadata.version.clone(), + author: pipeline.metadata.author.clone().unwrap_or_default(), + inputs: pipeline.spec.inputs.iter().map(|input| { + PipelineInputInfo { + name: input.name.clone(), + input_type: match input.input_type { + zclaw_pipeline::InputType::String => "string".to_string(), + zclaw_pipeline::InputType::Number => "number".to_string(), + zclaw_pipeline::InputType::Boolean => "boolean".to_string(), + zclaw_pipeline::InputType::Select => "select".to_string(), + zclaw_pipeline::InputType::MultiSelect => "multi-select".to_string(), + zclaw_pipeline::InputType::File => "file".to_string(), + zclaw_pipeline::InputType::Text => "text".to_string(), + }, + required: input.required, + label: input.label.clone().unwrap_or_else(|| input.name.clone()), + placeholder: input.placeholder.clone(), + default: input.default.clone(), + options: input.options.clone(), + } + }).collect(), + }) + }) + .collect(); + + tracing::debug!("[pipeline_templates] Found {} templates", templates.len()); + Ok(templates) +} diff --git a/desktop/src-tauri/src/pipeline_commands/types.rs b/desktop/src-tauri/src/pipeline_commands/types.rs new file mode 100644 index 0000000..d5ee9ac --- /dev/null +++ b/desktop/src-tauri/src/pipeline_commands/types.rs @@ -0,0 +1,99 @@ +//! Public types for Pipeline commands. + +use std::collections::HashMap; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// Pipeline info for list display +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PipelineInfo { + /// Pipeline ID (name) + pub id: String, + /// Display name + pub display_name: String, + /// Description + pub description: String, + /// Category (functional classification) + pub category: String, + /// Industry classification (e.g., "internet", "finance", "healthcare") + pub industry: String, + /// Tags + pub tags: Vec, + /// Icon (emoji) + pub icon: String, + /// Version + pub version: String, + /// Author + pub author: String, + /// Input parameters + pub inputs: Vec, +} + +/// Pipeline input parameter info +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PipelineInputInfo { + /// Parameter name + pub name: String, + /// Input type + pub input_type: String, + /// Is required + pub required: bool, + /// Label + pub label: String, + /// Placeholder + pub placeholder: Option, + /// Default value + pub default: Option, + /// Options (for select/multi-select) + pub options: Vec, +} + +/// Run pipeline request +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RunPipelineRequest { + /// Pipeline ID + pub pipeline_id: String, + /// Input values + pub inputs: HashMap, +} + +/// Run pipeline response +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RunPipelineResponse { + /// Run ID + pub run_id: String, + /// Pipeline ID + pub pipeline_id: String, + /// Status + pub status: String, +} + +/// Pipeline run status response +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PipelineRunResponse { + /// Run ID + pub run_id: String, + /// Pipeline ID + pub pipeline_id: String, + /// Status + pub status: String, + /// Current step + pub current_step: Option, + /// Progress percentage + pub percentage: u8, + /// Message + pub message: String, + /// Outputs (if completed) + pub outputs: Option, + /// Error (if failed) + pub error: Option, + /// Started at + pub started_at: String, + /// Ended at + pub ended_at: Option, +} diff --git a/desktop/src/components/AgentOnboardingWizard.tsx b/desktop/src/components/AgentOnboardingWizard.tsx index c3693c8..237a94b 100644 --- a/desktop/src/components/AgentOnboardingWizard.tsx +++ b/desktop/src/components/AgentOnboardingWizard.tsx @@ -91,6 +91,7 @@ const steps = [ export function AgentOnboardingWizard({ isOpen, onClose, onSuccess }: AgentOnboardingWizardProps) { const { createClone, createFromTemplate, updateClone, clones, isLoading, error, clearError } = useAgentStore(); + const availableTemplates = useSaaSStore((s) => s.availableTemplates); const [currentStep, setCurrentStep] = useState(0); const [formData, setFormData] = useState(initialFormData); const [errors, setErrors] = useState>({}); @@ -384,7 +385,7 @@ export function AgentOnboardingWizard({ isOpen, onClose, onSuccess }: AgentOnboa
空白 Agent
从零配置
- {useSaaSStore.getState().availableTemplates.map(t => ( + {(availableTemplates ?? []).map(t => (