From 2876f4468e3adddc72d528e507644d308d1c17a2 Mon Sep 17 00:00:00 2001 From: oxoxDev <164490987+oxoxDev@users.noreply.github.com> Date: Wed, 25 Feb 2026 12:13:49 +0530 Subject: [PATCH 1/9] Feat/zeroclaw x alphahuman (#140) * feat(runtime): add skill_type to SkillManifest for unified registry Add optional skill_type field (alphahuman | openclaw) with serde default 'alphahuman' so existing manifest.json files remain valid. Enables type-based dispatch in the unified skill system. * feat(runtime): add UnifiedSkillEntry and UnifiedSkillResult types UnifiedSkillEntry provides a common interface for both alphahuman (QuickJS) and openclaw (SKILL.md/TOML) skills: id, name, skill_type, version, description, status, tools. UnifiedSkillResult standardizes execution responses (skill_id, tool_name, content, is_error, executed_at) and is MCP-spec compatible for content blocks. * feat(runtime): expose skills_source_dir() on RuntimeEngine Public getter for the skill source directory so the unified skill generator can write new alphahuman skills (manifest.json + index.js) to the same directory the runtime discovers skills from. * feat(skills): add unified skill registry, openclaw executor, generator - UnifiedSkillRegistry: merges alphahuman (RuntimeEngine::discover_skills) and openclaw (load_skills from ~/.alphahuman/workspace/skills/), dispatches execute by skill_type, supports generate from GenerateSkillSpec. - openclaw_executor: runs SKILL.toml tools via shell (sh -c) or http (reqwest), returns SKILL.md prompt as text when no tools; interpolation. - generator: generate_alphahuman writes manifest.json + index.js to skills dir; generate_openclaw writes SKILL.md or SKILL.toml to workspace/skills//. * feat(commands): add Tauri commands for unified skill API - unified_list_skills: returns Vec (alphahuman + openclaw). - unified_execute_skill: dispatches by skill_type to QuickJS or openclaw executor, returns UnifiedSkillResult. - unified_generate_skill: generates skill from spec, returns new UnifiedSkillEntry. Desktop implementations use UnifiedSkillRegistry; mobile stubs return empty/error (QuickJS not available on Android/iOS). * feat(tauri): register unified skill commands in generate_handler Wire unified_list_skills, unified_execute_skill, and unified_generate_skill into both desktop and mobile generate_handler![] blocks so the frontend can invoke them via Tauri IPC. * feat(skills): add skill_type to frontend skill types - SkillManifest (lib/skills/types.ts): optional skill_type 'alphahuman' | 'openclaw'. - SkillListEntry (skills/shared.tsx): same for list entries so the grid can show type badges and handle openclaw vs alphahuman differently if needed. * feat(SkillsGrid): use unified registry, type badges, generate button - Load skills via unified_list_skills (fallback to runtime_discover_skills when unavailable, e.g. mobile). - Show SkillTypeBadge per row: blue for alphahuman, purple for openclaw. - Add Generate button that calls unified_generate_skill with a demo alphahuman spec and refreshes the list so the new skill appears. * feat(skills): unified skill registry with security hardening and PR fixes - Add UnifiedSkillRegistry merging alphahuman (QuickJS) and openclaw (SKILL.md/TOML) skill types - Add three Tauri commands: unified_list_skills, unified_execute_skill, unified_generate_skill - Add skill_type field to SkillManifest and UnifiedSkillEntry - Add SkillTypeBadge (blue=alphahuman, sage=openclaw) and Generate button in SkillsGrid Security fixes from PR review: - Fix shell injection in openclaw_executor: POSIX single-quote escaping + 30s timeout - Fix SSRF in run_http_tool: URL scheme validation + DNS resolution + private IP blocking - Add EscapeContext enum (Shell/Url/None) for context-aware arg interpolation Generator fixes: - Guard sanitize_id empty result with early Err in generate_alphahuman/generate_openclaw - Fix JS description embedding via serde_json::to_string (proper newline/backslash escaping) - Fix sanitize_fn_name to prepend _ when result starts with digit - Replace manual TOML format! with serde structs + toml::to_string for correct escaping Frontend fixes: - Extract normalizeUnifiedEntry helper shared by initial load and post-generate refresh - Replace hardcoded hasSetup/ignoreInProduction in refreshed mapping with data-driven values - Use SkillType alias centralising 'alphahuman' | 'openclaw' union in types.ts and shared.tsx - Fix SkillTypeBadge colors to approved palette (sage) and radius token (rounded-md) Nitpick: remove status from UnifiedSkillEntry (frontend derives status from Redux) Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/unified_skills.rs | 105 ++++++++ src-tauri/src/lib.rs | 10 + src-tauri/src/runtime/manifest.rs | 9 + src-tauri/src/runtime/qjs_engine.rs | 5 + src-tauri/src/runtime/types.rs | 32 +++ src-tauri/src/unified_skills/generator.rs | 207 ++++++++++++++ src-tauri/src/unified_skills/mod.rs | 237 ++++++++++++++++ .../src/unified_skills/openclaw_executor.rs | 254 ++++++++++++++++++ src/components/SkillsGrid.tsx | 159 ++++++++--- src/components/skills/shared.tsx | 4 +- src/lib/skills/types.ts | 5 + 12 files changed, 991 insertions(+), 37 deletions(-) create mode 100644 src-tauri/src/commands/unified_skills.rs create mode 100644 src-tauri/src/unified_skills/generator.rs create mode 100644 src-tauri/src/unified_skills/mod.rs create mode 100644 src-tauri/src/unified_skills/openclaw_executor.rs diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index fb6586dd7..780fdd303 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -4,6 +4,7 @@ pub mod runtime; pub mod socket; pub mod tdlib; pub mod alphahuman; +pub mod unified_skills; #[cfg(desktop)] pub mod window; diff --git a/src-tauri/src/commands/unified_skills.rs b/src-tauri/src/commands/unified_skills.rs new file mode 100644 index 000000000..5f3887f3e --- /dev/null +++ b/src-tauri/src/commands/unified_skills.rs @@ -0,0 +1,105 @@ +//! Tauri commands for the unified skill registry. +//! +//! These commands expose a single, type-agnostic API to the frontend WebView. +//! Internally they dispatch to the QuickJS runtime (alphahuman skills) or the +//! file-based executor (openclaw skills) based on `skill_type`. +//! +//! Commands are desktop-only — mobile stubs return empty/error results. + +use crate::runtime::types::{UnifiedSkillEntry, UnifiedSkillResult}; +use crate::unified_skills::GenerateSkillSpec; +use std::sync::Arc; +use tauri::State; + +#[cfg(not(any(target_os = "android", target_os = "ios")))] +use crate::runtime::qjs_engine::RuntimeEngine; + +// ============================================================================= +// Desktop implementations +// ============================================================================= + +#[cfg(not(any(target_os = "android", target_os = "ios")))] +mod desktop { + use super::*; + use crate::unified_skills::UnifiedSkillRegistry; + + /// List all skills from the unified registry (both alphahuman and openclaw types). + #[tauri::command] + pub async fn unified_list_skills( + engine: State<'_, Arc>, + ) -> Result, String> { + let registry = UnifiedSkillRegistry::new(Arc::clone(&engine)); + Ok(registry.list_all().await) + } + + /// Execute a named tool on any registered skill. + /// + /// Dispatches based on skill_type: + /// - `alphahuman` → QuickJS runtime + /// - `openclaw` → shell/http executor or returns prompt content + #[tauri::command] + pub async fn unified_execute_skill( + engine: State<'_, Arc>, + skill_id: String, + tool_name: String, + args: serde_json::Value, + ) -> Result { + let registry = UnifiedSkillRegistry::new(Arc::clone(&engine)); + registry.execute(&skill_id, &tool_name, args).await + } + + /// Programmatically generate a new skill, register it, and return its entry. + /// + /// For `skill_type = "alphahuman"`: writes manifest.json + index.js to the skills dir. + /// For `skill_type = "openclaw"`: writes SKILL.md or SKILL.toml to workspace/skills/. + /// + /// The skill is immediately available in subsequent `unified_list_skills` calls. + #[tauri::command] + pub async fn unified_generate_skill( + engine: State<'_, Arc>, + spec: GenerateSkillSpec, + ) -> Result { + let registry = UnifiedSkillRegistry::new(Arc::clone(&engine)); + registry.generate(spec).await + } +} + +// ============================================================================= +// Mobile stubs (QuickJS not available on Android/iOS) +// ============================================================================= + +#[cfg(any(target_os = "android", target_os = "ios"))] +mod mobile { + use super::*; + + #[tauri::command] + pub async fn unified_list_skills() -> Result, String> { + Ok(vec![]) + } + + #[tauri::command] + pub async fn unified_execute_skill( + _skill_id: String, + _tool_name: String, + _args: serde_json::Value, + ) -> Result { + Err("Unified skill execution is not available on mobile platforms.".to_string()) + } + + #[tauri::command] + pub async fn unified_generate_skill( + _spec: GenerateSkillSpec, + ) -> Result { + Err("Skill generation is not available on mobile platforms.".to_string()) + } +} + +// ============================================================================= +// Re-export the right module based on platform +// ============================================================================= + +#[cfg(not(any(target_os = "android", target_os = "ios")))] +pub use desktop::{unified_execute_skill, unified_generate_skill, unified_list_skills}; + +#[cfg(any(target_os = "android", target_os = "ios"))] +pub use mobile::{unified_execute_skill, unified_generate_skill, unified_list_skills}; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d1474ad25..b34177a6e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -15,9 +15,11 @@ mod models; mod runtime; mod services; pub mod alphahuman; +mod unified_skills; mod utils; use ai::*; +use commands::unified_skills::{unified_execute_skill, unified_generate_skill, unified_list_skills}; use commands::*; use services::socket_service::SOCKET_SERVICE; use tauri::{AppHandle, Manager, RunEvent}; @@ -691,6 +693,10 @@ pub fn run() { alphahuman_service_stop, alphahuman_service_status, alphahuman_service_uninstall, + // Unified skill registry commands + unified_list_skills, + unified_execute_skill, + unified_generate_skill, ] } #[cfg(not(desktop))] @@ -803,6 +809,10 @@ pub fn run() { alphahuman_service_stop, alphahuman_service_status, alphahuman_service_uninstall, + // Unified skill registry commands (mobile stubs) + unified_list_skills, + unified_execute_skill, + unified_generate_skill, ] } }) diff --git a/src-tauri/src/runtime/manifest.rs b/src-tauri/src/runtime/manifest.rs index 60e9eac4d..c667b8883 100644 --- a/src-tauri/src/runtime/manifest.rs +++ b/src-tauri/src/runtime/manifest.rs @@ -50,6 +50,15 @@ pub struct SkillManifest { /// When absent or empty, the skill is available on all platforms. #[serde(default)] pub platforms: Option>, + /// Skill type for the unified registry dispatch. + /// "alphahuman" → executed via QuickJS runtime (default). + /// "openclaw" → loaded and executed from SKILL.md/SKILL.toml. + #[serde(default = "default_skill_type")] + pub skill_type: String, +} + +fn default_skill_type() -> String { + "alphahuman".to_string() } fn default_runtime() -> String { diff --git a/src-tauri/src/runtime/qjs_engine.rs b/src-tauri/src/runtime/qjs_engine.rs index 4e4f0d751..b52e76d14 100644 --- a/src-tauri/src/runtime/qjs_engine.rs +++ b/src-tauri/src/runtime/qjs_engine.rs @@ -175,6 +175,11 @@ impl RuntimeEngine { Ok(prod_dir) } + /// Expose the resolved skills source directory (for external callers like unified registry). + pub fn skills_source_dir(&self) -> Result { + self.get_skills_source_dir() + } + /// Discover all JavaScript skills from the skills directory. pub async fn discover_skills(&self) -> Result, String> { let skills_dir = self.get_skills_source_dir()?; diff --git a/src-tauri/src/runtime/types.rs b/src-tauri/src/runtime/types.rs index 3beb3d646..77bf94edf 100644 --- a/src-tauri/src/runtime/types.rs +++ b/src-tauri/src/runtime/types.rs @@ -165,6 +165,38 @@ fn default_memory_limit() -> usize { 256 * 1024 * 1024 // 256 MB } +/// A skill entry in the unified registry (covers both alphahuman and openclaw types). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UnifiedSkillEntry { + /// Unique skill identifier. + pub id: String, + /// Human-readable name. + pub name: String, + /// Skill type: "alphahuman" (QuickJS) or "openclaw" (SKILL.md/TOML). + pub skill_type: String, + /// Version string. + pub version: String, + /// Description of what the skill does. + pub description: String, + /// Tools exposed by this skill. + pub tools: Vec, +} + +/// Standardized result returned by any skill execution in the unified registry. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UnifiedSkillResult { + /// The skill that produced this result. + pub skill_id: String, + /// The tool that was called, if any. + pub tool_name: Option, + /// Output content blocks (MCP-spec compatible, same as ToolResult). + pub content: Vec, + /// Whether the execution produced an error. + pub is_error: bool, + /// ISO8601 timestamp of when execution completed. + pub executed_at: String, +} + /// Events emitted from the runtime to the frontend via Tauri. #[allow(dead_code)] pub mod events { diff --git a/src-tauri/src/unified_skills/generator.rs b/src-tauri/src/unified_skills/generator.rs new file mode 100644 index 000000000..38c3ce27b --- /dev/null +++ b/src-tauri/src/unified_skills/generator.rs @@ -0,0 +1,207 @@ +//! Programmatic skill generation for the unified skill registry. +//! +//! Supports generating both skill types: +//! - `alphahuman`: writes manifest.json + index.js to the QuickJS skills directory. +//! - `openclaw`: writes SKILL.md or SKILL.toml to the alphahuman workspace skills directory. + +use crate::unified_skills::GenerateSkillSpec; +use directories::UserDirs; +use serde::Serialize; +use std::path::{Path, PathBuf}; + +/// Generate an alphahuman (QuickJS) skill at `//`. +/// Returns the path of the created skill directory. +pub async fn generate_alphahuman(spec: &GenerateSkillSpec, skills_dir: &Path) -> Result { + let dir_name = sanitize_id(&spec.name); + if dir_name.is_empty() { + return Err(format!( + "Invalid skill name '{}': must contain at least one alphanumeric character", + spec.name + )); + } + let skill_dir = skills_dir.join(&dir_name); + + tokio::fs::create_dir_all(&skill_dir) + .await + .map_err(|e| format!("Failed to create skill directory: {e}"))?; + + // Write manifest.json + let manifest = serde_json::json!({ + "id": dir_name, + "name": spec.name, + "skill_type": "alphahuman", + "runtime": "quickjs", + "entry": "index.js", + "version": "1.0.0", + "description": spec.description, + "auto_start": false + }); + let manifest_path = skill_dir.join("manifest.json"); + tokio::fs::write(&manifest_path, serde_json::to_string_pretty(&manifest).unwrap()) + .await + .map_err(|e| format!("Failed to write manifest.json: {e}"))?; + + // Write index.js + let tool_code = spec.tool_code.as_deref().unwrap_or( + "return { result: 'Generated skill executed successfully', args };", + ); + let tool_fn_name = sanitize_fn_name(&spec.name); + + let index_js = build_index_js(&tool_fn_name, &spec.description, tool_code); + tokio::fs::write(skill_dir.join("index.js"), index_js) + .await + .map_err(|e| format!("Failed to write index.js: {e}"))?; + + Ok(skill_dir) +} + +/// Generate an openclaw (SKILL.md/TOML) skill in `~/.alphahuman/workspace/skills//`. +/// Returns the path of the created skill directory. +pub async fn generate_openclaw(spec: &GenerateSkillSpec) -> Result { + let dir_name = sanitize_id(&spec.name); + if dir_name.is_empty() { + return Err(format!( + "Invalid skill name '{}': must contain at least one alphanumeric character", + spec.name + )); + } + + let base = workspace_skills_dir()?; + let skill_dir = base.join(&dir_name); + + tokio::fs::create_dir_all(&skill_dir) + .await + .map_err(|e| format!("Failed to create openclaw skill directory: {e}"))?; + + if let Some(cmd) = &spec.shell_command { + // Structured TOML skill with a shell tool — use serde so all special + // characters (backslashes, quotes, etc.) are correctly escaped. + #[derive(Serialize)] + struct SkillTomlFile { + skill: SkillTomlMeta, + tools: Vec, + } + #[derive(Serialize)] + struct SkillTomlMeta { + name: String, + description: String, + version: String, + } + #[derive(Serialize)] + struct SkillTomlTool { + name: String, + description: String, + kind: String, + command: String, + } + + let obj = SkillTomlFile { + skill: SkillTomlMeta { + name: spec.name.clone(), + description: spec.description.clone(), + version: "1.0.0".to_string(), + }, + tools: vec![SkillTomlTool { + name: sanitize_fn_name(&spec.name), + description: spec.description.clone(), + kind: "shell".to_string(), + command: cmd.clone(), + }], + }; + + let toml_content = toml::to_string(&obj) + .map_err(|e| format!("Failed to serialize SKILL.toml: {e}"))?; + tokio::fs::write(skill_dir.join("SKILL.toml"), toml_content) + .await + .map_err(|e| format!("Failed to write SKILL.toml: {e}"))?; + } else { + // Markdown prompt skill. + let md_content = spec.markdown_content.as_deref().unwrap_or(&spec.description); + let full_md = format!("# {}\n\n{}\n", spec.name, md_content); + tokio::fs::write(skill_dir.join("SKILL.md"), full_md) + .await + .map_err(|e| format!("Failed to write SKILL.md: {e}"))?; + } + + Ok(skill_dir) +} + +/// Returns `~/.alphahuman/workspace/skills/`. +fn workspace_skills_dir() -> Result { + let dirs = UserDirs::new().ok_or("Cannot resolve home directory")?; + Ok(dirs + .home_dir() + .join(".alphahuman") + .join("workspace") + .join("skills")) +} + +/// Build a minimal but functional QuickJS skill index.js. +/// The description is JSON-serialized so newlines, backslashes, and quotes are +/// always correctly escaped for embedding in a JS string literal. +fn build_index_js(tool_fn: &str, description: &str, tool_code: &str) -> String { + // serde_json::to_string produces a quoted, escaped JSON string literal. + let desc_json = serde_json::to_string(description) + .unwrap_or_else(|_| r#""unknown""#.to_string()); + + format!( + r#"// Auto-generated alphahuman skill +const tools = [ + {{ + name: "{tool_fn}", + description: {desc}, + inputSchema: {{ + type: "object", + properties: {{ + args: {{ type: "object", description: "Optional arguments" }} + }} + }} + }} +]; + +async function init() {{}} + +async function start() {{}} + +async function {tool_fn}(args) {{ + {tool_code} +}} +"#, + tool_fn = tool_fn, + desc = desc_json, + tool_code = tool_code, + ) +} + +/// Convert a display name to a filesystem-safe id (lowercase, hyphens). +fn sanitize_id(name: &str) -> String { + name.to_lowercase() + .chars() + .map(|c| if c.is_alphanumeric() { c } else { '-' }) + .collect::() + .split('-') + .filter(|s| !s.is_empty()) + .collect::>() + .join("-") +} + +/// Convert a display name to a valid JS function name (lowercase, underscores). +/// Ensures the result never starts with a digit by prepending `_` when needed. +fn sanitize_fn_name(name: &str) -> String { + let joined = name + .to_lowercase() + .chars() + .map(|c| if c.is_alphanumeric() || c == '_' { c } else { '_' }) + .collect::() + .split('_') + .filter(|s| !s.is_empty()) + .collect::>() + .join("_"); + + // JS identifiers must not start with a digit. + if joined.starts_with(|c: char| c.is_ascii_digit()) { + format!("_{joined}") + } else { + joined + } +} diff --git a/src-tauri/src/unified_skills/mod.rs b/src-tauri/src/unified_skills/mod.rs new file mode 100644 index 000000000..45b0df1e8 --- /dev/null +++ b/src-tauri/src/unified_skills/mod.rs @@ -0,0 +1,237 @@ +//! Unified Skill Registry +//! +//! A single registry that aggregates both skill types: +//! +//! - `alphahuman`: JavaScript-based skills executed in the QuickJS runtime. +//! - `openclaw`: File-based skills defined in SKILL.md or SKILL.toml. +//! +//! All skills expose a common [`UnifiedSkillEntry`] interface and return +//! [`UnifiedSkillResult`] on execution, regardless of their underlying type. + +pub mod generator; +pub mod openclaw_executor; + +use crate::alphahuman::skills::{load_skills, Skill}; +use crate::runtime::qjs_engine::RuntimeEngine; +use crate::runtime::types::{ToolDefinition, UnifiedSkillEntry, UnifiedSkillResult}; +use chrono::Utc; +use directories::UserDirs; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::sync::Arc; + +/// Specification for programmatically generating a new skill. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GenerateSkillSpec { + /// Display name for the skill (e.g., "My Skill"). + pub name: String, + /// Human-readable description of what the skill does. + pub description: String, + /// Skill type: "alphahuman" or "openclaw". + pub skill_type: String, + /// For alphahuman skills: the JavaScript body of the generated tool function. + pub tool_code: Option, + /// For openclaw skills: markdown content written to SKILL.md. + pub markdown_content: Option, + /// For openclaw skills: shell command written into SKILL.toml as a tool. + pub shell_command: Option, +} + +/// The unified skill registry wrapping the QuickJS engine and openclaw loader. +pub struct UnifiedSkillRegistry { + engine: Arc, +} + +impl UnifiedSkillRegistry { + pub fn new(engine: Arc) -> Self { + Self { engine } + } + + /// List all skills from both subsystems. + /// + /// - alphahuman skills come from `RuntimeEngine::discover_skills()` (manifest.json). + /// - openclaw skills come from the alphahuman workspace skills directory (SKILL.md/TOML). + pub async fn list_all(&self) -> Vec { + let mut entries = Vec::new(); + + // --- alphahuman skills (QuickJS runtime) --- + if let Ok(manifests) = self.engine.discover_skills().await { + let snapshots = self.engine.list_skills(); + + for manifest in &manifests { + let tools = snapshots + .iter() + .find(|s| s.skill_id == manifest.id) + .map(|s| s.tools.clone()) + .unwrap_or_default(); + + entries.push(UnifiedSkillEntry { + id: manifest.id.clone(), + name: manifest.name.clone(), + skill_type: manifest.skill_type.clone(), + version: manifest.version.clone().unwrap_or_else(|| "0.1.0".to_string()), + description: manifest.description.clone().unwrap_or_default(), + tools, + }); + } + } + + // --- openclaw skills (SKILL.md / SKILL.toml) --- + let workspace_dir = workspace_dir(); + let openclaw_skills = load_skills(&workspace_dir); + + for skill in &openclaw_skills { + let id = skill_to_id(skill); + // Skip if already listed by the alphahuman runtime (avoid duplicates). + if entries.iter().any(|e| e.id == id) { + continue; + } + + let tools: Vec = skill + .tools + .iter() + .map(|t| ToolDefinition { + name: t.name.clone(), + description: t.description.clone(), + input_schema: serde_json::json!({ + "type": "object", + "properties": {} + }), + }) + .collect(); + + entries.push(UnifiedSkillEntry { + id, + name: skill.name.clone(), + skill_type: "openclaw".to_string(), + version: skill.version.clone(), + description: skill.description.clone(), + tools, + }); + } + + entries + } + + /// Execute a skill by ID. Dispatches to the appropriate backend based on skill_type. + pub async fn execute( + &self, + skill_id: &str, + tool_name: &str, + args: serde_json::Value, + ) -> Result { + let all = self.list_all().await; + let entry = all + .iter() + .find(|e| e.id == skill_id) + .ok_or_else(|| format!("Skill '{skill_id}' not found in unified registry"))?; + + match entry.skill_type.as_str() { + "alphahuman" => self.execute_alphahuman(skill_id, tool_name, args).await, + "openclaw" => self.execute_openclaw(skill_id, tool_name, args).await, + other => Err(format!("Unknown skill type: '{other}'")), + } + } + + /// Dispatch to QuickJS runtime for alphahuman skills. + async fn execute_alphahuman( + &self, + skill_id: &str, + tool_name: &str, + args: serde_json::Value, + ) -> Result { + let tool_result = self.engine.call_tool(skill_id, tool_name, args).await?; + Ok(UnifiedSkillResult { + skill_id: skill_id.to_string(), + tool_name: Some(tool_name.to_string()), + content: tool_result.content, + is_error: tool_result.is_error, + executed_at: Utc::now().to_rfc3339(), + }) + } + + /// Dispatch to openclaw executor for SKILL.md/TOML skills. + async fn execute_openclaw( + &self, + skill_id: &str, + tool_name: &str, + args: serde_json::Value, + ) -> Result { + let workspace_dir = workspace_dir(); + let skills = load_skills(&workspace_dir); + + let skill = skills + .iter() + .find(|s| skill_to_id(s) == skill_id) + .ok_or_else(|| format!("openclaw skill '{skill_id}' not found on disk"))?; + + openclaw_executor::execute(skill, skill_id, tool_name, args).await + } + + /// Generate a new skill from a spec, write it to disk, and return its registry entry. + pub async fn generate(&self, spec: GenerateSkillSpec) -> Result { + match spec.skill_type.as_str() { + "alphahuman" => { + // Find the skills source directory from the engine. + let skills_dir = self.engine.skills_source_dir()?; + generator::generate_alphahuman(&spec, &skills_dir).await?; + + // Rediscover so the new skill appears in subsequent list_all() calls. + let _ = self.engine.discover_skills().await; + + // Return the entry for the newly generated skill. + let id = sanitize_id(&spec.name); + Ok(UnifiedSkillEntry { + id, + name: spec.name, + skill_type: "alphahuman".to_string(), + version: "1.0.0".to_string(), + description: spec.description, + tools: vec![], + }) + } + "openclaw" => { + generator::generate_openclaw(&spec).await?; + + let id = sanitize_id(&spec.name); + Ok(UnifiedSkillEntry { + id, + name: spec.name, + skill_type: "openclaw".to_string(), + version: "1.0.0".to_string(), + description: spec.description, + tools: vec![], + }) + } + other => Err(format!("Unknown skill_type: '{other}'. Use 'alphahuman' or 'openclaw'.")), + } + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Derive a stable registry ID from an openclaw Skill. +fn skill_to_id(skill: &Skill) -> String { + sanitize_id(&skill.name) +} + +/// Convert a display name to a lowercase hyphen-separated ID. +fn sanitize_id(name: &str) -> String { + name.to_lowercase() + .chars() + .map(|c| if c.is_alphanumeric() { c } else { '-' }) + .collect::() + .split('-') + .filter(|s| !s.is_empty()) + .collect::>() + .join("-") +} + +/// Returns `~/.alphahuman/workspace` as the base for openclaw skills. +fn workspace_dir() -> PathBuf { + UserDirs::new() + .map(|d| d.home_dir().join(".alphahuman").join("workspace")) + .unwrap_or_else(|| PathBuf::from(".alphahuman/workspace")) +} diff --git a/src-tauri/src/unified_skills/openclaw_executor.rs b/src-tauri/src/unified_skills/openclaw_executor.rs new file mode 100644 index 000000000..90edaa69f --- /dev/null +++ b/src-tauri/src/unified_skills/openclaw_executor.rs @@ -0,0 +1,254 @@ +//! Executor for openclaw-type skills (SKILL.md / SKILL.toml). +//! +//! openclaw skills are file-based: +//! - SKILL.toml → structured tool definitions (shell/http commands) +//! - SKILL.md → markdown prompt content (returned as text) + +use crate::alphahuman::skills::{Skill, SkillTool}; +use crate::runtime::types::{ToolContent, UnifiedSkillResult}; +use chrono::Utc; +use std::collections::HashMap; +use std::net::IpAddr; + +/// How substituted placeholder values should be escaped before insertion. +#[derive(Debug, Clone, Copy)] +enum EscapeContext { + /// Values are shell-escaped (single-quote wrapping) for use in `sh -c` strings. + Shell, + /// Values are percent-encoded for use as URL components. + Url, + /// Values are inserted verbatim (no escaping). + None, +} + +/// Execute a named tool from an openclaw skill, or return prompt content if no tools. +pub async fn execute( + skill: &Skill, + skill_id: &str, + tool_name: &str, + args: serde_json::Value, +) -> Result { + let executed_at = Utc::now().to_rfc3339(); + + // SKILL.md skills have no tools — return the prompt content as text. + if skill.tools.is_empty() { + let content = skill.prompts.first().cloned().unwrap_or_default(); + return Ok(UnifiedSkillResult { + skill_id: skill_id.to_string(), + tool_name: None, + content: vec![ToolContent::Text { text: content }], + is_error: false, + executed_at, + }); + } + + // SKILL.toml: find the requested tool. + let tool = skill + .tools + .iter() + .find(|t| t.name == tool_name) + .ok_or_else(|| format!("Tool '{tool_name}' not found in skill '{}'", skill.name))?; + + let result = run_tool(tool, args).await; + + match result { + Ok(output) => Ok(UnifiedSkillResult { + skill_id: skill_id.to_string(), + tool_name: Some(tool_name.to_string()), + content: vec![ToolContent::Text { text: output }], + is_error: false, + executed_at, + }), + Err(err) => Ok(UnifiedSkillResult { + skill_id: skill_id.to_string(), + tool_name: Some(tool_name.to_string()), + content: vec![ToolContent::Text { text: err }], + is_error: true, + executed_at, + }), + } +} + +/// Run a single SkillTool based on its kind. +async fn run_tool(tool: &SkillTool, args: serde_json::Value) -> Result { + match tool.kind.as_str() { + "shell" => run_shell_tool(tool, args).await, + "http" => run_http_tool(tool, args).await, + other => Err(format!("Unsupported tool kind: '{other}'")), + } +} + +/// Execute a shell tool. +/// +/// Placeholder values are shell-escaped before substitution to prevent injection. +/// Execution is bounded to 30 seconds. +async fn run_shell_tool(tool: &SkillTool, args: serde_json::Value) -> Result { + let command = interpolate_args(&tool.command, &tool.args, &args, EscapeContext::Shell); + + let child = tokio::process::Command::new("sh") + .arg("-c") + .arg(&command) + .output(); + + let output = tokio::time::timeout(std::time::Duration::from_secs(30), child) + .await + .map_err(|_| "Shell command timed out after 30 seconds".to_string())? + .map_err(|e| format!("Failed to run shell command: {e}"))?; + + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + Err(if stderr.is_empty() { + format!("Command exited with status {}", output.status) + } else { + stderr + }) + } +} + +/// Execute an HTTP tool (GET to the URL, or POST if args provided). +/// +/// The resolved URL is validated: only http/https schemes are accepted, and +/// requests to private/internal IP ranges are rejected to prevent SSRF. +async fn run_http_tool(tool: &SkillTool, args: serde_json::Value) -> Result { + let url = interpolate_args(&tool.command, &tool.args, &args, EscapeContext::Url); + + validate_url_no_ssrf(&url).await?; + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|e| e.to_string())?; + + let args_obj = args.as_object(); + let response = if args_obj.map(|o| !o.is_empty()).unwrap_or(false) { + client.post(&url).json(&args).send().await + } else { + client.get(&url).send().await + }; + + let resp = response.map_err(|e| format!("HTTP request failed: {e}"))?; + let status = resp.status(); + let body = resp.text().await.map_err(|e| e.to_string())?; + + if status.is_success() { + Ok(body) + } else { + Err(format!("HTTP {status}: {body}")) + } +} + +/// Validate that `raw_url` is a safe public HTTP/HTTPS URL. +/// +/// Rejects: +/// - Non-http/https schemes +/// - URLs with no host +/// - Hosts that resolve to private, loopback, or link-local IP addresses (SSRF guard) +async fn validate_url_no_ssrf(raw_url: &str) -> Result<(), String> { + let parsed = url::Url::parse(raw_url).map_err(|e| format!("Invalid URL: {e}"))?; + + match parsed.scheme() { + "http" | "https" => {} + scheme => { + return Err(format!( + "URL scheme '{scheme}' is not permitted; only http and https are allowed" + )) + } + } + + let host = parsed + .host_str() + .ok_or_else(|| "URL has no host".to_string())?; + + let port = parsed.port_or_known_default().unwrap_or(80); + let addrs = tokio::net::lookup_host(format!("{host}:{port}")) + .await + .map_err(|e| format!("Failed to resolve host '{host}': {e}"))?; + + for addr in addrs { + let ip = addr.ip(); + if is_private_ip(ip) { + return Err(format!( + "SSRF protection: request to '{host}' is not allowed \ + (resolves to internal address {ip})" + )); + } + } + + Ok(()) +} + +/// Returns `true` for any IP address that belongs to a private or internal range. +fn is_private_ip(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(v4) => { + v4.is_loopback() // 127.0.0.0/8 + || v4.is_private() // 10/8, 172.16/12, 192.168/16 + || v4.is_link_local() // 169.254.0.0/16 + || v4.is_broadcast() // 255.255.255.255 + || v4.is_unspecified() // 0.0.0.0 + } + IpAddr::V6(v6) => { + v6.is_loopback() // ::1 + || v6.is_unspecified() // :: + // fc00::/7 — unique local (includes fd00::/8) + || (v6.segments()[0] & 0xfe00) == 0xfc00 + // fe80::/10 — link-local + || (v6.segments()[0] & 0xffc0) == 0xfe80 + } + } +} + +/// Replace `${key}` patterns in `template` using tool default args merged with caller args. +/// Each substituted value is escaped according to `ctx` before insertion. +fn interpolate_args( + template: &str, + tool_defaults: &HashMap, + caller_args: &serde_json::Value, + ctx: EscapeContext, +) -> String { + let escape = |val: &str| -> String { + match ctx { + EscapeContext::Shell => shell_escape(val), + EscapeContext::Url => urlencoding::encode(val).into_owned(), + EscapeContext::None => val.to_string(), + } + }; + + let mut result = template.to_string(); + + // Apply tool-level defaults first. + for (key, value) in tool_defaults { + result = result.replace(&format!("${{{key}}}"), &escape(value)); + } + + // Caller args override defaults. + if let Some(obj) = caller_args.as_object() { + for (key, value) in obj { + let str_val = match value { + serde_json::Value::String(s) => s.clone(), + other => other.to_string(), + }; + result = result.replace(&format!("${{{key}}}"), &escape(&str_val)); + } + } + + result +} + +/// POSIX single-quote shell escaping: wraps `s` in single quotes and escapes +/// any single quotes inside as `'\''`, making the value safe for `sh -c` strings. +fn shell_escape(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('\''); + for c in s.chars() { + if c == '\'' { + out.push_str("'\\''"); + } else { + out.push(c); + } + } + out.push('\''); + out +} diff --git a/src/components/SkillsGrid.tsx b/src/components/SkillsGrid.tsx index 482f6f3ea..59bf52dce 100644 --- a/src/components/SkillsGrid.tsx +++ b/src/components/SkillsGrid.tsx @@ -16,14 +16,46 @@ import { } from './skills/shared'; import SkillSetupModal from './skills/SkillSetupModal'; +/** Normalize a raw unified registry entry into a SkillListEntry for display. */ +function normalizeUnifiedEntry(e: Record): SkillListEntry { + const setup = e.setup as Record | undefined; + return { + id: e.id as string, + name: + (e.name as string) || + (e.id as string).charAt(0).toUpperCase() + (e.id as string).slice(1), + description: (e.description as string) || '', + icon: SKILL_ICONS[e.id as string], + ignoreInProduction: (e.ignoreInProduction as boolean) ?? false, + hasSetup: !!(setup && setup.required), + skill_type: (e.skill_type as 'alphahuman' | 'openclaw') ?? 'alphahuman', + }; +} + interface SkillRowProps { skillId: string; name: string; icon?: React.ReactElement; + skillType?: 'alphahuman' | 'openclaw'; onConnect: (e: React.MouseEvent) => void; } -function SkillRow({ skillId, name, icon, onConnect }: SkillRowProps) { +function SkillTypeBadge({ type }: { type?: string }) { + if (!type) return null; + const isOpenclaw = type === 'openclaw'; + return ( + + {type} + + ); +} + +function SkillRow({ skillId, name, icon, skillType, onConnect }: SkillRowProps) { const connectionStatus = useSkillConnectionStatus(skillId); const statusDisplay = STATUS_DISPLAY[connectionStatus] || STATUS_DISPLAY.offline; @@ -37,6 +69,7 @@ function SkillRow({ skillId, name, icon, onConnect }: SkillRowProps) { {icon || } {name} + @@ -72,6 +105,7 @@ export default function SkillsGrid() { const [skillsList, setSkillsList] = useState([]); const [loading, setLoading] = useState(true); const [isMobile, setIsMobile] = useState(false); + const [generating, setGenerating] = useState(false); const [setupModalOpen, setSetupModalOpen] = useState(false); const [managementModalOpen, setManagementModalOpen] = useState(false); const [activeSkillId, setActiveSkillId] = useState(null); @@ -96,45 +130,51 @@ export default function SkillsGrid() { }; detectMobile(); - // Load skills from the V8 runtime engine. + // Load skills from the unified registry (covers both alphahuman and openclaw types). const loadSkills = async () => { try { - const manifests = await invoke>>('runtime_discover_skills'); + // Try unified registry first — it merges both skill types. + const entries = await invoke>>('unified_list_skills'); - console.log('manifests', manifests); - - // Validate skill names (underscores are reserved for tool namespacing) - const validManifests = manifests.filter(m => { - const id = m.id as string; - if (id.includes('_')) { - console.warn( - `Skill "${id}" contains underscore and will be skipped. Skill names cannot contain underscores.` - ); - return false; - } - return true; - }); - - const processed: SkillListEntry[] = validManifests - .map(m => { - const setup = m.setup as Record | undefined; - return { - id: m.id as string, - name: - (m.name as string) || - (m.id as string).charAt(0).toUpperCase() + (m.id as string).slice(1), - description: (m.description as string) || '', - icon: SKILL_ICONS[m.id as string], - ignoreInProduction: (m.ignoreInProduction as boolean) ?? false, - hasSetup: !!(setup && setup.required), - }; + const processed: SkillListEntry[] = entries + .filter(e => { + const id = e.id as string; + if (id.includes('_')) { + console.warn( + `Skill "${id}" contains underscore and will be skipped. Skill IDs cannot contain underscores.` + ); + return false; + } + return true; }) + .map(normalizeUnifiedEntry) .filter(s => IS_DEV || !s.ignoreInProduction); setSkillsList(processed); - setLoading(false); - } catch (error) { - console.warn('Could not load skills from runtime:', error); + } catch { + // Fallback to legacy runtime_discover_skills if unified registry isn't available. + try { + const manifests = await invoke>>('runtime_discover_skills'); + const processed: SkillListEntry[] = manifests + .filter(m => !(m.id as string).includes('_')) + .map(m => { + const setup = m.setup as Record | undefined; + return { + id: m.id as string, + name: (m.name as string) || (m.id as string), + description: (m.description as string) || '', + icon: SKILL_ICONS[m.id as string], + ignoreInProduction: (m.ignoreInProduction as boolean) ?? false, + hasSetup: !!(setup && setup.required), + skill_type: 'alphahuman' as const, + }; + }) + .filter(s => IS_DEV || !s.ignoreInProduction); + setSkillsList(processed); + } catch (err) { + console.warn('Could not load skills:', err); + } + } finally { setLoading(false); } }; @@ -214,9 +254,55 @@ export default function SkillsGrid() { return ( <>
-

- Available Skills -

+
+

Available Skills

+ +
setManagementModalOpen(true)}> @@ -244,6 +330,7 @@ export default function SkillsGrid() { skillId={skill.id} name={skill.name} icon={skill.icon} + skillType={skill.skill_type} onConnect={e => { e.stopPropagation(); handleConnect(skill); diff --git a/src/components/skills/shared.tsx b/src/components/skills/shared.tsx index d7eeb190d..253367920 100644 --- a/src/components/skills/shared.tsx +++ b/src/components/skills/shared.tsx @@ -4,7 +4,7 @@ import GoogleIcon from '../../assets/icons/GoogleIcon'; import NotionIcon from '../../assets/icons/notion.svg'; import TelegramIcon from '../../assets/icons/telegram.svg'; import { skillManager } from '../../lib/skills/manager'; -import type { SkillConnectionStatus } from '../../lib/skills/types'; +import type { SkillConnectionStatus, SkillType } from '../../lib/skills/types'; // Map skill IDs to icons export const SKILL_ICONS: Record = { @@ -66,6 +66,8 @@ export interface SkillListEntry { ignoreInProduction?: boolean; icon?: React.ReactElement; hasSetup: boolean; + /** Unified registry type: "alphahuman" (QuickJS) or "openclaw" (SKILL.md/TOML). */ + skill_type?: SkillType; } // Contextual action button for skills diff --git a/src/lib/skills/types.ts b/src/lib/skills/types.ts index 00415be48..6eb8f8085 100644 --- a/src/lib/skills/types.ts +++ b/src/lib/skills/types.ts @@ -9,6 +9,9 @@ export type SkillPlatform = "windows" | "macos" | "linux" | "android" | "ios"; +/** Unified registry skill type discriminant. */ +export type SkillType = 'alphahuman' | 'openclaw'; + export interface SkillManifest { id: string; name: string; @@ -33,6 +36,8 @@ export interface SkillManifest { platforms?: SkillPlatform[]; /** When true, skill is hidden in production builds. */ ignoreInProduction?: boolean; + /** Unified registry type: "alphahuman" (QuickJS) or "openclaw" (skill.md/TOML). */ + skill_type?: SkillType; } // --------------------------------------------------------------------------- From 793b54ebf52b15093963346625ad758a5c2cb3ac Mon Sep 17 00:00:00 2001 From: oxoxDev <164490987+oxoxDev@users.noreply.github.com> Date: Thu, 26 Feb 2026 21:25:33 +0530 Subject: [PATCH 2/9] feat(skills): show skill type badge in manage modal header (#147) Pass skill_type through to SkillSetupModal and render an alphahuman/openclaw badge next to the title in the modal header, consistent with the grid badges. Co-authored-by: Claude Sonnet 4.6 --- src/components/SkillsGrid.tsx | 4 ++++ src/components/skills/SkillSetupModal.tsx | 25 +++++++++++++++++------ 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src/components/SkillsGrid.tsx b/src/components/SkillsGrid.tsx index 59bf52dce..734efbfd7 100644 --- a/src/components/SkillsGrid.tsx +++ b/src/components/SkillsGrid.tsx @@ -112,6 +112,7 @@ export default function SkillsGrid() { const [activeSkillName, setActiveSkillName] = useState(''); const [activeSkillDescription, setActiveSkillDescription] = useState(''); const [activeSkillHasSetup, setActiveSkillHasSetup] = useState(false); + const [activeSkillType, setActiveSkillType] = useState<'alphahuman' | 'openclaw'>('alphahuman'); // Get Redux state for sorting const skillsState = useAppSelector(state => state.skills.skills); @@ -248,6 +249,7 @@ export default function SkillsGrid() { setActiveSkillName(skill.name); setActiveSkillDescription(skill.description); setActiveSkillHasSetup(skill.hasSetup); + setActiveSkillType(skill.skill_type ?? 'alphahuman'); setSetupModalOpen(true); }; @@ -354,6 +356,7 @@ export default function SkillsGrid() { skillName={activeSkillName} skillDescription={activeSkillDescription} hasSetup={activeSkillHasSetup} + skillType={activeSkillType} onClose={() => { setSetupModalOpen(false); setActiveSkillId(null); @@ -424,6 +427,7 @@ export default function SkillsGrid() { setActiveSkillName(skill.name); setActiveSkillDescription(skill.description); setActiveSkillHasSetup(skill.hasSetup); + setActiveSkillType(skill.skill_type ?? 'alphahuman'); setSetupModalOpen(true); }} /> diff --git a/src/components/skills/SkillSetupModal.tsx b/src/components/skills/SkillSetupModal.tsx index 4233773c5..78dc71bb7 100644 --- a/src/components/skills/SkillSetupModal.tsx +++ b/src/components/skills/SkillSetupModal.tsx @@ -16,6 +16,7 @@ interface SkillSetupModalProps { skillDescription: string; /** Whether this skill has interactive setup hooks. */ hasSetup?: boolean; + skillType?: 'alphahuman' | 'openclaw'; onClose: () => void; } @@ -24,6 +25,7 @@ export default function SkillSetupModal({ skillName, skillDescription, hasSetup = true, + skillType = 'alphahuman', onClose, }: SkillSetupModalProps) { const modalRef = useRef(null); @@ -92,12 +94,23 @@ export default function SkillSetupModal({
-

- {headerTitle} -

+
+

+ {headerTitle} +

+ + {skillType} + +
{skillDescription && (

{skillDescription} From b037627d8475eff496d04517206a868b291efb59 Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Thu, 26 Feb 2026 21:25:53 +0530 Subject: [PATCH 3/9] Feat/daemon setup (#148) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add .mcp.json for MCP server configuration - Introduced `.mcp.json` with server details for managing MCP integrations - Defines `readme` server with HTTP type and URL endpoint configuration * fix: Update skills submodule with Telegram error handling improvements - Fixed setup flow showing false success when TDLib errors occurred - Improved async error handling in setup steps - Added client reset mechanisms for error recovery - Enhanced error messaging for better user experience - Cleaned up excessive debug logging while maintaining error logs Resolves issue where Telegram setup showed success modal despite underlying TDLib errors. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * Add daemon lifecycle management system to frontend UI This implementation integrates the Rust daemon's health monitoring and lifecycle management into the React frontend. Key features include real-time health indicators, automatic start/error recovery, and detailed control panels, enhancing both user experience and operational reliability. * Enhance daemon health monitoring with detailed logging, event tracking, and state management improvements. * Add extensive logging for Tauri socket lifecycle and event listeners This update improves troubleshooting and debugging by adding detailed logs throughout Tauri socket initialization, event listener setup, and error handling processes. * Define global Tauri interface types in TypeScript * Fix daemon service management and health communication issues This comprehensive fix resolves two critical daemon-related issues: 1. **Launchctl "Input/output error" fix**: - Add intelligent state checking before service operations - Only load LaunchAgent if not already loaded - Treat "already running" as success, not failure - Add helper functions for cross-platform service state detection - Implement idempotent service start operations 2. **Daemon health communication fix**: - Add ALPHAHUMAN_DAEMON_INTERNAL environment variable to external service plist - Force external daemon to use file-based communication (daemon_state.json) - Implement file watching bridge in main app to emit Tauri events - Ensure frontend receives proper health updates from external daemon **Key Changes**: - Enhanced `start()` function with state checking across all platforms - Added `is_service_loaded_macos()`, `is_service_enabled_linux()`, `is_task_exists_windows()` - Modified macOS plist to include `ALPHAHUMAN_DAEMON_INTERNAL=false` - Added `watch_daemon_health_file()` function for external daemon communication - Updated daemon mode detection logic for cross-platform consistency - Added comprehensive logging for service management operations **Expected Results**: - No more "Input/output error" when clicking daemon start button - External daemon status shows "connected" instead of "disconnected" - Idempotent service operations (safe to run multiple times) - Single daemon process prevents resource conflicts - Cross-platform daemon service reliability 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * Add `ALPHAHUMAN_DAEMON_INTERNAL` env variable to configuration documentation --------- Co-authored-by: Claude --- CLAUDE.md | 1 + docs/feat/daemon-lifecycle-management.md | 341 ++++++++++++++++++ skills | 2 +- src-tauri/src/alphahuman/daemon/mod.rs | 40 +- src-tauri/src/alphahuman/service/mod.rs | 112 +++++- src-tauri/src/commands/alphahuman.rs | 5 +- src-tauri/src/lib.rs | 121 +++++-- src/components/MiniSidebar.tsx | 93 +++-- .../daemon/DaemonHealthIndicator.tsx | 125 +++++++ src/components/daemon/DaemonHealthPanel.tsx | 283 +++++++++++++++ .../settings/panels/TauriCommandsPanel.tsx | 155 ++++++-- src/hooks/useDaemonHealth.ts | 198 ++++++++++ src/hooks/useDaemonLifecycle.ts | 266 ++++++++++++++ src/providers/SocketProvider.tsx | 53 ++- src/services/api/userApi.ts | 4 +- src/services/daemonHealthService.ts | 207 +++++++++++ src/store/daemonSlice.ts | 214 +++++++++++ src/store/index.ts | 2 + src/types/global.d.ts | 11 + src/utils/tauriSocket.ts | 36 +- 20 files changed, 2157 insertions(+), 112 deletions(-) create mode 100644 docs/feat/daemon-lifecycle-management.md create mode 100644 src/components/daemon/DaemonHealthIndicator.tsx create mode 100644 src/components/daemon/DaemonHealthPanel.tsx create mode 100644 src/hooks/useDaemonHealth.ts create mode 100644 src/hooks/useDaemonLifecycle.ts create mode 100644 src/services/daemonHealthService.ts create mode 100644 src/store/daemonSlice.ts create mode 100644 src/types/global.d.ts diff --git a/CLAUDE.md b/CLAUDE.md index e8703c02e..dba39db4c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -219,6 +219,7 @@ Set in `.env` (Vite exposes `VITE_*` prefixed vars): | `VITE_TELEGRAM_BOT_ID` | Telegram bot numeric ID | | `VITE_SENTRY_DSN` | Sentry DSN for error reporting (optional) | | `VITE_DEBUG` | Debug mode flag | +| `ALPHAHUMAN_DAEMON_INTERNAL` | Force internal daemon mode (default: false, uses external services) | Production defaults are in `src/utils/config.ts`. diff --git a/docs/feat/daemon-lifecycle-management.md b/docs/feat/daemon-lifecycle-management.md new file mode 100644 index 000000000..4365626e4 --- /dev/null +++ b/docs/feat/daemon-lifecycle-management.md @@ -0,0 +1,341 @@ +# Daemon Lifecycle Management System + +**Status**: ✅ Implemented +**Version**: 1.0.0 +**Date**: February 2026 + +## Overview + +The Daemon Lifecycle Management System provides comprehensive frontend integration with the alphahuman Rust daemon, enabling real-time health monitoring, automatic lifecycle management, and user-friendly daemon controls throughout the application. + +## Background + +### Problem Statement + +The alphahuman application runs a sophisticated Rust daemon that manages critical backend services (gateway, channels, heartbeat, scheduler) and emits detailed health information every 5 seconds. However, the frontend previously had: + +- **Poor Visibility**: Daemon controls buried in developer console only +- **No Real-Time Monitoring**: Manual status checks with raw JSON output +- **No Automatic Management**: No startup detection or error recovery +- **Disconnected UX**: Health events from Rust were ignored by frontend +- **Manual Recovery**: Users had to manually restart failed services + +### Solution Overview + +This implementation creates a complete bridge between the Rust daemon's health system and the React frontend, providing: + +- Real-time health monitoring with visual indicators +- Automatic daemon startup and error recovery +- User-friendly health displays in main UI +- Enhanced developer tools with live status +- Coordinated daemon and socket connection management + +## Architecture + +### System Components + +``` +┌─────────────────────────────────────────────────────────────┐ +│ React Frontend │ +├─────────────────────────────────────────────────────────────┤ +│ UI Layer │ +│ ├── DaemonHealthIndicator (Main UI) │ +│ ├── DaemonHealthPanel (Detailed View) │ +│ └── Enhanced TauriCommandsPanel (Dev Tools) │ +├─────────────────────────────────────────────────────────────┤ +│ State Management │ +│ ├── daemonSlice.ts (Redux) │ +│ ├── useDaemonHealth.ts (React Hook) │ +│ └── useDaemonLifecycle.ts (Lifecycle Hook) │ +├─────────────────────────────────────────────────────────────┤ +│ Services │ +│ └── daemonHealthService.ts (Event Processing) │ +├─────────────────────────────────────────────────────────────┤ +│ Integration Layer │ +│ ├── tauriSocket.ts (Event Listening) │ +│ └── SocketProvider.tsx (Coordinated State) │ +└─────────────────────────────────────────────────────────────┘ + │ + Tauri Event Bridge + │ +┌─────────────────────────────────────────────────────────────┐ +│ Rust Daemon │ +├─────────────────────────────────────────────────────────────┤ +│ Health System (src-tauri/src/alphahuman/health/mod.rs) │ +│ ├── Component Health Tracking │ +│ ├── Health Snapshot Generation │ +│ └── Event Emission (every 5s) │ +├─────────────────────────────────────────────────────────────┤ +│ Daemon Supervisor (src-tauri/src/alphahuman/daemon/mod.rs) │ +│ ├── Component Management │ +│ ├── Automatic Restarts │ +│ └── Exponential Backoff │ +├─────────────────────────────────────────────────────────────┤ +│ Components │ +│ ├── Gateway (API Server) │ +│ ├── Channels (Communication) │ +│ ├── Heartbeat (Health Monitor) │ +│ └── Scheduler (Cron Jobs) │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Event Flow + +1. **Health Generation**: Rust daemon generates health snapshots every 5 seconds +2. **Event Emission**: Health data emitted via `alphahuman:health` Tauri event +3. **Frontend Processing**: `daemonHealthService` receives and parses events +4. **State Updates**: Redux state updated with component health information +5. **UI Reactivity**: React components automatically re-render with new status +6. **User Actions**: Manual controls trigger Tauri commands back to Rust + +## Implementation Details + +### Redux State Management + +**File**: `src/store/daemonSlice.ts` + +```typescript +interface DaemonUserState { + status: 'starting' | 'running' | 'error' | 'disconnected'; + healthSnapshot: HealthSnapshot | null; + components: { + gateway?: ComponentHealth; + channels?: ComponentHealth; + heartbeat?: ComponentHealth; + scheduler?: ComponentHealth; + }; + lastHealthUpdate: string | null; + connectionAttempts: number; + autoStartEnabled: boolean; + isRecovering: boolean; + healthTimeoutId: string | null; +} +``` + +**Key Features**: +- Per-user daemon state isolation (following existing patterns) +- Component-level health tracking with error details +- Connection attempt management with exponential backoff +- Auto-start preference persistence +- Timeout handling for health event detection + +### Health Monitoring Service + +**File**: `src/services/daemonHealthService.ts` + +```typescript +export class DaemonHealthService { + private healthTimeoutId: NodeJS.Timeout | null = null; + private readonly HEALTH_TIMEOUT_MS = 30000; // 30 seconds + + async setupHealthListener(): Promise + private parseHealthSnapshot(payload: unknown): HealthSnapshot | null + private updateReduxFromHealth(snapshot: HealthSnapshot): void + startHealthTimeout(): void +} +``` + +**Responsibilities**: +- Listen for `alphahuman:health` Tauri events from Rust +- Parse and validate health snapshot data +- Update Redux state with component health information +- Manage 30-second timeout detection for disconnected daemon +- Handle cleanup and error recovery + +### UI Components + +#### DaemonHealthIndicator + +**File**: `src/components/daemon/DaemonHealthIndicator.tsx` + +**Purpose**: Compact status indicator for main application UI + +**Visual States**: +- 🟢 **Green**: All components running healthy (`status: 'running'`) +- 🟡 **Yellow**: Daemon starting or recovering (`status: 'starting'`) +- 🔴 **Red**: One or more components in error state (`status: 'error'`) +- ⚪ **Gray**: Daemon disconnected or not running (`status: 'disconnected'`) + +**Features**: +- Click to open detailed health panel +- Tooltip showing component health summary +- Responsive sizing (sm/md/lg variants) +- Only visible in Tauri environments + +#### DaemonHealthPanel + +**File**: `src/components/daemon/DaemonHealthPanel.tsx` + +**Purpose**: Detailed health breakdown and manual controls + +**Features**: +- Component health table with status, last update, restart counts +- Manual restart buttons for individual components +- Auto-start toggle with persistence +- Connection retry controls +- Real-time health information display +- User-friendly error messages and troubleshooting hints + +### Lifecycle Management + +**File**: `src/hooks/useDaemonLifecycle.ts` + +**Automatic Behaviors**: + +1. **App Startup**: + - Detect existing daemon status + - Auto-start daemon if enabled and not running + - Setup health monitoring listeners + +2. **Error Recovery**: + - Exponential backoff retry attempts (1s → 2s → 4s → 8s → 30s max) + - Maximum 5 retry attempts before requiring manual intervention + - Clear error states on successful recovery + +3. **Background Handling**: + - Maintain health monitoring when app backgrounded + - Reconnection logic without page refresh + - Coordinate with socket connection management + +4. **Cleanup**: + - Proper event listener cleanup on unmount + - Timeout cancellation to prevent memory leaks + - Graceful shutdown coordination + +### Integration Points + +#### Enhanced Service Management + +**File**: `src/components/settings/panels/TauriCommandsPanel.tsx` + +**Improvements to Existing Developer Console**: + +- **Live Status Display**: Real-time daemon status with PID and uptime +- **Component Health Grid**: Visual status for all daemon components +- **Enhanced Controls**: Smart start/stop buttons with proper loading states +- **Auto-Start Toggle**: User preference for automatic daemon startup +- **Connection Tracking**: Display retry attempts and recovery status +- **Better Error Messages**: User-friendly errors with troubleshooting hints + +**Before vs After**: +```typescript +// Before: Manual status check + run(alphahumanServiceStatus, 'serviceStatus')}> + Status + + +// After: Live status display +

+ +
+
Daemon Status: {status}
+
PID: {pid} | Uptime: {uptime}
+
+
+``` + +#### Socket Provider Integration + +**File**: `src/providers/SocketProvider.tsx` + +**Coordinated State Management**: +- Check daemon health before attempting socket connections +- Display daemon-related errors in socket connection status +- Coordinate daemon startup with socket connection flows +- Provide daemon health context to socket consumers + +#### Main UI Integration + +**File**: `src/components/MiniSidebar.tsx` + +**User-Facing Integration**: +- Daemon health indicator in main navigation (Tauri-only) +- Click to open detailed health modal +- Non-intrusive but easily accessible +- Consistent with existing UI patterns + +## User Experience + +### For End Users + +1. **Visible Status**: Daemon health indicator in main UI shows system status at a glance +2. **Automatic Operation**: Daemon starts automatically and recovers from errors without user intervention +3. **Clear Feedback**: User-friendly messages explain daemon state and provide actionable guidance +4. **Quick Access**: Click health indicator to see detailed component status and manual controls + +### For Developers + +1. **Enhanced Console**: Improved service management in settings with live status updates +2. **Component Monitoring**: Real-time visibility into all daemon components (gateway, channels, etc.) +3. **Debug Information**: Detailed health snapshots, retry attempts, and error history +4. **Manual Override**: Full control over daemon lifecycle with proper state management + +### Error Handling + +1. **Graceful Degradation**: System works properly in non-Tauri environments +2. **Timeout Management**: 30-second timeout detection with automatic recovery attempts +3. **User Guidance**: Clear error messages with troubleshooting suggestions +4. **Recovery Actions**: Manual restart options when automatic recovery fails + +## Technical Benefits + +### Performance + +- **Efficient Updates**: Debounced Redux updates prevent excessive re-renders +- **Memory Management**: Proper cleanup of timeouts and event listeners +- **Background Optimization**: Minimal resource usage when app backgrounded + +### Reliability + +- **Timeout Handling**: Robust detection of daemon disconnection +- **Exponential Backoff**: Smart retry logic prevents resource exhaustion +- **State Consistency**: Coordinated daemon and socket connection states +- **Error Recovery**: Automatic recovery from transient failures + +### Maintainability + +- **TypeScript**: Full type safety throughout the implementation +- **Modular Design**: Clear separation between state, services, and UI components +- **Existing Patterns**: Follows established Redux and component patterns +- **Testing**: Comprehensive error handling and edge case management + +## Configuration + +### Auto-Start Behavior + +Users can control daemon auto-start behavior through: + +1. **UI Toggle**: Available in both health panel and settings console +2. **Persistence**: Preference stored in Redux with persistence +3. **Default**: Auto-start enabled by default for better UX + +### Health Monitoring + +- **Event Frequency**: Rust daemon emits health every 5 seconds +- **Timeout Duration**: 30 seconds without health events = disconnected +- **Retry Logic**: Maximum 5 attempts with exponential backoff +- **Component Tracking**: Gateway, channels, heartbeat, scheduler components + +## Future Enhancements + +### Potential Improvements + +1. **Health History**: Track daemon health over time with charts/graphs +2. **Performance Metrics**: CPU/memory usage from daemon components +3. **Log Integration**: Show daemon logs directly in health panel +4. **Mobile Optimization**: Enhanced mobile-specific daemon management +5. **Notification System**: Push notifications for critical daemon events + +### Extensibility + +The architecture supports easy extension for: +- Additional daemon components +- Custom health check logic +- Third-party integrations +- Advanced monitoring features + +## Conclusion + +The Daemon Lifecycle Management System provides a complete bridge between the sophisticated Rust daemon infrastructure and the React frontend, delivering excellent user experience while maintaining the technical robustness required for a production application. + +The implementation follows established patterns, provides comprehensive error handling, and creates a foundation for future daemon-related features while ensuring the system remains reliable and user-friendly. \ No newline at end of file diff --git a/skills b/skills index 0c6a11b1e..66ad4d7e3 160000 --- a/skills +++ b/skills @@ -1 +1 @@ -Subproject commit 0c6a11b1e05aed1d6c1cb986e81501f13afcf203 +Subproject commit 66ad4d7e3fd00122f24532e474d8b664b62bbd30 diff --git a/src-tauri/src/alphahuman/daemon/mod.rs b/src-tauri/src/alphahuman/daemon/mod.rs index 1766f4824..ba7047f78 100644 --- a/src-tauri/src/alphahuman/daemon/mod.rs +++ b/src-tauri/src/alphahuman/daemon/mod.rs @@ -55,7 +55,9 @@ pub async fn run( let data_dir = config.data_dir.clone(); let cancel_clone = cancel.clone(); handles.push(tokio::spawn(async move { + log::info!("[alphahuman] Starting health event writer task"); spawn_state_writer(app, data_dir, cancel_clone).await; + log::info!("[alphahuman] Health event writer task terminated"); })); } @@ -69,12 +71,13 @@ pub async fn run( initial_backoff, max_backoff ); + log::info!("[alphahuman] health: Events will be emitted every {}s to frontend", STATUS_FLUSH_SECONDS); // Wait for cancellation (Tauri exit) cancel.cancelled().await; crate::alphahuman::health::mark_component_error("daemon", "shutdown requested"); - log::info!("[alphahuman] Daemon supervisor shutting down"); + log::info!("[alphahuman] Daemon supervisor shutting down (health events will stop)"); for handle in &handles { handle.abort(); @@ -195,7 +198,9 @@ pub async fn run_full( Ok(()) } -pub(crate) fn state_file_path(config: &Config) -> PathBuf { + +/// Get the path to the daemon state file shared between internal and external processes. +pub fn state_file_path(config: &Config) -> PathBuf { config .config_path .parent() @@ -322,12 +327,24 @@ async fn spawn_state_writer( let _ = tokio::fs::create_dir_all(parent).await; } + log::info!("[alphahuman] Health state writer starting ({}s intervals)", STATUS_FLUSH_SECONDS); + log::info!("[alphahuman] Health state file: {}", state_path.display()); + let mut interval = tokio::time::interval(Duration::from_secs(STATUS_FLUSH_SECONDS)); + let mut event_count = 0u64; loop { tokio::select! { - _ = interval.tick() => {}, - _ = cancel.cancelled() => break, + _ = interval.tick() => { + event_count += 1; + if event_count % 12 == 1 { // Log every minute (12 * 5s = 60s) + log::info!("[alphahuman] Health monitoring active (event #{})", event_count); + } + }, + _ = cancel.cancelled() => { + log::info!("[alphahuman] Health state writer received shutdown signal"); + break; + } } let mut json = crate::alphahuman::health::snapshot_json(); @@ -336,15 +353,26 @@ async fn spawn_state_writer( "written_at".into(), serde_json::json!(Utc::now().to_rfc3339()), ); + obj.insert( + "event_count".into(), + serde_json::json!(event_count), + ); } // Emit Tauri event for frontend consumption - let _ = app_handle.emit("alphahuman:health", &json); + log::debug!("[alphahuman] Emitting health event #{}: {:?}", event_count, json); + if let Err(e) = app_handle.emit("alphahuman:health", &json) { + log::error!("[alphahuman] Failed to emit health event #{}: {}", event_count, e); + } else { + log::debug!("[alphahuman] Health event #{} emitted successfully", event_count); + } // Also persist to disk let data = serde_json::to_vec_pretty(&json).unwrap_or_else(|_| b"{}".to_vec()); - let _ = tokio::fs::write(&state_path, data).await; + if let Err(e) = tokio::fs::write(&state_path, data).await { + log::debug!("[alphahuman] Failed to write health state to disk: {}", e); + } } } diff --git a/src-tauri/src/alphahuman/service/mod.rs b/src-tauri/src/alphahuman/service/mod.rs index ed97e1690..ddf54470b 100644 --- a/src-tauri/src/alphahuman/service/mod.rs +++ b/src-tauri/src/alphahuman/service/mod.rs @@ -49,20 +49,82 @@ pub fn install(config: &Config) -> Result { pub fn start(config: &Config) -> Result { if cfg!(target_os = "macos") { let plist = macos_service_file()?; - run_checked(Command::new("launchctl").arg("load").arg("-w").arg(&plist))?; - run_checked(Command::new("launchctl").arg("start").arg(SERVICE_LABEL))?; + + // Check if service is already loaded to avoid "Input/output error" + if !is_service_loaded_macos()? { + log::info!("[service] Loading macOS LaunchAgent service"); + run_checked(Command::new("launchctl").arg("load").arg("-w").arg(&plist))?; + } else { + log::info!("[service] LaunchAgent service already loaded, skipping load step"); + } + + // Always try to start - this is safe even if already running + log::info!("[service] Starting macOS LaunchAgent service"); + let start_result = run_checked(Command::new("launchctl").arg("start").arg(SERVICE_LABEL)); + if let Err(e) = start_result { + // Check if it's already running - that's not an error for us + let status_check = status(config)?; + if matches!(status_check.state, ServiceState::Running) { + log::info!("[service] Service was already running - operation successful"); + } else { + return Err(e); + } + } return status(config); } if cfg!(target_os = "linux") { + // Check if service is enabled before trying to start + if !is_service_enabled_linux()? { + log::info!("[service] Enabling systemd service"); + let _ = run_checked(Command::new("systemctl").args(["--user", "enable", "alphahuman.service"])); + } else { + log::info!("[service] Systemd service already enabled"); + } + run_checked(Command::new("systemctl").args(["--user", "daemon-reload"]))?; - run_checked(Command::new("systemctl").args(["--user", "start", "alphahuman.service"]))?; + + // Try to start - systemctl start is idempotent + log::info!("[service] Starting systemd service"); + let start_result = run_checked(Command::new("systemctl").args(["--user", "start", "alphahuman.service"])); + if let Err(e) = start_result { + // Check if it's already active - that's success for us + let status_check = status(config)?; + if matches!(status_check.state, ServiceState::Running) { + log::info!("[service] Service was already running - operation successful"); + } else { + return Err(e); + } + } return status(config); } if cfg!(target_os = "windows") { - let _ = config; - run_checked(Command::new("schtasks").args(["/Run", "/TN", windows_task_name()]))?; + let task_name = windows_task_name(); + + // Check if task exists before trying to run + if !is_task_exists_windows(task_name)? { + log::warn!("[service] Windows scheduled task does not exist, please install first"); + return Ok(ServiceStatus { + state: ServiceState::NotInstalled, + unit_path: None, + label: task_name.to_string(), + details: Some("Task not installed".to_string()), + }); + } + + // Try to run task - this may fail if already running, which is OK + log::info!("[service] Starting Windows scheduled task"); + let run_result = run_checked(Command::new("schtasks").args(["/Run", "/TN", task_name])); + if let Err(e) = run_result { + // Check if it's already running - that's success for us + let status_check = status(config)?; + if matches!(status_check.state, ServiceState::Running) { + log::info!("[service] Task was already running - operation successful"); + } else { + return Err(e); + } + } return status(config); } @@ -263,6 +325,11 @@ fn install_macos(config: &Config) -> Result<()> { {stdout} StandardErrorPath {stderr} + EnvironmentVariables + + ALPHAHUMAN_DAEMON_INTERNAL + false + "#, @@ -391,6 +458,41 @@ fn run_capture(cmd: &mut Command) -> Result { Ok(String::from_utf8_lossy(&output.stdout).to_string()) } +/// Check if the macOS LaunchAgent service is loaded (regardless of running state) +fn is_service_loaded_macos() -> Result { + let out = run_capture(Command::new("launchctl").arg("list"))?; + Ok(out.lines().any(|line| { + line.contains(SERVICE_LABEL) || line.contains(LEGACY_SERVICE_LABEL) + })) +} + +/// Check if the Linux systemd service is enabled +fn is_service_enabled_linux() -> Result { + let result = Command::new("systemctl") + .args(["--user", "is-enabled", "alphahuman.service"]) + .output(); + + match result { + Ok(output) => { + let status_str = String::from_utf8_lossy(&output.stdout).trim().to_string(); + Ok(status_str == "enabled") + } + Err(_) => Ok(false), // Service not found or other error means not enabled + } +} + +/// Check if the Windows scheduled task exists +fn is_task_exists_windows(task_name: &str) -> Result { + let result = Command::new("schtasks") + .args(["/Query", "/TN", task_name]) + .output(); + + match result { + Ok(output) => Ok(output.status.success()), + Err(_) => Ok(false), // Command failed means task doesn't exist + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/commands/alphahuman.rs b/src-tauri/src/commands/alphahuman.rs index 8a696015c..7d28e22ab 100644 --- a/src-tauri/src/commands/alphahuman.rs +++ b/src-tauri/src/commands/alphahuman.rs @@ -587,7 +587,10 @@ pub async fn alphahuman_service_start() -> Result Result<(), Box> { Ok(()) } +/// Watch daemon health file and bridge changes to frontend Tauri events +async fn watch_daemon_health_file(app_handle: AppHandle, data_dir: PathBuf) { + let state_file = data_dir.join("daemon_state.json"); + let mut interval = interval(Duration::from_secs(2)); + let mut last_modified: Option = None; + + log::info!("[alphahuman] Watching daemon health file: {}", state_file.display()); + + loop { + interval.tick().await; + + // Check if file exists and was modified + if let Ok(metadata) = fs::metadata(&state_file).await { + if let Ok(modified) = metadata.modified() { + if last_modified.map_or(true, |last| modified > last) { + last_modified = Some(modified); + + // Read and parse health data + if let Ok(content) = fs::read_to_string(&state_file).await { + if let Ok(json_value) = serde_json::from_str::(&content) { + log::debug!("[alphahuman] Broadcasting health event from file: {:?}", json_value); + + // Emit Tauri event to frontend (same as internal daemon) + if let Err(e) = app_handle.emit("alphahuman:health", &json_value) { + log::error!("[alphahuman] Failed to emit health event from file: {}", e); + } else { + log::debug!("[alphahuman] Health event emitted successfully from file"); + } + } else { + log::debug!("[alphahuman] Failed to parse health file as JSON: {}", state_file.display()); + } + } else { + log::debug!("[alphahuman] Failed to read health file: {}", state_file.display()); + } + } + } + } else { + // File doesn't exist yet - external daemon may not be writing yet + log::debug!("[alphahuman] Health file not found yet: {}", state_file.display()); + } + } +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { if let Err(err) = rustls::crypto::ring::default_provider().install_default() { @@ -504,34 +549,23 @@ pub fn run() { }; app.manage(daemon_handle); - if cfg!(target_os = "macos") - && !daemon_mode - && !daemon_foreground_requested() - { - tauri::async_runtime::spawn(async move { - match alphahuman::config::Config::load_or_init().await { - Ok(config) => { - if let Err(e) = alphahuman::service::install(&config) { - log::warn!( - "[alphahuman] LaunchAgent install failed: {e}" - ); - } - if let Err(e) = alphahuman::service::start(&config) { - log::warn!( - "[alphahuman] LaunchAgent start failed: {e}" - ); - } - } - Err(e) => { - log::warn!( - "[alphahuman] Failed to load config for LaunchAgent: {e}" - ); - } - } - }); - } else { + // Determine daemon mode: internal supervisor vs external platform service + let use_internal_daemon = daemon_mode + || daemon_foreground_requested() + || cfg!(debug_assertions) // Always use internal supervisor in debug builds + || std::env::var("ALPHAHUMAN_DAEMON_INTERNAL").unwrap_or("false".to_string()) == "true"; // Cross-platform override via env var + + if use_internal_daemon { + // Run internal daemon supervisor with health event emission + // This path is taken when: + // - Daemon mode enabled, OR + // - Foreground daemon requested, OR + // - Debug build (for easier development), OR + // - ALPHAHUMAN_DAEMON_INTERNAL=true env var (any platform) + log::info!("[alphahuman] Using internal daemon supervisor (ALPHAHUMAN_DAEMON_INTERNAL=true or debug build)"); let app_handle_for_daemon = app.handle().clone(); tauri::async_runtime::spawn(async move { + log::info!("[alphahuman] Starting daemon supervisor with health monitoring"); if let Err(e) = alphahuman::daemon::run( daemon_config, app_handle_for_daemon, @@ -542,6 +576,39 @@ pub fn run() { log::error!("[alphahuman] Daemon supervisor error: {e}"); } }); + } else { + // Start external platform-specific service for background daemon + // This path is taken on all platforms when ALPHAHUMAN_DAEMON_INTERNAL=false/unset + // and not in daemon mode, foreground mode, or debug build + log::info!("[alphahuman] Using external daemon service (ALPHAHUMAN_DAEMON_INTERNAL=false/unset)"); + + // Setup file watching to bridge external daemon health events to frontend + let app_handle_for_watcher = app.handle().clone(); + let data_dir_clone = data_dir.clone(); + tauri::async_runtime::spawn(async move { + watch_daemon_health_file(app_handle_for_watcher, data_dir_clone).await; + }); + + // Start the external platform service + tauri::async_runtime::spawn(async move { + match alphahuman::config::Config::load_or_init().await { + Ok(config) => { + match alphahuman::service::install(&config) { + Ok(status) => log::info!("[alphahuman] External daemon service installed: {:?}", status), + Err(e) => log::error!("[alphahuman] Failed to install external daemon service: {e}"), + } + match alphahuman::service::start(&config) { + Ok(status) => log::info!("[alphahuman] External daemon service started: {:?}", status), + Err(e) => log::error!("[alphahuman] Failed to start external daemon service: {e}"), + } + } + Err(e) => { + log::error!( + "[alphahuman] Failed to load config for external service: {e}" + ); + } + } + }); } } diff --git a/src/components/MiniSidebar.tsx b/src/components/MiniSidebar.tsx index 94399d1f8..45c775bb7 100644 --- a/src/components/MiniSidebar.tsx +++ b/src/components/MiniSidebar.tsx @@ -1,6 +1,10 @@ import { useLocation, useNavigate } from 'react-router-dom'; +import { useState } from 'react'; +import DaemonHealthIndicator from './daemon/DaemonHealthIndicator'; +import DaemonHealthPanel from './daemon/DaemonHealthPanel'; import { useAppSelector } from '../store/hooks'; +import { isTauri } from '../utils/tauriCommands'; const navItems = [ { @@ -90,6 +94,7 @@ const MiniSidebar = () => { const location = useLocation(); const navigate = useNavigate(); const token = useAppSelector(state => state.auth.token); + const [showDaemonPanel, setShowDaemonPanel] = useState(false); // Unread count for Conversations: threads with lastMessageAt > lastViewedAt (must be before early return) const conversationsUnreadCount = useAppSelector(state => { @@ -115,37 +120,69 @@ const MiniSidebar = () => { }; return ( -
- {navItems.map(item => { - const active = isActive(item.path); - const showUnreadBadge = item.id === 'conversations' && conversationsUnreadCount > 0; - return ( -
- - {showUnreadBadge && ( - - {conversationsUnreadCount > 99 ? '99+' : conversationsUnreadCount} - - )} - {/* Tooltip - appears to the right */} + <> +
+ {/* Navigation Items */} +
+ {navItems.map(item => { + const active = isActive(item.path); + const showUnreadBadge = item.id === 'conversations' && conversationsUnreadCount > 0; + return ( +
+ + {showUnreadBadge && ( + + {conversationsUnreadCount > 99 ? '99+' : conversationsUnreadCount} + + )} + {/* Tooltip - appears to the right */} +
+ {item.label} +
+
+ ); + })} +
+ + {/* Daemon Health Indicator - Only show in Tauri mode */} + {isTauri() && ( +
+
+ setShowDaemonPanel(true)} + /> +
+ {/* Tooltip */}
- {item.label} + Daemon Status
- ); - })} -
+ )} +
+ + {/* Daemon Health Panel Modal */} + {showDaemonPanel && ( +
+
+ setShowDaemonPanel(false)} + /> +
+
+ )} + ); }; diff --git a/src/components/daemon/DaemonHealthIndicator.tsx b/src/components/daemon/DaemonHealthIndicator.tsx new file mode 100644 index 000000000..162b08806 --- /dev/null +++ b/src/components/daemon/DaemonHealthIndicator.tsx @@ -0,0 +1,125 @@ +/** + * Daemon Health Indicator + * + * Compact status indicator showing daemon health with a colored dot and optional label. + * Can be clicked to show detailed health information. + */ +import type React from 'react'; + +import { useDaemonHealth, formatRelativeTime } from '../../hooks/useDaemonHealth'; +import type { DaemonStatus } from '../../store/daemonSlice'; + +interface Props { + userId?: string; + size?: 'sm' | 'md' | 'lg'; + showLabel?: boolean; + onClick?: () => void; + className?: string; +} + +const DaemonHealthIndicator: React.FC = ({ + userId, + size = 'md', + showLabel = false, + onClick, + className = '', +}) => { + const daemonHealth = useDaemonHealth(userId); + + // Size configurations + const sizeConfig = { + sm: { + dot: 'w-2 h-2', + text: 'text-xs', + container: 'gap-1.5', + }, + md: { + dot: 'w-3 h-3', + text: 'text-sm', + container: 'gap-2', + }, + lg: { + dot: 'w-4 h-4', + text: 'text-base', + container: 'gap-2.5', + }, + }; + + const config = sizeConfig[size]; + + // Status color mapping + const getStatusColor = (status: DaemonStatus): string => { + switch (status) { + case 'running': + return 'bg-green-500'; + case 'starting': + return 'bg-yellow-500'; + case 'error': + return 'bg-red-500'; + case 'disconnected': + default: + return 'bg-gray-500'; + } + }; + + // Status text mapping + const getStatusText = (status: DaemonStatus): string => { + switch (status) { + case 'running': + return 'Running'; + case 'starting': + return 'Starting'; + case 'error': + return 'Error'; + case 'disconnected': + default: + return 'Disconnected'; + } + }; + + // Tooltip content + const getTooltipContent = (): string => { + const { status, componentCount, healthyComponentCount, errorComponentCount, lastUpdate } = daemonHealth; + + let tooltip = `Status: ${getStatusText(status)}`; + + if (componentCount > 0) { + tooltip += `\nComponents: ${healthyComponentCount}/${componentCount} healthy`; + if (errorComponentCount > 0) { + tooltip += ` (${errorComponentCount} errors)`; + } + } + + if (lastUpdate) { + tooltip += `\nLast update: ${formatRelativeTime(lastUpdate)}`; + } + + return tooltip; + }; + + const statusColor = getStatusColor(daemonHealth.status); + const statusText = getStatusText(daemonHealth.status); + + const containerClasses = ` + flex items-center ${config.container} + ${onClick ? 'cursor-pointer hover:opacity-80 transition-opacity' : ''} + ${className} + `.trim(); + + return ( +
+
+ {showLabel && ( + + {statusText} + + )} +
+ ); +}; + +export default DaemonHealthIndicator; \ No newline at end of file diff --git a/src/components/daemon/DaemonHealthPanel.tsx b/src/components/daemon/DaemonHealthPanel.tsx new file mode 100644 index 000000000..df77b000f --- /dev/null +++ b/src/components/daemon/DaemonHealthPanel.tsx @@ -0,0 +1,283 @@ +/** + * Daemon Health Panel + * + * Detailed health breakdown component showing daemon status, component health, + * and providing manual control buttons for daemon lifecycle management. + */ +import { useState } from 'react'; +import { + CheckCircleIcon, + XCircleIcon, + ClockIcon, + ArrowPathIcon, + PlayIcon, + StopIcon, + XMarkIcon, +} from '@heroicons/react/24/outline'; + +import { useDaemonHealth, formatRelativeTime } from '../../hooks/useDaemonHealth'; +import type { DaemonStatus, ComponentHealth } from '../../store/daemonSlice'; + +interface Props { + userId?: string; + onClose?: () => void; + className?: string; +} + +const DaemonHealthPanel = ({ userId, onClose, className = '' }: Props) => { + const daemonHealth = useDaemonHealth(userId); + const [operationLoading, setOperationLoading] = useState(null); + + // Handle daemon operations with loading states + const handleOperation = async (operation: () => Promise, operationName: string) => { + setOperationLoading(operationName); + try { + await operation(); + } catch (error) { + console.error(`[DaemonHealthPanel] ${operationName} failed:`, error); + } finally { + setOperationLoading(null); + } + }; + + // Status styling + const getStatusStyling = (status: DaemonStatus) => { + switch (status) { + case 'running': + return { + bg: 'bg-green-900/20 border-green-500/30', + text: 'text-green-400', + icon: CheckCircleIcon, + }; + case 'starting': + return { + bg: 'bg-yellow-900/20 border-yellow-500/30', + text: 'text-yellow-400', + icon: ClockIcon, + }; + case 'error': + return { + bg: 'bg-red-900/20 border-red-500/30', + text: 'text-red-400', + icon: XCircleIcon, + }; + case 'disconnected': + default: + return { + bg: 'bg-gray-900/20 border-gray-500/30', + text: 'text-gray-400', + icon: XCircleIcon, + }; + } + }; + + // Component status styling + const getComponentStyling = (component: ComponentHealth) => { + switch (component.status) { + case 'ok': + return { + bg: 'bg-green-500', + text: 'text-green-400', + icon: CheckCircleIcon, + }; + case 'error': + return { + bg: 'bg-red-500', + text: 'text-red-400', + icon: XCircleIcon, + }; + case 'starting': + return { + bg: 'bg-yellow-500', + text: 'text-yellow-400', + icon: ClockIcon, + }; + } + }; + + const statusStyling = getStatusStyling(daemonHealth.status); + const StatusIcon = statusStyling.icon; + + return ( +
+ {/* Header */} +
+

Daemon Health

+ {onClose && ( + + )} +
+ + {/* Overall Status */} +
+
+
+ +
+
+ Status: {daemonHealth.status.charAt(0).toUpperCase() + daemonHealth.status.slice(1)} +
+ {daemonHealth.healthSnapshot && ( +
+ PID: {daemonHealth.healthSnapshot.pid} • Uptime: {daemonHealth.uptimeText} +
+ )} + {daemonHealth.lastUpdate && ( +
+ Last update: {formatRelativeTime(daemonHealth.lastUpdate)} +
+ )} +
+
+ + {/* Recovery indicator */} + {daemonHealth.isRecovering && ( +
+ + Recovering... +
+ )} +
+
+ + {/* Component Health */} + {daemonHealth.componentCount > 0 && ( +
+

Components ({daemonHealth.healthyComponentCount}/{daemonHealth.componentCount} healthy)

+
+ {Object.entries(daemonHealth.components).map(([name, component]) => { + const componentStyling = getComponentStyling(component); + const ComponentIcon = componentStyling.icon; + + return ( +
+
+
+
+ + {name} +
+
+ Updated: {formatRelativeTime(component.updated_at)} +
+ {component.restart_count > 0 && ( +
+ Restarts: {component.restart_count} +
+ )} + {component.last_error && component.status === 'error' && ( +
+ Error: {component.last_error} +
+ )} +
+
+ ); + })} +
+
+ )} + + {/* Auto-start Toggle */} +
+
+
Auto-start Daemon
+
Automatically start daemon on app launch
+
+ +
+ + {/* Control Actions */} +
+ + + + + + + +
+ + {/* Connection Info */} + {daemonHealth.connectionAttempts > 0 && ( +
+
+ Connection attempts: {daemonHealth.connectionAttempts} +
+
+ )} + + {/* Debug Info (development only) */} + {process.env.NODE_ENV === 'development' && daemonHealth.healthSnapshot && ( +
+ Debug Info +
+            {JSON.stringify(daemonHealth.healthSnapshot, null, 2)}
+          
+
+ )} +
+ ); +}; + +export default DaemonHealthPanel; \ No newline at end of file diff --git a/src/components/settings/panels/TauriCommandsPanel.tsx b/src/components/settings/panels/TauriCommandsPanel.tsx index e57b11691..9719c495f 100644 --- a/src/components/settings/panels/TauriCommandsPanel.tsx +++ b/src/components/settings/panels/TauriCommandsPanel.tsx @@ -14,6 +14,8 @@ import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; import SectionCard from './components/SectionCard'; import InputGroup, { Field, CheckboxField } from './components/InputGroup'; import ActionPanel, { PrimaryButton } from './components/ActionPanel'; +import DaemonHealthIndicator from '../../daemon/DaemonHealthIndicator'; +import { useDaemonHealth, formatRelativeTime } from '../../../hooks/useDaemonHealth'; import { alphahumanAgentChat, alphahumanDecryptSecret, @@ -32,9 +34,7 @@ import { alphahumanUpdateRuntimeSettings, alphahumanUpdateTunnelSettings, alphahumanServiceInstall, - alphahumanServiceStart, alphahumanServiceStatus, - alphahumanServiceStop, alphahumanServiceUninstall, alphahumanEncryptSecret, isTauri, @@ -50,6 +50,7 @@ const formatJson = (value: unknown) => JSON.stringify(value, null, 2); const TauriCommandsPanel = () => { const { navigateBack } = useSettingsNavigation(); + const daemonHealth = useDaemonHealth(); // View mode removed - always show all sections const [expandedSections] = useState>( @@ -527,44 +528,120 @@ const TauriCommandsPanel = () => {
)} - +
- - run(alphahumanServiceStatus, 'serviceStatus')} - loading={operationLoading === 'serviceStatus'} - > - Status - - run(alphahumanServiceInstall, 'serviceInstall')} - loading={operationLoading === 'serviceInstall'} - variant="outline" - > - Install - - run(alphahumanServiceStart, 'serviceStart')} - loading={operationLoading === 'serviceStart'} - variant="outline" - > - Start - - run(alphahumanServiceStop, 'serviceStop')} - loading={operationLoading === 'serviceStop'} - variant="outline" - > - Stop - - run(alphahumanServiceUninstall, 'serviceUninstall')} - loading={operationLoading === 'serviceUninstall'} - variant="outline" - > - Uninstall - - +
+ {/* Live Status Display */} +
+
+ +
+
Daemon Status: {daemonHealth.status}
+
+ Last update: {daemonHealth.lastUpdate ? formatRelativeTime(daemonHealth.lastUpdate) : 'Never'} +
+ {daemonHealth.healthSnapshot && ( +
+ PID: {daemonHealth.healthSnapshot.pid} • Uptime: {daemonHealth.uptimeText} +
+ )} +
+
+ {daemonHealth.status === 'error' && ( + daemonHealth.restartDaemon()} + variant="outline" + loading={daemonHealth.isRecovering} + > + Restart + + )} +
+ + {/* Component Health */} + {daemonHealth.componentCount > 0 && ( +
+ {Object.entries(daemonHealth.components).map(([name, health]) => ( +
+
+ {name} + {health.restart_count > 0 && ( + ({health.restart_count}) + )} +
+ ))} +
+ )} + + {/* Service Controls */} + + daemonHealth.startDaemon()} + loading={operationLoading === 'serviceStart'} + disabled={daemonHealth.status === 'running'} + > + Start + + daemonHealth.stopDaemon()} + loading={operationLoading === 'serviceStop'} + disabled={daemonHealth.status === 'disconnected'} + variant="outline" + > + Stop + + run(alphahumanServiceStatus, 'serviceStatus')} + loading={operationLoading === 'serviceStatus'} + variant="outline" + > + Status + + run(alphahumanServiceInstall, 'serviceInstall')} + loading={operationLoading === 'serviceInstall'} + variant="outline" + > + Install + + run(alphahumanServiceUninstall, 'serviceUninstall')} + loading={operationLoading === 'serviceUninstall'} + variant="outline" + > + Uninstall + + + + {/* Auto-start Toggle */} +
+
+
Auto-start Daemon
+
Automatically start daemon on app launch
+
+ +
+ + {/* Connection Info */} + {daemonHealth.connectionAttempts > 0 && ( +
+
+ Connection attempts: {daemonHealth.connectionAttempts} +
+
+ )} +
diff --git a/src/hooks/useDaemonHealth.ts b/src/hooks/useDaemonHealth.ts new file mode 100644 index 000000000..e6e95a492 --- /dev/null +++ b/src/hooks/useDaemonHealth.ts @@ -0,0 +1,198 @@ +/** + * Daemon Health Hook + * + * React hook for accessing daemon health state and actions. + * Provides convenient access to daemon status, components, and control functions. + */ +import { useCallback } from 'react'; + +import { useAppDispatch, useAppSelector } from '../store/hooks'; +import { + resetConnectionAttempts, + selectDaemonComponents, + selectDaemonConnectionAttempts, + selectDaemonHealthSnapshot, + selectDaemonLastHealthUpdate, + selectDaemonStatus, + selectIsDaemonAutoStartEnabled, + selectIsDaemonRecovering, + setAutoStartEnabled, + setIsRecovering, +} from '../store/daemonSlice'; +import { + alphahumanServiceStart, + alphahumanServiceStop, + alphahumanServiceStatus, + type CommandResponse, + type ServiceStatus, +} from '../utils/tauriCommands'; + +export const useDaemonHealth = (userId?: string) => { + const dispatch = useAppDispatch(); + + // Selectors + const status = useAppSelector(state => selectDaemonStatus(state, userId)); + const components = useAppSelector(state => selectDaemonComponents(state, userId)); + const healthSnapshot = useAppSelector(state => selectDaemonHealthSnapshot(state, userId)); + const lastUpdate = useAppSelector(state => selectDaemonLastHealthUpdate(state, userId)); + const isAutoStartEnabled = useAppSelector(state => selectIsDaemonAutoStartEnabled(state, userId)); + const connectionAttempts = useAppSelector(state => selectDaemonConnectionAttempts(state, userId)); + const isRecovering = useAppSelector(state => selectIsDaemonRecovering(state, userId)); + + // Action creators + const startDaemon = useCallback(async (): Promise | null> => { + try { + const result = await alphahumanServiceStart(); + // Check if the service status indicates success + if (result.result && result.result.state === 'Running') { + dispatch(resetConnectionAttempts({ userId: userId || '__pending__' })); + } + return result; + } catch (error) { + console.error('[useDaemonHealth] Failed to start daemon:', error); + return null; + } + }, [dispatch, userId]); + + const stopDaemon = useCallback(async (): Promise | null> => { + try { + return await alphahumanServiceStop(); + } catch (error) { + console.error('[useDaemonHealth] Failed to stop daemon:', error); + return null; + } + }, []); + + const restartDaemon = useCallback(async (): Promise => { + const uid = userId || '__pending__'; + try { + dispatch(setIsRecovering({ userId: uid, isRecovering: true })); + + // Stop first + const stopResult = await alphahumanServiceStop(); + if (!stopResult?.result || stopResult.result.state !== 'Stopped') { + console.warn('[useDaemonHealth] Stop daemon failed, but continuing with start'); + } + + // Wait a moment for clean shutdown + await new Promise(resolve => setTimeout(resolve, 2000)); + + // Start again + const startResult = await alphahumanServiceStart(); + const success = startResult?.result && startResult.result.state === 'Running'; + + if (success) { + dispatch(resetConnectionAttempts({ userId: uid })); + } + + dispatch(setIsRecovering({ userId: uid, isRecovering: false })); + return success; + } catch (error) { + console.error('[useDaemonHealth] Failed to restart daemon:', error); + dispatch(setIsRecovering({ userId: uid, isRecovering: false })); + return false; + } + }, [dispatch, userId]); + + const checkDaemonStatus = useCallback(async (): Promise | null> => { + try { + return await alphahumanServiceStatus(); + } catch (error) { + console.error('[useDaemonHealth] Failed to check daemon status:', error); + return null; + } + }, []); + + const setAutoStart = useCallback( + (enabled: boolean) => { + dispatch(setAutoStartEnabled({ userId: userId || '__pending__', enabled })); + }, + [dispatch, userId] + ); + + // Derived state + const isHealthy = status === 'running'; + const hasErrors = status === 'error'; + const isConnected = status !== 'disconnected'; + const isStarting = status === 'starting'; + + const componentCount = Object.keys(components).length; + const healthyComponentCount = Object.values(components).filter(c => c.status === 'ok').length; + const errorComponentCount = Object.values(components).filter(c => c.status === 'error').length; + + // Get uptime in human readable format + const uptimeText = healthSnapshot + ? formatUptime(healthSnapshot.uptime_seconds) + : 'Unknown'; + + return { + // State + status, + components, + healthSnapshot, + lastUpdate, + isAutoStartEnabled, + connectionAttempts, + isRecovering, + + // Derived state + isHealthy, + hasErrors, + isConnected, + isStarting, + componentCount, + healthyComponentCount, + errorComponentCount, + uptimeText, + + // Actions + startDaemon, + stopDaemon, + restartDaemon, + checkDaemonStatus, + setAutoStart, + }; +}; + +/** + * Format uptime seconds into human-readable string + */ +function formatUptime(seconds: number): string { + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + + if (days > 0) { + return `${days}d ${hours}h ${minutes}m`; + } else if (hours > 0) { + return `${hours}h ${minutes}m ${secs}s`; + } else if (minutes > 0) { + return `${minutes}m ${secs}s`; + } else { + return `${secs}s`; + } +} + +/** + * Format relative time from ISO string + */ +export function formatRelativeTime(isoString: string): string { + const date = new Date(isoString); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffSeconds = Math.floor(diffMs / 1000); + + if (diffSeconds < 60) { + return `${diffSeconds}s ago`; + } else if (diffSeconds < 3600) { + const minutes = Math.floor(diffSeconds / 60); + return `${minutes}m ago`; + } else if (diffSeconds < 86400) { + const hours = Math.floor(diffSeconds / 3600); + return `${hours}h ago`; + } else { + const days = Math.floor(diffSeconds / 86400); + return `${days}d ago`; + } +} \ No newline at end of file diff --git a/src/hooks/useDaemonLifecycle.ts b/src/hooks/useDaemonLifecycle.ts new file mode 100644 index 000000000..454ce31d1 --- /dev/null +++ b/src/hooks/useDaemonLifecycle.ts @@ -0,0 +1,266 @@ +/** + * Daemon Lifecycle Management Hook + * + * Handles automatic daemon lifecycle management including: + * - Auto-start on app launch (if enabled) + * - Background/foreground event handling + * - Exponential backoff for restart attempts + * - Error recovery logic + */ +import { useEffect, useRef, useCallback } from 'react'; + +import { useAppDispatch, useAppSelector } from '../store/hooks'; +import { + incrementConnectionAttempts, + resetConnectionAttempts, + selectDaemonStatus, + selectIsDaemonAutoStartEnabled, + selectDaemonConnectionAttempts, + selectIsDaemonRecovering, + setIsRecovering, +} from '../store/daemonSlice'; +import { useDaemonHealth } from './useDaemonHealth'; +import { isTauri } from '../utils/tauriCommands'; + +// Configuration constants +const MAX_RECONNECTION_ATTEMPTS = 5; +const BASE_RETRY_DELAY_MS = 1000; // 1 second +const MAX_RETRY_DELAY_MS = 30000; // 30 seconds +const AUTO_START_DELAY_MS = 3000; // 3 seconds after app start + +export const useDaemonLifecycle = (userId?: string) => { + const dispatch = useAppDispatch(); + const daemonHealth = useDaemonHealth(userId); + + // Selectors + const status = useAppSelector(state => selectDaemonStatus(state, userId)); + const isAutoStartEnabled = useAppSelector(state => selectIsDaemonAutoStartEnabled(state, userId)); + const connectionAttempts = useAppSelector(state => selectDaemonConnectionAttempts(state, userId)); + const isRecovering = useAppSelector(state => selectIsDaemonRecovering(state, userId)); + + // Refs for cleanup + const autoStartTimeoutRef = useRef | null>(null); + const retryTimeoutRef = useRef | null>(null); + const isMountedRef = useRef(true); + + // Calculate exponential backoff delay + const calculateRetryDelay = useCallback((attempt: number): number => { + const exponentialDelay = BASE_RETRY_DELAY_MS * Math.pow(2, attempt - 1); + return Math.min(exponentialDelay, MAX_RETRY_DELAY_MS); + }, []); + + // Auto-start daemon if enabled and conditions are met + const attemptAutoStart = useCallback(async () => { + if (!isTauri() || !isAutoStartEnabled || !isMountedRef.current) { + return; + } + + // Only auto-start if daemon is disconnected and not already recovering + if (status === 'disconnected' && !isRecovering && connectionAttempts === 0) { + console.log('[DaemonLifecycle] Attempting auto-start of daemon'); + + try { + dispatch(setIsRecovering({ userId: userId || '__pending__', isRecovering: true })); + const result = await daemonHealth.startDaemon(); + + if (result?.result && result.result.state === 'Running') { + console.log('[DaemonLifecycle] Auto-start successful'); + dispatch(resetConnectionAttempts({ userId: userId || '__pending__' })); + } else { + console.warn('[DaemonLifecycle] Auto-start failed:', result); + dispatch(incrementConnectionAttempts({ userId: userId || '__pending__' })); + } + } catch (error) { + console.error('[DaemonLifecycle] Auto-start error:', error); + dispatch(incrementConnectionAttempts({ userId: userId || '__pending__' })); + } finally { + dispatch(setIsRecovering({ userId: userId || '__pending__', isRecovering: false })); + } + } + }, [ + isAutoStartEnabled, + status, + isRecovering, + connectionAttempts, + userId, + dispatch, + daemonHealth, + ]); + + // Retry connection with exponential backoff + const scheduleRetry = useCallback(() => { + if (!isTauri() || !isMountedRef.current || isRecovering) { + return; + } + + // Don't retry if we've exceeded max attempts + if (connectionAttempts >= MAX_RECONNECTION_ATTEMPTS) { + console.warn('[DaemonLifecycle] Max reconnection attempts reached'); + return; + } + + // Don't retry if daemon is already running or starting + if (status === 'running' || status === 'starting') { + return; + } + + const retryDelay = calculateRetryDelay(connectionAttempts + 1); + console.log(`[DaemonLifecycle] Scheduling retry attempt ${connectionAttempts + 1} in ${retryDelay}ms`); + + // Clear existing timeout + if (retryTimeoutRef.current) { + clearTimeout(retryTimeoutRef.current); + } + + retryTimeoutRef.current = setTimeout(async () => { + if (!isMountedRef.current) return; + + try { + dispatch(setIsRecovering({ userId: userId || '__pending__', isRecovering: true })); + dispatch(incrementConnectionAttempts({ userId: userId || '__pending__' })); + + const result = await daemonHealth.startDaemon(); + + if (result?.result && result.result.state === 'Running') { + console.log('[DaemonLifecycle] Retry successful'); + dispatch(resetConnectionAttempts({ userId: userId || '__pending__' })); + } else { + console.warn('[DaemonLifecycle] Retry failed:', result); + // Will trigger another retry via useEffect + } + } catch (error) { + console.error('[DaemonLifecycle] Retry error:', error); + // Will trigger another retry via useEffect + } finally { + dispatch(setIsRecovering({ userId: userId || '__pending__', isRecovering: false })); + } + }, retryDelay); + }, [ + connectionAttempts, + status, + isRecovering, + calculateRetryDelay, + userId, + dispatch, + daemonHealth, + ]); + + // Handle visibility change (background/foreground) + const handleVisibilityChange = useCallback(() => { + if (!isTauri() || !isMountedRef.current) return; + + if (document.visibilityState === 'visible') { + console.log('[DaemonLifecycle] App became visible - checking daemon status'); + + // Check if daemon needs to be started when app comes back to foreground + if (isAutoStartEnabled && status === 'disconnected' && !isRecovering) { + // Small delay to allow app to fully activate + setTimeout(() => { + if (isMountedRef.current) { + attemptAutoStart(); + } + }, 1000); + } + } + }, [isAutoStartEnabled, status, isRecovering, attemptAutoStart]); + + // Main lifecycle effect + useEffect(() => { + if (!isTauri()) return; + + console.log('[DaemonLifecycle] Setting up daemon lifecycle management'); + + // Setup auto-start with delay on mount + if (isAutoStartEnabled) { + autoStartTimeoutRef.current = setTimeout(() => { + if (isMountedRef.current) { + attemptAutoStart(); + } + }, AUTO_START_DELAY_MS); + } + + // Setup visibility change listener + document.addEventListener('visibilitychange', handleVisibilityChange); + + return () => { + console.log('[DaemonLifecycle] Cleaning up daemon lifecycle management'); + isMountedRef.current = false; + + // Clear timeouts + if (autoStartTimeoutRef.current) { + clearTimeout(autoStartTimeoutRef.current); + autoStartTimeoutRef.current = null; + } + if (retryTimeoutRef.current) { + clearTimeout(retryTimeoutRef.current); + retryTimeoutRef.current = null; + } + + // Remove event listeners + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, [isAutoStartEnabled, attemptAutoStart, handleVisibilityChange]); + + // Retry effect - triggers when daemon goes into error state or connection fails + useEffect(() => { + if (!isTauri() || !isMountedRef.current) return; + + // Schedule retry if daemon is in error state or disconnected with failed attempts + if ( + (status === 'error' || status === 'disconnected') && + connectionAttempts > 0 && + connectionAttempts < MAX_RECONNECTION_ATTEMPTS && + !isRecovering && + isAutoStartEnabled + ) { + console.log('[DaemonLifecycle] Scheduling retry for daemon recovery'); + scheduleRetry(); + } + + return () => { + if (retryTimeoutRef.current) { + clearTimeout(retryTimeoutRef.current); + retryTimeoutRef.current = null; + } + }; + }, [status, connectionAttempts, isRecovering, isAutoStartEnabled, scheduleRetry]); + + // Reset connection attempts when daemon becomes healthy + useEffect(() => { + if (status === 'running' && connectionAttempts > 0) { + console.log('[DaemonLifecycle] Daemon healthy - resetting connection attempts'); + dispatch(resetConnectionAttempts({ userId: userId || '__pending__' })); + + // Clear retry timeout if running + if (retryTimeoutRef.current) { + clearTimeout(retryTimeoutRef.current); + retryTimeoutRef.current = null; + } + } + }, [status, connectionAttempts, userId, dispatch]); + + // Return lifecycle state and controls + return { + // State + isAutoStartEnabled, + connectionAttempts, + isRecovering, + maxAttemptsReached: connectionAttempts >= MAX_RECONNECTION_ATTEMPTS, + + // Actions + attemptAutoStart, + resetRetries: () => { + dispatch(resetConnectionAttempts({ userId: userId || '__pending__' })); + if (retryTimeoutRef.current) { + clearTimeout(retryTimeoutRef.current); + retryTimeoutRef.current = null; + } + }, + + // Config + MAX_RECONNECTION_ATTEMPTS, + nextRetryDelay: connectionAttempts < MAX_RECONNECTION_ATTEMPTS + ? calculateRetryDelay(connectionAttempts + 1) + : null, + }; +}; \ No newline at end of file diff --git a/src/providers/SocketProvider.tsx b/src/providers/SocketProvider.tsx index 5c8be4ce5..dfbe2edf8 100644 --- a/src/providers/SocketProvider.tsx +++ b/src/providers/SocketProvider.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef } from 'react'; +import { useDaemonLifecycle } from '../hooks/useDaemonLifecycle'; import { socketService } from '../services/socketService'; import { store } from '../store'; import { useAppSelector } from '../store/hooks'; @@ -25,21 +26,71 @@ import { * In web mode: uses the frontend Socket.io client directly. */ const SocketProvider = ({ children }: { children: React.ReactNode }) => { + console.log('[SocketProvider] Component mounting/re-rendering'); const token = useAppSelector(state => state.auth.token); const socketStatus = useAppSelector(selectSocketStatus); const previousTokenRef = useRef(null); const tauriListenersSetup = useRef(false); const usesRustSocket = isTauri(); + console.log('[SocketProvider] usesRustSocket:', usesRustSocket, 'isTauri():', isTauri()); + + // Setup daemon lifecycle management in Tauri mode + const daemonLifecycle = useDaemonLifecycle(); + + // Log daemon lifecycle state for debugging + useEffect(() => { + if (usesRustSocket && process.env.NODE_ENV === 'development') { + console.log('[SocketProvider] Daemon lifecycle state:', { + isAutoStartEnabled: daemonLifecycle.isAutoStartEnabled, + connectionAttempts: daemonLifecycle.connectionAttempts, + isRecovering: daemonLifecycle.isRecovering, + maxAttemptsReached: daemonLifecycle.maxAttemptsReached, + }); + } + }, [ + usesRustSocket, + daemonLifecycle.isAutoStartEnabled, + daemonLifecycle.connectionAttempts, + daemonLifecycle.isRecovering, + daemonLifecycle.maxAttemptsReached, + ]); // Setup Tauri event listeners once useEffect(() => { + console.log('[SocketProvider] useEffect triggered, usesRustSocket:', usesRustSocket, 'tauriListenersSetup:', tauriListenersSetup.current); + if (usesRustSocket && !tauriListenersSetup.current) { - setupTauriSocketListeners(); + console.log('[SocketProvider] Condition met - calling setupTauriSocketListeners()'); + console.log('[SocketProvider] About to call setupTauriSocketListeners()'); + + // Set this immediately to prevent multiple calls tauriListenersSetup.current = true; + + setupTauriSocketListeners() + .then(() => { + console.log('[SocketProvider] setupTauriSocketListeners() completed successfully'); + }) + .catch((error) => { + console.error('[SocketProvider] setupTauriSocketListeners() failed:', error); + console.error('[SocketProvider] Error details:', { + message: error?.message, + stack: error?.stack, + toString: error?.toString(), + }); + // Reset flag on failure so it can retry + tauriListenersSetup.current = false; + }); + } else if (usesRustSocket && tauriListenersSetup.current) { + console.log('[SocketProvider] Tauri listeners already set up, skipping'); + } else if (!usesRustSocket) { + console.log('[SocketProvider] Not using Rust socket, skipping Tauri listener setup'); + } else { + console.log('[SocketProvider] Unexpected condition - usesRustSocket:', usesRustSocket, 'tauriListenersSetup.current:', tauriListenersSetup.current); } return () => { if (usesRustSocket && tauriListenersSetup.current) { + console.log('[SocketProvider] Cleaning up Tauri socket listeners'); cleanupTauriSocketListeners(); tauriListenersSetup.current = false; } diff --git a/src/services/api/userApi.ts b/src/services/api/userApi.ts index 5685c14f4..419b182d0 100644 --- a/src/services/api/userApi.ts +++ b/src/services/api/userApi.ts @@ -19,11 +19,11 @@ export const userApi = { /** * Mark onboarding complete for the current user. - * POST /telegram/settings/onboarding-complete + * POST /settings/onboarding-complete */ onboardingComplete: async (): Promise => { await apiClient.post<{ success: boolean; data: unknown }>( - '/telegram/settings/onboarding-complete', + '/settings/onboarding-complete', {} ); }, diff --git a/src/services/daemonHealthService.ts b/src/services/daemonHealthService.ts new file mode 100644 index 000000000..e619c0d26 --- /dev/null +++ b/src/services/daemonHealthService.ts @@ -0,0 +1,207 @@ +/** + * Daemon Health Service + * + * Manages health monitoring for the alphahuman daemon by listening to + * 'alphahuman:health' events emitted by the Rust backend every 5 seconds. + */ +import { listen, type UnlistenFn } from '@tauri-apps/api/event'; + +import { store } from '../store'; +import { + type ComponentHealth, + type HealthSnapshot, + setDaemonStatus, + setHealthTimeoutId, + updateHealthSnapshot, +} from '../store/daemonSlice'; + +export class DaemonHealthService { + private healthTimeoutId: ReturnType | null = null; + private readonly HEALTH_TIMEOUT_MS = 30000; // 30 seconds + private healthEventListener: UnlistenFn | null = null; + + /** + * Setup health event listener from the Rust daemon. + * Should be called once when the app starts in Tauri mode. + */ + async setupHealthListener(): Promise { + console.log('[DaemonHealth] setupHealthListener() called - starting setup process'); + try { + console.log('[DaemonHealth] About to call listen() for alphahuman:health event'); + console.log('[DaemonHealth] Setting up alphahuman:health event listener'); + + this.healthEventListener = await listen('alphahuman:health', event => { + console.log('[DaemonHealth] Received health event:', event.payload); + + const healthSnapshot = this.parseHealthSnapshot(event.payload); + if (healthSnapshot) { + this.updateReduxFromHealth(healthSnapshot); + this.startHealthTimeout(); + } else { + console.warn('[DaemonHealth] Failed to parse health snapshot:', event.payload); + } + }); + console.log('[DaemonHealth] alphahuman:health listener created successfully'); + + // Start initial timeout + console.log('[DaemonHealth] Starting health timeout'); + this.startHealthTimeout(); + console.log('[DaemonHealth] Health timeout started'); + + console.log('[DaemonHealth] Health listener setup complete'); + return this.healthEventListener; + } catch (error) { + console.error('[DaemonHealth] Failed to setup health listener:', error); + return null; + } + } + + /** + * Cleanup the health event listener. + */ + cleanup(): void { + if (this.healthEventListener) { + this.healthEventListener(); + this.healthEventListener = null; + } + + if (this.healthTimeoutId) { + clearTimeout(this.healthTimeoutId); + this.healthTimeoutId = null; + } + } + + /** + * Parse the health snapshot received from Rust. + */ + private parseHealthSnapshot(payload: unknown): HealthSnapshot | null { + try { + if (!payload || typeof payload !== 'object') { + return null; + } + + const data = payload as Record; + + // Validate required fields + if ( + typeof data.pid !== 'number' || + typeof data.updated_at !== 'string' || + typeof data.uptime_seconds !== 'number' || + !data.components || + typeof data.components !== 'object' + ) { + return null; + } + + // Parse components + const components: Record = {}; + const componentsData = data.components as Record; + + for (const [name, component] of Object.entries(componentsData)) { + if (!component || typeof component !== 'object') { + continue; + } + + const comp = component as Record; + if ( + typeof comp.status !== 'string' || + typeof comp.updated_at !== 'string' || + typeof comp.restart_count !== 'number' + ) { + continue; + } + + // Validate status is a valid ComponentStatus + if (comp.status !== 'ok' && comp.status !== 'error' && comp.status !== 'starting') { + continue; + } + + components[name] = { + status: comp.status as 'ok' | 'error' | 'starting', + updated_at: comp.updated_at, + last_ok: typeof comp.last_ok === 'string' ? comp.last_ok : undefined, + last_error: typeof comp.last_error === 'string' ? comp.last_error : undefined, + restart_count: comp.restart_count, + }; + } + + return { + pid: data.pid as number, + updated_at: data.updated_at as string, + uptime_seconds: data.uptime_seconds as number, + components, + }; + } catch (error) { + console.error('[DaemonHealth] Error parsing health snapshot:', error); + return null; + } + } + + /** + * Update Redux state based on received health snapshot. + */ + private updateReduxFromHealth(snapshot: HealthSnapshot): void { + const userId = this.getUserId(); + + try { + // Update the health snapshot in Redux + store.dispatch(updateHealthSnapshot({ userId, healthSnapshot: snapshot })); + + console.log('[DaemonHealth] Updated health snapshot for user:', userId, snapshot); + } catch (error) { + console.error('[DaemonHealth] Error updating Redux from health:', error); + } + } + + /** + * Start or restart the health timeout. + * If no health events are received within the timeout period, + * the daemon status will be set to 'disconnected'. + */ + private startHealthTimeout(): void { + // Clear existing timeout + if (this.healthTimeoutId) { + clearTimeout(this.healthTimeoutId); + } + + const userId = this.getUserId(); + + // Set new timeout + this.healthTimeoutId = setTimeout(() => { + console.warn('[DaemonHealth] Health timeout reached - setting status to disconnected'); + store.dispatch(setDaemonStatus({ userId, status: 'disconnected' })); + store.dispatch(setHealthTimeoutId({ userId, timeoutId: null })); + this.healthTimeoutId = null; + }, this.HEALTH_TIMEOUT_MS); + + // Store timeout ID in Redux for cleanup + store.dispatch( + setHealthTimeoutId({ + userId, + timeoutId: this.healthTimeoutId.toString(), + }) + ); + } + + /** + * Get the current user ID for daemon state management. + */ + private getUserId(): string { + const token = store.getState().auth.token; + if (!token) return '__pending__'; + + try { + const parts = token.split('.'); + if (parts.length !== 3) return '__pending__'; + const payloadBase64 = parts[1].replace(/-/g, '+').replace(/_/g, '/'); + const payloadJson = atob(payloadBase64); + const payload = JSON.parse(payloadJson); + return payload.tgUserId || payload.userId || payload.sub || '__pending__'; + } catch { + return '__pending__'; + } + } +} + +// Export singleton instance +export const daemonHealthService = new DaemonHealthService(); \ No newline at end of file diff --git a/src/store/daemonSlice.ts b/src/store/daemonSlice.ts new file mode 100644 index 000000000..2b409dab6 --- /dev/null +++ b/src/store/daemonSlice.ts @@ -0,0 +1,214 @@ +import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; + +export type DaemonStatus = 'starting' | 'running' | 'error' | 'disconnected'; +export type ComponentStatus = 'ok' | 'error' | 'starting'; + +export interface ComponentHealth { + status: ComponentStatus; + updated_at: string; + last_ok?: string; + last_error?: string; + restart_count: number; +} + +export interface HealthSnapshot { + pid: number; + updated_at: string; + uptime_seconds: number; + components: Record; +} + +export interface DaemonUserState { + status: DaemonStatus; + healthSnapshot: HealthSnapshot | null; + components: { + gateway?: ComponentHealth; + channels?: ComponentHealth; + heartbeat?: ComponentHealth; + scheduler?: ComponentHealth; + }; + lastHealthUpdate: string | null; + connectionAttempts: number; + autoStartEnabled: boolean; + isRecovering: boolean; + healthTimeoutId: string | null; +} + +const initialUserState: DaemonUserState = { + status: 'disconnected', + healthSnapshot: null, + components: {}, + lastHealthUpdate: null, + connectionAttempts: 0, + autoStartEnabled: false, + isRecovering: false, + healthTimeoutId: null, +}; + +interface DaemonState { + /** Daemon state per user id. Use __pending__ when user not loaded yet. */ + byUser: Record; +} + +const initialState: DaemonState = { byUser: {} }; + +const ensureUserState = (state: DaemonState, userId: string): DaemonUserState => { + if (!state.byUser[userId]) { + state.byUser[userId] = { ...initialUserState }; + } + return state.byUser[userId]; +}; + +const daemonSlice = createSlice({ + name: 'daemon', + initialState, + reducers: { + updateHealthSnapshot: ( + state, + action: PayloadAction<{ userId: string; healthSnapshot: HealthSnapshot }> + ) => { + const { userId, healthSnapshot } = action.payload; + const user = ensureUserState(state, userId); + + user.healthSnapshot = healthSnapshot; + user.lastHealthUpdate = new Date().toISOString(); + + // Update component health + user.components = healthSnapshot.components; + + // Determine overall daemon status based on component health + const componentStatuses = Object.values(healthSnapshot.components).map(c => c.status); + + if (componentStatuses.length === 0) { + user.status = 'disconnected'; + } else if (componentStatuses.every(status => status === 'ok')) { + user.status = 'running'; + user.isRecovering = false; + user.connectionAttempts = 0; + } else if (componentStatuses.some(status => status === 'error')) { + user.status = 'error'; + } else if (componentStatuses.some(status => status === 'starting')) { + user.status = 'starting'; + } + }, + + setDaemonStatus: ( + state, + action: PayloadAction<{ userId: string; status: DaemonStatus }> + ) => { + const { userId, status } = action.payload; + const user = ensureUserState(state, userId); + user.status = status; + + if (status === 'disconnected') { + user.healthSnapshot = null; + user.components = {}; + user.lastHealthUpdate = null; + } + }, + + incrementConnectionAttempts: ( + state, + action: PayloadAction<{ userId: string }> + ) => { + const { userId } = action.payload; + const user = ensureUserState(state, userId); + user.connectionAttempts += 1; + }, + + resetConnectionAttempts: ( + state, + action: PayloadAction<{ userId: string }> + ) => { + const { userId } = action.payload; + const user = ensureUserState(state, userId); + user.connectionAttempts = 0; + }, + + setAutoStartEnabled: ( + state, + action: PayloadAction<{ userId: string; enabled: boolean }> + ) => { + const { userId, enabled } = action.payload; + const user = ensureUserState(state, userId); + user.autoStartEnabled = enabled; + }, + + setIsRecovering: ( + state, + action: PayloadAction<{ userId: string; isRecovering: boolean }> + ) => { + const { userId, isRecovering } = action.payload; + const user = ensureUserState(state, userId); + user.isRecovering = isRecovering; + }, + + setHealthTimeoutId: ( + state, + action: PayloadAction<{ userId: string; timeoutId: string | null }> + ) => { + const { userId, timeoutId } = action.payload; + const user = ensureUserState(state, userId); + user.healthTimeoutId = timeoutId; + }, + + resetForUser: (state, action: PayloadAction<{ userId: string }>) => { + const { userId } = action.payload; + state.byUser[userId] = { ...initialUserState }; + }, + }, +}); + +export const { + updateHealthSnapshot, + setDaemonStatus, + incrementConnectionAttempts, + resetConnectionAttempts, + setAutoStartEnabled, + setIsRecovering, + setHealthTimeoutId, + resetForUser, +} = daemonSlice.actions; + +// Selectors +export const selectDaemonStateForUser = (state: { daemon: DaemonState }, userId?: string) => { + const uid = userId || '__pending__'; + return state.daemon.byUser[uid] || initialUserState; +}; + +export const selectDaemonStatus = (state: { daemon: DaemonState }, userId?: string) => { + const daemonState = selectDaemonStateForUser(state, userId); + return daemonState.status; +}; + +export const selectDaemonComponents = (state: { daemon: DaemonState }, userId?: string) => { + const daemonState = selectDaemonStateForUser(state, userId); + return daemonState.components; +}; + +export const selectDaemonHealthSnapshot = (state: { daemon: DaemonState }, userId?: string) => { + const daemonState = selectDaemonStateForUser(state, userId); + return daemonState.healthSnapshot; +}; + +export const selectDaemonLastHealthUpdate = (state: { daemon: DaemonState }, userId?: string) => { + const daemonState = selectDaemonStateForUser(state, userId); + return daemonState.lastHealthUpdate; +}; + +export const selectIsDaemonAutoStartEnabled = (state: { daemon: DaemonState }, userId?: string) => { + const daemonState = selectDaemonStateForUser(state, userId); + return daemonState.autoStartEnabled; +}; + +export const selectDaemonConnectionAttempts = (state: { daemon: DaemonState }, userId?: string) => { + const daemonState = selectDaemonStateForUser(state, userId); + return daemonState.connectionAttempts; +}; + +export const selectIsDaemonRecovering = (state: { daemon: DaemonState }, userId?: string) => { + const daemonState = selectDaemonStateForUser(state, userId); + return daemonState.isRecovering; +}; + +export default daemonSlice.reducer; \ No newline at end of file diff --git a/src/store/index.ts b/src/store/index.ts index 7d6932041..c3a9fb227 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -17,6 +17,7 @@ import { IS_DEV } from '../utils/config'; import { storeSession } from '../utils/tauriCommands'; import aiReducer from './aiSlice'; import authReducer, { setOnboardedForUser, setToken } from './authSlice'; +import daemonReducer from './daemonSlice'; import inviteReducer from './inviteSlice'; import skillsReducer from './skillsSlice'; import socketReducer from './socketSlice'; @@ -81,6 +82,7 @@ export const store = configureStore({ auth: persistedAuthReducer, socket: socketReducer, user: userReducer, + daemon: daemonReducer, ai: persistedAiReducer, skills: persistedSkillsReducer, team: teamReducer, diff --git a/src/types/global.d.ts b/src/types/global.d.ts new file mode 100644 index 000000000..0708f5e30 --- /dev/null +++ b/src/types/global.d.ts @@ -0,0 +1,11 @@ +// Global type declarations for the application + +declare global { + interface Window { + __TAURI__?: { + [key: string]: unknown; + }; + } +} + +export {}; \ No newline at end of file diff --git a/src/utils/tauriSocket.ts b/src/utils/tauriSocket.ts index 393296b70..717f78837 100644 --- a/src/utils/tauriSocket.ts +++ b/src/utils/tauriSocket.ts @@ -17,13 +17,18 @@ import { isTauri as coreIsTauri, invoke } from '@tauri-apps/api/core'; import { listen, UnlistenFn } from '@tauri-apps/api/event'; import { syncToolsToBackend } from '../lib/skills/sync'; +import { daemonHealthService } from '../services/daemonHealthService'; import { store } from '../store'; import { setSocketIdForUser, setStatusForUser } from '../store/socketSlice'; import { BACKEND_URL } from './config'; // Check if we're running in Tauri export const isTauri = (): boolean => { - return coreIsTauri(); + const isTauriEnv = coreIsTauri(); + const windowTauri = typeof window !== 'undefined' ? !!window.__TAURI__ : 'undefined'; + const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : 'undefined'; + console.log('[TauriSocket] isTauri() check:', isTauriEnv, 'window.__TAURI__:', windowTauri, 'userAgent:', userAgent); + return isTauriEnv; }; // --------------------------------------------------------------------------- @@ -102,6 +107,7 @@ let unlistenConnect: UnlistenFn | null = null; let unlistenDisconnect: UnlistenFn | null = null; let unlistenSocketState: UnlistenFn | null = null; let unlistenServerEvent: UnlistenFn | null = null; +let unlistenDaemonHealth: UnlistenFn | null = null; function getSocketUserId(): string { const token = store.getState().auth.token; @@ -124,10 +130,17 @@ function getSocketUserId(): string { * This should be called once when the app starts in Tauri mode. */ export async function setupTauriSocketListeners(): Promise { - if (!isTauri()) return; + console.log('[TauriSocket] setupTauriSocketListeners() called'); + if (!isTauri()) { + console.log('[TauriSocket] Not in Tauri environment, returning early'); + return; + } + console.log('[TauriSocket] In Tauri environment, proceeding with listener setup'); try { + console.log('[TauriSocket] Starting listener setup sequence'); // Listen for Rust socket state changes (Phase 2 — primary) + console.log('[TauriSocket] Setting up runtime:socket-state-changed listener'); unlistenSocketState = await listen<{ status: string; socket_id: string | null }>( 'runtime:socket-state-changed', event => { @@ -156,14 +169,18 @@ export async function setupTauriSocketListeners(): Promise { } } ); + console.log('[TauriSocket] runtime:socket-state-changed listener setup complete'); // Listen for forwarded server events + console.log('[TauriSocket] Setting up server:event listener'); unlistenServerEvent = await listen<{ event: string; data: unknown }>('server:event', event => { console.log('[TauriSocket] Server event:', event.payload.event, event.payload.data); // Future: dispatch to specific handlers based on event type }); + console.log('[TauriSocket] server:event listener setup complete'); // Legacy: Listen for connect requests from Rust (backwards compat) + console.log('[TauriSocket] Setting up legacy socket:should_connect listener'); unlistenConnect = await listen<{ backendUrl: string; token: string }>( 'socket:should_connect', async event => { @@ -172,12 +189,20 @@ export async function setupTauriSocketListeners(): Promise { void event; } ); + console.log('[TauriSocket] socket:should_connect listener setup complete'); // Legacy: Listen for disconnect requests from Rust + console.log('[TauriSocket] Setting up legacy socket:should_disconnect listener'); unlistenDisconnect = await listen('socket:should_disconnect', async () => { console.log('[TauriSocket] Legacy disconnect request (ignored — using Rust socket)'); // No-op: Rust socket handles disconnection now }); + console.log('[TauriSocket] socket:should_disconnect listener setup complete'); + + // Setup daemon health monitoring + console.log('[TauriSocket] About to setup daemon health listener'); + unlistenDaemonHealth = await daemonHealthService.setupHealthListener(); + console.log('[TauriSocket] Daemon health listener setup result:', unlistenDaemonHealth); console.log('[TauriSocket] Event listeners setup complete'); } catch (error) { @@ -205,6 +230,13 @@ export function cleanupTauriSocketListeners(): void { unlistenServerEvent(); unlistenServerEvent = null; } + if (unlistenDaemonHealth) { + unlistenDaemonHealth(); + unlistenDaemonHealth = null; + } + + // Cleanup daemon health service + daemonHealthService.cleanup(); } // --------------------------------------------------------------------------- From 9e26a9525ab92b92752a0e5e2c275bb2234b0ed1 Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Thu, 26 Feb 2026 21:26:25 +0530 Subject: [PATCH 4/9] Fix/api endpoints alignment (#149) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add .mcp.json for MCP server configuration - Introduced `.mcp.json` with server details for managing MCP integrations - Defines `readme` server with HTTP type and URL endpoint configuration * fix: Update skills submodule with Telegram error handling improvements - Fixed setup flow showing false success when TDLib errors occurred - Improved async error handling in setup steps - Added client reset mechanisms for error recovery - Enhanced error messaging for better user experience - Cleaned up excessive debug logging while maintaining error logs Resolves issue where Telegram setup showed success modal despite underlying TDLib errors. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * fix: align frontend API endpoints with backend specification - Fix thread API endpoints by removing incorrect /telegram prefix - Fix user settings endpoint path - Add missing API services: settings, actionable items, credits, feedback, API keys - Ensure all endpoints match backend OpenAPI specification 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * Refactor MCP server configuration structure Updated the MCP configuration to use "alphaHuman" as the key instead of "readme" for clarity and consistency. This change ensures better alignment with naming conventions and improves readability. --------- Co-authored-by: Claude --- .claude/mcp.json | 9 +- .mcp.json | 8 ++ src/services/api/actionableItemsApi.ts | 113 +++++++++++++++++++++++++ src/services/api/apiKeysApi.ts | 53 ++++++++++++ src/services/api/creditsApi.ts | 51 +++++++++++ src/services/api/feedbackApi.ts | 68 +++++++++++++++ src/services/api/settingsApi.ts | 38 +++++++++ src/services/api/threadApi.ts | 20 ++--- 8 files changed, 349 insertions(+), 11 deletions(-) create mode 100644 .mcp.json create mode 100644 src/services/api/actionableItemsApi.ts create mode 100644 src/services/api/apiKeysApi.ts create mode 100644 src/services/api/creditsApi.ts create mode 100644 src/services/api/feedbackApi.ts create mode 100644 src/services/api/settingsApi.ts diff --git a/.claude/mcp.json b/.claude/mcp.json index b15ddeb24..0ff89af83 100644 --- a/.claude/mcp.json +++ b/.claude/mcp.json @@ -1 +1,8 @@ -{ "mcpServers": { "readme": { "type": "http", "url": "https://alphahuman.readme.io/mcp" } } } +{ + "mcpServers": { + "alphaHuman": { + "type": "http", + "url": "https://alphahuman.readme.io/mcp" + } + } +} diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 000000000..0ff89af83 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "alphaHuman": { + "type": "http", + "url": "https://alphahuman.readme.io/mcp" + } + } +} diff --git a/src/services/api/actionableItemsApi.ts b/src/services/api/actionableItemsApi.ts new file mode 100644 index 000000000..d1bd436ab --- /dev/null +++ b/src/services/api/actionableItemsApi.ts @@ -0,0 +1,113 @@ +import type { ApiResponse } from '../../types/api'; +import { apiClient } from '../apiClient'; + +interface ActionableItem { + id: string; + title: string; + description?: string; + status: 'pending' | 'dismissed' | 'snoozed' | 'completed'; + createdAt: string; + updatedAt: string; + // Add other fields based on backend schema +} + +interface ExecutionSession { + id: string; + itemId: string; + status: 'running' | 'pending' | 'completed' | 'failed'; + // Add other fields based on backend schema +} + +interface ThreadData { + threadId: string; + conversationId: string; +} + +/** + * Actionable Items API endpoints + */ +export const actionableItemsApi = { + /** + * List actionable items for the authenticated user + * GET /telegram/actionable-items + */ + getActionableItems: async (): Promise => { + const response = await apiClient.get>('/telegram/actionable-items'); + return response.data; + }, + + /** + * Update an actionable item (dismiss, snooze, etc.) + * PATCH /telegram/actionable-items/:itemId + */ + updateActionableItem: async ( + itemId: string, + updates: { status?: ActionableItem['status']; snoozeUntil?: string } + ): Promise => { + const response = await apiClient.patch>( + `/telegram/actionable-items/${itemId}`, + updates + ); + return response.data; + }, + + /** + * Get or create conversation thread for an actionable item + * GET /telegram/actionable-items/:itemId/thread + */ + getItemThread: async (itemId: string): Promise => { + const response = await apiClient.get>( + `/telegram/actionable-items/${itemId}/thread` + ); + return response.data; + }, + + /** + * Get current execution session for an actionable item + * GET /telegram/actionable-items/:itemId/session + */ + getItemSession: async (itemId: string): Promise => { + const response = await apiClient.get>( + `/telegram/actionable-items/${itemId}/session` + ); + return response.data; + }, + + /** + * Start execution of an actionable item + * POST /telegram/actionable-items/:itemId/execute + */ + executeItem: async (itemId: string): Promise => { + const response = await apiClient.post>( + `/telegram/actionable-items/${itemId}/execute` + ); + return response.data; + }, + + /** + * Get execution session status + * GET /telegram/execution-sessions/:sessionId + */ + getExecutionSession: async (sessionId: string): Promise => { + const response = await apiClient.get>( + `/telegram/execution-sessions/${sessionId}` + ); + return response.data; + }, + + /** + * Confirm or reject pending execution step + * POST /telegram/execution-sessions/:sessionId/confirm + */ + confirmExecutionStep: async ( + sessionId: string, + action: 'confirm' | 'reject', + data?: unknown + ): Promise => { + const response = await apiClient.post>( + `/telegram/execution-sessions/${sessionId}/confirm`, + { action, data } + ); + return response.data; + }, +}; \ No newline at end of file diff --git a/src/services/api/apiKeysApi.ts b/src/services/api/apiKeysApi.ts new file mode 100644 index 000000000..7509965d9 --- /dev/null +++ b/src/services/api/apiKeysApi.ts @@ -0,0 +1,53 @@ +import type { ApiResponse } from '../../types/api'; +import { apiClient } from '../apiClient'; + +interface ApiKey { + id: string; + name: string; + keyPreview: string; // First few characters of the key + createdAt: string; + lastUsedAt?: string; + // Add other fields based on backend schema +} + +interface CreateApiKeyData { + name: string; +} + +interface CreateApiKeyResponse { + id: string; + name: string; + key: string; // Full key only returned on creation + createdAt: string; +} + +/** + * API Keys management endpoints + */ +export const apiKeysApi = { + /** + * Create API key + * POST /api-keys + */ + createApiKey: async (data: CreateApiKeyData): Promise => { + const response = await apiClient.post>('/api-keys', data); + return response.data; + }, + + /** + * List API keys + * GET /api-keys + */ + getApiKeys: async (): Promise => { + const response = await apiClient.get>('/api-keys'); + return response.data; + }, + + /** + * Revoke API key + * DELETE /api-keys/:keyId + */ + revokeApiKey: async (keyId: string): Promise => { + await apiClient.delete>(`/api-keys/${keyId}`); + }, +}; \ No newline at end of file diff --git a/src/services/api/creditsApi.ts b/src/services/api/creditsApi.ts new file mode 100644 index 000000000..004f69beb --- /dev/null +++ b/src/services/api/creditsApi.ts @@ -0,0 +1,51 @@ +import type { ApiResponse } from '../../types/api'; +import { apiClient } from '../apiClient'; + +interface CreditBalance { + balance: number; + currency: string; +} + +interface CreditTransaction { + id: string; + amount: number; + type: 'credit' | 'debit'; + description: string; + createdAt: string; + // Add other fields based on backend schema +} + +interface PaginatedTransactions { + transactions: CreditTransaction[]; + totalCount: number; + page: number; + limit: number; +} + +/** + * Credits API endpoints + */ +export const creditsApi = { + /** + * Get the current user's credit balance + * GET /credits/balance + */ + getBalance: async (): Promise => { + const response = await apiClient.get>('/credits/balance'); + return response.data; + }, + + /** + * Get paginated credit transaction history + * GET /credits/transactions + */ + getTransactions: async ( + page = 1, + limit = 50 + ): Promise => { + const response = await apiClient.get>( + `/credits/transactions?page=${page}&limit=${limit}` + ); + return response.data; + }, +}; \ No newline at end of file diff --git a/src/services/api/feedbackApi.ts b/src/services/api/feedbackApi.ts new file mode 100644 index 000000000..bf08dc6d9 --- /dev/null +++ b/src/services/api/feedbackApi.ts @@ -0,0 +1,68 @@ +import type { ApiResponse } from '../../types/api'; +import { apiClient } from '../apiClient'; + +interface FeedbackItem { + id: string; + type: 'bug' | 'feature_request' | 'general'; + title: string; + description: string; + steps?: string; + status: 'open' | 'in_progress' | 'closed'; + createdAt: string; + updatedAt: string; + // Add other fields based on backend schema +} + +interface CreateFeedbackData { + type: FeedbackItem['type']; + title: string; + description: string; + steps?: string; +} + +interface UpdateFeedbackData { + title?: string; + description?: string; + steps?: string; +} + +/** + * Feedback API endpoints + */ +export const feedbackApi = { + /** + * Submit feedback (bug, feature_request, general) + * POST /feedback + */ + createFeedback: async (feedback: CreateFeedbackData): Promise => { + const response = await apiClient.post>('/feedback', feedback); + return response.data; + }, + + /** + * List current user's feedback + * GET /feedback + */ + getFeedback: async (): Promise => { + const response = await apiClient.get>('/feedback'); + return response.data; + }, + + /** + * Get a single feedback item + * GET /feedback/:id + */ + getFeedbackById: async (id: string): Promise => { + const response = await apiClient.get>(`/feedback/${id}`); + return response.data; + }, + + /** + * Update feedback (description, steps, etc.) + * PUT /feedback/:id + */ + updateFeedback: async (id: string, updates: UpdateFeedbackData): Promise => { + const response = await apiClient.put>(`/feedback/${id}`, updates); + return response.data; + }, +}; \ No newline at end of file diff --git a/src/services/api/settingsApi.ts b/src/services/api/settingsApi.ts new file mode 100644 index 000000000..dcd547006 --- /dev/null +++ b/src/services/api/settingsApi.ts @@ -0,0 +1,38 @@ +import type { ApiResponse } from '../../types/api'; +import { apiClient } from '../apiClient'; + +interface UserSettings { + // Add specific settings types based on backend schema + [key: string]: unknown; +} + +/** + * Settings API endpoints + */ +export const settingsApi = { + /** + * Get user settings + * GET /settings + */ + getSettings: async (): Promise => { + const response = await apiClient.get>('/settings'); + return response.data; + }, + + /** + * Update user settings + * PATCH /settings + */ + updateSettings: async (settings: Partial): Promise => { + const response = await apiClient.patch>('/settings', settings); + return response.data; + }, + + /** + * Set platforms connected + * POST /settings/platforms-connected + */ + setPlatformsConnected: async (platforms: string[]): Promise => { + await apiClient.post>('/settings/platforms-connected', { platforms }); + }, +}; \ No newline at end of file diff --git a/src/services/api/threadApi.ts b/src/services/api/threadApi.ts index a649df031..f2b1bd94c 100644 --- a/src/services/api/threadApi.ts +++ b/src/services/api/threadApi.ts @@ -12,33 +12,33 @@ import type { import { apiClient } from '../apiClient'; export const threadApi = { - /** GET /telegram/threads — list all threads for the authenticated user */ + /** GET /threads — list all threads for the authenticated user */ getThreads: async (): Promise => { - const response = await apiClient.get>('/telegram/threads'); + const response = await apiClient.get>('/threads'); return response.data; }, - /** POST /telegram/threads — create a new thread */ + /** POST /threads — create a new thread */ createThread: async (chatId?: number): Promise => { const response = await apiClient.post>( - '/telegram/threads', + '/threads', chatId != null ? { chatId } : undefined ); return response.data; }, - /** GET /telegram/threads/:threadId/messages — get messages for a thread */ + /** GET /threads/:threadId/messages — get messages for a thread */ getThreadMessages: async (threadId: string): Promise => { const response = await apiClient.get>( - `/telegram/threads/${encodeURIComponent(threadId)}/messages` + `/threads/${encodeURIComponent(threadId)}/messages` ); return response.data; }, - /** DELETE /telegram/threads/:threadId — delete a single thread */ + /** DELETE /threads/:threadId — delete a single thread */ deleteThread: async (threadId: string): Promise => { const response = await apiClient.delete>( - `/telegram/threads/${encodeURIComponent(threadId)}` + `/threads/${encodeURIComponent(threadId)}` ); return response.data; }, @@ -65,9 +65,9 @@ export const threadApi = { return response.data; }, - /** POST /telegram/purge — purge messages and/or threads */ + /** POST /purge — purge messages and/or threads */ purge: async (body: PurgeRequestBody): Promise => { - const response = await apiClient.post>('/telegram/purge', body); + const response = await apiClient.post>('/purge', body); return response.data; }, }; From ca11b001c8902738ffb051fb5e538f2657c38d50 Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Thu, 26 Feb 2026 21:26:51 +0530 Subject: [PATCH 5/9] feat: comprehensive Tauri Command Console redesign with runtime panic fixes (#136) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add .mcp.json for MCP server configuration - Introduced `.mcp.json` with server details for managing MCP integrations - Defines `readme` server with HTTP type and URL endpoint configuration * fix: Update skills submodule with Telegram error handling improvements - Fixed setup flow showing false success when TDLib errors occurred - Improved async error handling in setup steps - Added client reset mechanisms for error recovery - Enhanced error messaging for better user experience - Cleaned up excessive debug logging while maintaining error logs Resolves issue where Telegram setup showed success modal despite underlying TDLib errors. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * feat: comprehensive redesign of Tauri Command Console Transform 845-line admin interface into premium crypto-styled experience: - Complete architectural redesign with 6 logical categories - Premium card system with priority-based styling (Ocean blue, Slate, Amber) - Progressive disclosure system (Basic → Advanced → Developer modes) - Contextual descriptions for all 60+ form fields - Responsive design with desktop grid and mobile accordion - 100% functionality preservation while dramatically improving UX Categories reorganized: • System Configuration (Critical - API keys, model settings) • Runtime & Execution (Critical - V8 engine, skills, services) • Security & Data (Critical - encryption, integrations) • Network & Infrastructure (Infrastructure - gateway, tunnels, memory) • Development & Operations (Development - diagnostics, hardware) • Interactive Tools (Tools - agent chat, output console) Components added: - SectionCard: Priority-based collapsible sections - InputGroup: Consistent form field organization - ActionPanel: Enhanced action buttons with loading states Features: - Smart progressive disclosure with user preference memory - Contextually accurate field descriptions and guidance - Professional typography using Cabinet Grotesk and Inter - Sophisticated color gradients matching crypto aesthetic - Mobile-responsive accordion behavior - Accessibility improvements with proper ARIA labeling 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * refactor: remove tooltips and reposition descriptions in Tauri Commands Panel - Remove tooltip system entirely from InputGroup components - Move full descriptions directly below labels instead of after input fields - Improve spacing and readability throughout interface - Delete unused Tooltip component and textUtils utilities - Enhance Field and CheckboxField components with better typography 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * style: improve spacing and layout in Tauri Commands Panel - Enhance spacing between sections and components for better readability - Improve grid gaps and padding throughout the interface - Refine ActionPanel and SectionCard component spacing - Better organize skills display with improved item spacing - Polish overall visual hierarchy and component alignment 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * feat: implement comprehensive UI consistency for Tauri Command Console - Replace all white backgrounds with sophisticated dark theme styling - Update input fields with bg-stone-900/40 and proper focus states - Implement custom button system replacing DaisyUI dependencies - Apply consistent glass morphism patterns throughout interface - Enhance checkbox and form component styling for better integration - Fix textarea elements with proper dark theme colors and borders - Add professional hover states and accessibility improvements - Achieve perfect visual consistency with Intelligence and Skills pages 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * feat: implement accordion pattern for System Configuration, Runtime & Execution, and Security & Data sections - Convert three critical sections to use collapsible accordion pattern - Add isSectionVisible() conditional wrappers for progressive disclosure - Change collapsible={false} to collapsible={true} for all three sections - Add defaultExpanded={!isCollapsed()} for consistent state management - Fix section ID consistency in getSectionVisibility() function - Maintain all existing functionality and content - Provide consistent UX matching Network & Infrastructure pattern 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * style: standardize all sections with Network & Infrastructure color scheme - Change all priority values to "infrastructure" for visual consistency - System Configuration: priority="critical" → "infrastructure" - Runtime & Execution: priority="critical" → "infrastructure" - Security & Data: priority="critical" → "infrastructure" - Development & Operations: priority="development" → "infrastructure" - Interactive Tools: priority="tools" → "infrastructure" All sections now use unified slate gradient backgrounds and icon colors for a cohesive, professional appearance throughout the interface. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * fix: resolve JSX syntax error in Interactive Tools section - Fix extra '>' character in ChatBubbleLeftRightIcon icon prop - Corrects TypeScript compilation error on line 1050 - Ensures proper JSX syntax for SectionCard component 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * feat: remove view mode selector and always show all 6 sections - Remove view mode state and selector UI (basic/advanced/developer) - Always show all sections: System Configuration, Runtime & Execution, Security & Data, Network & Infrastructure, Development & Operations, Interactive Tools - All sections start expanded by default for immediate access - Simplify section visibility logic to always return true - Keep loading indicator positioned on the right - Improve UX by removing unnecessary progressive disclosure 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * fix: resolve runtime panic and infinite loading in Test Connection - Fixed "Cannot drop a runtime in a context where blocking is not allowed" panic - Implemented Handle::try_current() pattern in TDLib manager and skills bridge - Added runtime safety utilities with proper async execution patterns - Fixed ownership issues with request_rx and future parameters - Added timeout protection to config loading operations - Enhanced error handling for runtime creation conflicts - Resolved compilation errors with proper variable cloning 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * feat: enhance Tauri Commands Panel with comprehensive validation system - Added ValidatedField component with real-time validation and visual feedback - Implemented provider-specific smart defaults and URL auto-population - Enhanced Test Connection with proper timeout and error handling - Added change tracking with unsaved changes indicator - Improved field validation for API keys, URLs, providers, models, and temperature - Enhanced ActionPanel and SectionCard with loading states and change indicators - Added provider configuration system with model suggestions - Implemented comprehensive error handling with field-specific feedback 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * chore: update skills submodule with dependency updates --------- Co-authored-by: Claude --- package.json | 2 +- src-tauri/src/commands/alphahuman.rs | 22 +- src-tauri/src/runtime/bridge/skills_bridge.rs | 35 +- src-tauri/src/runtime/mod.rs | 1 + src-tauri/src/runtime/utils.rs | 140 +++++ src-tauri/src/services/tdlib/manager.rs | 42 +- .../settings/panels/TauriCommandsPanel.tsx | 487 +++++++++++++++--- .../panels/components/ActionPanel.tsx | 16 +- .../panels/components/SectionCard.tsx | 10 +- .../panels/components/ValidatedField.tsx | 192 +++++++ 10 files changed, 841 insertions(+), 106 deletions(-) create mode 100644 src-tauri/src/runtime/utils.rs create mode 100644 src/components/settings/panels/components/ValidatedField.tsx diff --git a/package.json b/package.json index b43cc4742..dd7460265 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "alphahuman", "private": true, - "version": "0.48.0", + "version": "0.47.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/src/commands/alphahuman.rs b/src-tauri/src/commands/alphahuman.rs index 7d28e22ab..fb2d8bfc9 100644 --- a/src-tauri/src/commands/alphahuman.rs +++ b/src-tauri/src/commands/alphahuman.rs @@ -86,10 +86,24 @@ pub fn alphahuman_decrypt_secret( async fn load_alphahuman_config() -> Result { log::info!("[alphahuman:cmd] load_config called"); - Config::load_or_init().await.map_err(|e| { - log::error!("[alphahuman:cmd] load config failed: {}", e); - e.to_string() - }) + + // Add timeout protection and proper error handling to prevent runtime panics + let timeout_duration = std::time::Duration::from_secs(30); + + match tokio::time::timeout(timeout_duration, Config::load_or_init()).await { + Ok(Ok(config)) => { + log::info!("[alphahuman:cmd] config loaded successfully"); + Ok(config) + } + Ok(Err(e)) => { + log::error!("[alphahuman:cmd] load config failed: {}", e); + Err(e.to_string()) + } + Err(_) => { + log::error!("[alphahuman:cmd] load config timed out after 30 seconds"); + Err("Config loading timed out".to_string()) + } + } } #[derive(Debug, Clone, Serialize)] diff --git a/src-tauri/src/runtime/bridge/skills_bridge.rs b/src-tauri/src/runtime/bridge/skills_bridge.rs index 5466c03cf..1b2e8ccd5 100644 --- a/src-tauri/src/runtime/bridge/skills_bridge.rs +++ b/src-tauri/src/runtime/bridge/skills_bridge.rs @@ -54,21 +54,28 @@ pub fn call_tool( let (tx, rx) = std::sync::mpsc::sync_channel(1); std::thread::spawn(move || { - // Create a mini single-threaded Tokio runtime for the async call. - // We can't reuse the main runtime because we're on an OS thread - // outside it, and block_in_place would conflict with V8. - let rt = match tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - { - Ok(rt) => rt, - Err(e) => { - let _ = tx.send(Err(format!("Failed to create runtime: {e}"))); - return; - } - }; + // Try to use existing runtime first, fallback to creating new one if needed + let arguments_clone = arguments.clone(); + let runtime_result = tokio::runtime::Handle::try_current() + .map(|handle| { + // Use existing runtime by blocking on it + handle.block_on(async { registry.call_tool(&target, &tool, arguments).await }) + }) + .or_else(|_| { + // Only create new runtime if we're not in an async context + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| format!("Failed to create runtime: {e}")) + .map(|rt| { + rt.block_on(async { registry.call_tool(&target, &tool, arguments_clone).await }) + }) + }); - let result = rt.block_on(async { registry.call_tool(&target, &tool, arguments).await }); + let result = match runtime_result { + Ok(tool_result) => tool_result, + Err(e) => Err(e), + }; let _ = tx.send(result); }); diff --git a/src-tauri/src/runtime/mod.rs b/src-tauri/src/runtime/mod.rs index 337d494e8..a8fdbb773 100644 --- a/src-tauri/src/runtime/mod.rs +++ b/src-tauri/src/runtime/mod.rs @@ -12,6 +12,7 @@ pub mod manifest; pub mod preferences; pub mod socket_manager; pub mod types; +pub mod utils; // QuickJS modules - desktop only (not available on Android/iOS) #[cfg(not(any(target_os = "android", target_os = "ios")))] diff --git a/src-tauri/src/runtime/utils.rs b/src-tauri/src/runtime/utils.rs new file mode 100644 index 000000000..32bc55dc4 --- /dev/null +++ b/src-tauri/src/runtime/utils.rs @@ -0,0 +1,140 @@ +//! Runtime safety utilities. +//! +//! Provides safe ways to execute async code in different runtime contexts, +//! preventing the "Cannot start a runtime from within a runtime" panic. + +use std::future::Future; +use tokio::runtime::{Handle, Runtime}; + +/// Safely execute async code, trying to use existing runtime first. +/// +/// This function attempts to use the current async runtime handle if available, +/// otherwise creates a new single-threaded runtime. This prevents the common +/// panic "Cannot start a runtime from within a runtime". +pub fn safe_async_execute(future: F) -> Result +where + F: Future + Send + 'static, + R: Send + 'static, +{ + // Try to use existing runtime first + if let Ok(handle) = Handle::try_current() { + // We're in an async context, spawn on the current runtime + let (tx, rx) = std::sync::mpsc::sync_channel(1); + + handle.spawn(async move { + let result = future.await; + let _ = tx.send(result); + }); + + rx.recv() + .map_err(|e| format!("Failed to receive async result: {}", e)) + } else { + // No runtime available, create a new one + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| format!("Failed to create runtime: {}", e))? + .block_on(async { Ok(future.await) }) + } +} + +/// Safely execute async code on a separate thread to avoid deadlocks. +/// +/// This is useful when calling async functions from within V8 isolates or +/// other contexts where blocking the current thread would cause deadlocks. +pub fn safe_async_execute_threaded(future: F) -> Result +where + F: Future + Send + 'static, + R: Send + 'static, +{ + let (tx, rx) = std::sync::mpsc::sync_channel(1); + + std::thread::spawn(move || { + // Try using existing runtime first + if let Ok(handle) = Handle::try_current() { + let result = handle.block_on(future); + let _ = tx.send(Ok(result)); + return; + } + + // Create new single-threaded runtime if no runtime exists + let runtime_result = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| format!("Failed to create runtime: {}", e)) + .and_then(|rt| Ok(rt.block_on(future))); + + let result = match runtime_result { + Ok(value) => Ok(value), + Err(e) => Err(format!("Runtime creation failed: {}", e)) + }; + + let _ = tx.send(result); + }); + + rx.recv_timeout(std::time::Duration::from_secs(60)) + .map_err(|e| format!("Threaded async execution timed out: {}", e))? +} + +/// Check if we're currently in an async runtime context. +pub fn is_in_runtime() -> bool { + Handle::try_current().is_ok() +} + +/// Create a new single-threaded runtime safely. +/// +/// Returns an error if a runtime is already active in the current thread. +pub fn create_runtime() -> Result { + if is_in_runtime() { + return Err("Cannot create runtime: already in async context".to_string()); + } + + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| format!("Failed to create runtime: {}", e)) +} + +/// Execute a blocking operation safely within an async context. +/// +/// Uses spawn_blocking to avoid blocking the async executor. +pub async fn safe_blocking(f: F) -> Result +where + F: FnOnce() -> R + Send + 'static, + R: Send + 'static, +{ + tokio::task::spawn_blocking(f) + .await + .map_err(|e| format!("Blocking operation failed: {}", e)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_safe_async_execute_no_runtime() { + let result = safe_async_execute(async { "test".to_string() }); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "test"); + } + + #[tokio::test] + async fn test_safe_async_execute_with_runtime() { + let result = safe_async_execute(async { "test".to_string() }); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "test"); + } + + #[test] + fn test_is_in_runtime() { + // Outside async context + assert!(!is_in_runtime()); + } + + #[tokio::test] + async fn test_is_in_runtime_async() { + // Inside async context + assert!(is_in_runtime()); + } +} \ No newline at end of file diff --git a/src-tauri/src/services/tdlib/manager.rs b/src-tauri/src/services/tdlib/manager.rs index 2a09d7011..3894ebb15 100644 --- a/src-tauri/src/services/tdlib/manager.rs +++ b/src-tauri/src/services/tdlib/manager.rs @@ -250,18 +250,45 @@ impl TdLibManager { fn worker_loop( client_id: i32, state: Arc, - mut request_rx: mpsc::Receiver, + request_rx: mpsc::Receiver, data_dir: PathBuf, ) { log::info!("[tdlib] Worker loop started for client {}", client_id); - // Create a tokio runtime for async operations - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("Failed to create tokio runtime for TDLib worker"); + // Try to use existing runtime first, fallback to creating new one if needed + // This prevents runtime dropping conflicts when spawned from async contexts + let runtime_result = if let Ok(handle) = tokio::runtime::Handle::try_current() { + log::info!("[tdlib] Using existing runtime handle"); + // Use existing runtime by spawning the async work + Ok(handle.block_on(Self::async_worker_loop(client_id, state.clone(), request_rx, data_dir.clone()))) + } else { + log::info!("[tdlib] Creating new runtime for TDLib worker"); + // Only create new runtime if we're not in an async context + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| { + log::error!("[tdlib] Failed to create runtime: {}", e); + e + }) + .map(|rt| { + rt.block_on(Self::async_worker_loop(client_id, state, request_rx, data_dir)) + }) + }; - rt.block_on(async { + match runtime_result { + Ok(_) => log::info!("[tdlib] Worker loop completed for client {}", client_id), + Err(e) => log::error!("[tdlib] Worker loop failed for client {}: {}", client_id, e), + } + } + + /// Async portion of the worker loop - extracted to avoid runtime conflicts + async fn async_worker_loop( + client_id: i32, + state: Arc, + mut request_rx: mpsc::Receiver, + _data_dir: PathBuf, + ) { // Spawn a separate task to poll TDLib updates // This runs in spawn_blocking since tdlib_rs::receive() is a blocking call let state_clone = state.clone(); @@ -327,7 +354,6 @@ impl TdLibManager { // Clean up the poll task poll_handle.abort(); - }); log::info!("[tdlib] Worker loop exited for client {}", client_id); } diff --git a/src/components/settings/panels/TauriCommandsPanel.tsx b/src/components/settings/panels/TauriCommandsPanel.tsx index 9719c495f..48cc4a7a7 100644 --- a/src/components/settings/panels/TauriCommandsPanel.tsx +++ b/src/components/settings/panels/TauriCommandsPanel.tsx @@ -13,6 +13,7 @@ import SettingsHeader from '../components/SettingsHeader'; import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; import SectionCard from './components/SectionCard'; import InputGroup, { Field, CheckboxField } from './components/InputGroup'; +import ValidatedField, { ValidatedSelect } from './components/ValidatedField'; import ActionPanel, { PrimaryButton } from './components/ActionPanel'; import DaemonHealthIndicator from '../../daemon/DaemonHealthIndicator'; import { useDaemonHealth, formatRelativeTime } from '../../../hooks/useDaemonHealth'; @@ -102,6 +103,14 @@ const TauriCommandsPanel = () => { // Loading states const [operationLoading, setOperationLoading] = useState(''); + // Enhanced System Configuration state management + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + const [originalConfig, setOriginalConfig] = useState>({}); + const [fieldErrors, setFieldErrors] = useState>({}); + const [lastSaveTime, setLastSaveTime] = useState(null); + const [validationLoading, setValidationLoading] = useState(false); + const [configLoaded, setConfigLoaded] = useState(false); + const tauriAvailable = useMemo(() => isTauri(), []); const parseOptionalNumber = (value: string): number | null => { if (!value.trim()) { @@ -111,6 +120,147 @@ const TauriCommandsPanel = () => { return Number.isFinite(parsed) ? parsed : null; }; + // Provider configurations for smart defaults + const providerConfigs = useMemo(() => ({ + openai: { + defaultUrl: 'https://api.openai.com/v1', + keyPattern: /^sk-[a-zA-Z0-9]{32,}$/, + models: ['gpt-4', 'gpt-4-turbo', 'gpt-4o', 'gpt-3.5-turbo'] + }, + anthropic: { + defaultUrl: 'https://api.anthropic.com', + keyPattern: /^sk-ant-[a-zA-Z0-9_-]{32,}$/, + models: ['claude-sonnet-4-5-20250929', 'claude-opus-4-6', 'claude-haiku-3-5'] + }, + ollama: { + defaultUrl: 'http://localhost:11434', + keyPattern: null, // No API key required + models: ['llama3', 'llama3:8b', 'codellama', 'mistral', 'phi3'] + }, + groq: { + defaultUrl: 'https://api.groq.com/openai/v1', + keyPattern: /^gsk_[a-zA-Z0-9]{32,}$/, + models: ['llama-3.1-70b-versatile', 'llama-3.1-8b-instant', 'mixtral-8x7b-32768'] + }, + cohere: { + defaultUrl: 'https://api.cohere.ai/v1', + keyPattern: /^[a-zA-Z0-9]{32,}$/, + models: ['command-r', 'command-r-plus', 'command-light'] + } + }), []); + + // Validation functions + const validateApiKey = useCallback((key: string, provider: string): string | null => { + if (!provider || provider === 'none') return null; + if (!key.trim() && provider && provider !== 'none' && provider !== 'ollama') { + return 'API key is required for this provider'; + } + if (key.trim() && provider && providerConfigs[provider as keyof typeof providerConfigs]?.keyPattern) { + const pattern = providerConfigs[provider as keyof typeof providerConfigs].keyPattern; + if (pattern && !pattern.test(key)) { + return `Invalid API key format for ${provider}`; + } + } + return null; + }, [providerConfigs]); + + const validateApiUrl = useCallback((url: string): string | null => { + if (!url.trim()) return null; + try { + const parsedUrl = new URL(url); + if (!['http:', 'https:'].includes(parsedUrl.protocol)) { + return 'URL must use HTTP or HTTPS protocol'; + } + if (parsedUrl.protocol === 'http:' && !parsedUrl.hostname.includes('localhost') && !parsedUrl.hostname.includes('127.0.0.1')) { + return 'HTTP URLs are only allowed for localhost'; + } + return null; + } catch { + return 'Invalid URL format'; + } + }, []); + + const validateProvider = useCallback((provider: string): string | null => { + if (!provider.trim()) return null; + const supportedProviders = Object.keys(providerConfigs); + if (!supportedProviders.includes(provider)) { + return `Unsupported provider. Supported: ${supportedProviders.join(', ')}`; + } + return null; + }, [providerConfigs]); + + const validateModel = useCallback((model: string, provider: string): string | null => { + if (!model.trim() || !provider.trim()) return null; + const config = providerConfigs[provider as keyof typeof providerConfigs]; + if (config && !config.models.includes(model)) { + return `Model not available for ${provider}. Try: ${config.models.slice(0, 3).join(', ')}`; + } + return null; + }, [providerConfigs]); + + const validateTemperature = useCallback((temp: string): string | null => { + if (!temp.trim()) return null; + const value = parseFloat(temp); + if (isNaN(value)) return 'Temperature must be a number'; + if (value < 0 || value > 2) return 'Temperature must be between 0.0 and 2.0'; + return null; + }, []); + + // Real-time validation + const performValidation = useCallback(() => { + const errors: Record = {}; + + const apiKeyError = validateApiKey(apiKey, defaultProvider); + if (apiKeyError) errors.apiKey = apiKeyError; + + const apiUrlError = validateApiUrl(apiUrl); + if (apiUrlError) errors.apiUrl = apiUrlError; + + const providerError = validateProvider(defaultProvider); + if (providerError) errors.defaultProvider = providerError; + + const modelError = validateModel(defaultModel, defaultProvider); + if (modelError) errors.defaultModel = modelError; + + const tempError = validateTemperature(defaultTemp); + if (tempError) errors.defaultTemp = tempError; + + setFieldErrors(errors); + return Object.keys(errors).length === 0; + }, [apiKey, apiUrl, defaultProvider, defaultModel, defaultTemp, validateApiKey, validateApiUrl, validateProvider, validateModel, validateTemperature]); + + // Format timestamp for display + const formatTime = useCallback((date: Date): string => { + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + }, []); + + // Track changes + useEffect(() => { + if (!configLoaded) return; + + const currentConfig = { + api_key: apiKey, + api_url: apiUrl, + default_provider: defaultProvider, + default_model: defaultModel, + default_temperature: defaultTemp, + }; + + const hasChanges = JSON.stringify(currentConfig) !== JSON.stringify(originalConfig); + setHasUnsavedChanges(hasChanges); + + // Perform validation on changes + performValidation(); + }, [apiKey, apiUrl, defaultProvider, defaultModel, defaultTemp, originalConfig, configLoaded, performValidation]); + + // Auto-populate URL based on provider + useEffect(() => { + if (defaultProvider && !apiUrl && providerConfigs[defaultProvider as keyof typeof providerConfigs]) { + const config = providerConfigs[defaultProvider as keyof typeof providerConfigs]; + setApiUrl(config.defaultUrl); + } + }, [defaultProvider, apiUrl, providerConfigs]); + const run = async (fn: () => Promise, operationName?: string) => { setError(''); if (operationName) setOperationLoading(operationName); @@ -144,17 +294,39 @@ const TauriCommandsPanel = () => { const loadConfig = async () => { const response = await runWithResult(() => alphahumanGetConfig(), 'loadConfig'); if (!response) { + setError('Failed to load configuration'); return; } try { const snapshot = response.result; const config = snapshot.config as Record; - setApiKey((config.api_key as string) ?? ''); - setApiUrl((config.api_url as string) ?? ''); - setDefaultProvider((config.default_provider as string) ?? ''); - setDefaultModel((config.default_model as string) ?? ''); - setDefaultTemp(String((config.default_temperature as number) ?? 0.7)); + // Extract model configuration + const modelApiKey = (config.api_key as string) ?? ''; + const modelApiUrl = (config.api_url as string) ?? ''; + const modelProvider = (config.default_provider as string) ?? ''; + const modelModel = (config.default_model as string) ?? ''; + const modelTemp = String((config.default_temperature as number) ?? 0.7); + + // Set state + setApiKey(modelApiKey); + setApiUrl(modelApiUrl); + setDefaultProvider(modelProvider); + setDefaultModel(modelModel); + setDefaultTemp(modelTemp); + + // Store original config for change tracking + const systemConfig = { + api_key: modelApiKey, + api_url: modelApiUrl, + default_provider: modelProvider, + default_model: modelModel, + default_temperature: modelTemp, + }; + setOriginalConfig(systemConfig); + setConfigLoaded(true); + + // Load other configuration sections const tunnel = (config.tunnel as Record) ?? {}; setTunnelProvider((tunnel.provider as string) ?? 'none'); setCloudflareToken(((tunnel.cloudflare as Record)?.token as string) ?? ''); @@ -178,9 +350,12 @@ const TauriCommandsPanel = () => { const runtime = (config.runtime as Record) ?? {}; setRuntimeKind((runtime.kind as string) ?? 'native'); setReasoningEnabled((runtime.reasoning_enabled as boolean) ?? false); + + // Clear any previous errors + setFieldErrors({}); } catch (err) { const message = err instanceof Error ? err.message : String(err); - setError(message); + setError(`Failed to parse configuration: ${message}`); } }; @@ -212,17 +387,122 @@ const TauriCommandsPanel = () => { return { provider: 'none' }; }; - const saveModelSettings = () => - run(() => - alphahumanUpdateModelSettings({ + const saveModelSettings = async () => { + // Pre-save validation + if (!performValidation()) { + setError('Please fix validation errors before saving'); + return; + } + + setError(''); + setOperationLoading('saveModelSettings'); + + try { + const result = await alphahumanUpdateModelSettings({ api_key: apiKey.trim() ? apiKey : null, api_url: apiUrl.trim() ? apiUrl : null, default_provider: defaultProvider.trim() ? defaultProvider : null, default_model: defaultModel.trim() ? defaultModel : null, default_temperature: parseOptionalNumber(defaultTemp), - }), - 'saveModelSettings' - ); + }); + + setOutput(formatJson(result)); + + // Success feedback + const now = new Date(); + setLastSaveTime(now); + setHasUnsavedChanges(false); + + // Update original config + setOriginalConfig({ + api_key: apiKey, + api_url: apiUrl, + default_provider: defaultProvider, + default_model: defaultModel, + default_temperature: defaultTemp, + }); + + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (message.includes('API key')) { + setFieldErrors(prev => ({ ...prev, apiKey: 'Invalid API key or authentication failed' })); + } else if (message.includes('provider')) { + setFieldErrors(prev => ({ ...prev, defaultProvider: 'Provider not supported or misconfigured' })); + } else if (message.includes('model')) { + setFieldErrors(prev => ({ ...prev, defaultModel: 'Model not available for this provider' })); + } + setError(message); + } finally { + setOperationLoading(''); + } + }; + + const testConnection = async () => { + if (!performValidation()) { + setError('Please fix validation errors before testing connection'); + return; + } + + if (!defaultProvider || (!apiKey && defaultProvider !== 'ollama')) { + setError('Provider and API key are required to test connection (except for Ollama)'); + return; + } + + // Check if running in Tauri environment + if (!isTauri()) { + setError('Test Connection is only available in the desktop application'); + return; + } + + setValidationLoading(true); + setError(''); + + try { + // Add timeout to prevent infinite loading + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('Connection test timed out after 30 seconds')), 30000) + ); + + // Test connection by attempting to refresh models with current settings + const result = await Promise.race([ + alphahumanModelsRefresh(defaultProvider, false), + timeoutPromise + ]); + + setOutput(formatJson(result)); + + // If we get here, connection is successful + const successMessage = `Connection test successful for ${defaultProvider}`; + setOutput(prev => prev + '\n\n' + successMessage); + + // Clear any previous connection errors + setFieldErrors(prev => { + const newErrors = { ...prev }; + delete newErrors.apiKey; + delete newErrors.defaultProvider; + return newErrors; + }); + + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error('Test connection error:', err); + + // Set provider-specific errors + if (message.includes('authentication') || message.includes('401')) { + setFieldErrors(prev => ({ ...prev, apiKey: 'Authentication failed - check API key' })); + } else if (message.includes('provider') || message.includes('404')) { + setFieldErrors(prev => ({ ...prev, defaultProvider: 'Provider not found or unavailable' })); + } else if (message.includes('network') || message.includes('timeout')) { + setFieldErrors(prev => ({ ...prev, apiUrl: 'Network error - check API URL and connectivity' })); + } else if (message.includes('Not running in Tauri')) { + setFieldErrors(prev => ({ ...prev, defaultProvider: 'Desktop application required for testing' })); + } + + setError(`Connection test failed: ${message}`); + } finally { + setValidationLoading(false); + } + }; const saveTunnelSettings = () => run(() => alphahumanUpdateTunnelSettings(buildTunnelConfig()), 'saveTunnelSettings'); @@ -368,82 +648,151 @@ const TauriCommandsPanel = () => { icon={} collapsible={true} defaultExpanded={!isCollapsed('system-configuration')} - hasChanges={false} - loading={operationLoading === 'loadConfig' || operationLoading === 'saveModelSettings'} + hasChanges={hasUnsavedChanges} + loading={operationLoading === 'loadConfig' || operationLoading === 'saveModelSettings' || validationLoading} > - - setApiKey(event.target.value)} - /> - - - setApiUrl(event.target.value)} - /> - - - setDefaultProvider(event.target.value)} - /> - - { + setDefaultProvider(value); + // Auto-populate URL when provider changes + if (value && providerConfigs[value as keyof typeof providerConfigs]) { + const config = providerConfigs[value as keyof typeof providerConfigs]; + if (!apiUrl || Object.values(providerConfigs).some(c => c.defaultUrl === apiUrl)) { + setApiUrl(config.defaultUrl); + } + } + }} + options={[ + { value: '', label: 'Select provider...', description: 'Choose your AI service provider' }, + { value: 'openai', label: 'OpenAI', description: 'GPT models with high performance' }, + { value: 'anthropic', label: 'Anthropic', description: 'Claude models with safety focus' }, + { value: 'ollama', label: 'Ollama', description: 'Local models, no API key needed' }, + { value: 'groq', label: 'Groq', description: 'Fast inference with LPU acceleration' }, + { value: 'cohere', label: 'Cohere', description: 'Enterprise-grade language models' }, + ]} + error={fieldErrors.defaultProvider} + required={true} + helpText="Primary AI provider for agent operations. Each provider offers different models with unique capabilities and pricing." + /> + + + + + + - setDefaultModel(event.target.value)} - /> - - ({ + value: model, + label: model, + description: + model.includes('gpt-4') ? 'Advanced reasoning, higher cost' : + model.includes('gpt-3.5') ? 'Fast and economical' : + model.includes('claude') ? 'Excellent analysis and safety' : + model.includes('llama') ? 'Open source, good performance' : + model.includes('mixtral') ? 'Mixture of experts model' : + 'High-quality language model' + })) || []) + ]} + error={fieldErrors.defaultModel} + helpText="Primary language model for agent interactions. Available models are filtered based on your selected provider." + disabled={!defaultProvider} + validation={ + !defaultModel ? 'none' : + fieldErrors.defaultModel ? 'invalid' : + defaultProvider && validateModel(defaultModel, defaultProvider) === null ? 'valid' : 'none' + } + /> + + - setDefaultTemp(event.target.value)} - /> - + value={defaultTemp} + onChange={setDefaultTemp} + error={fieldErrors.defaultTemp} + type="number" + placeholder="0.7" + helpText="Controls randomness in AI responses (0.0-2.0). Lower values (0.1-0.3) for factual tasks, medium (0.5-0.8) for balanced responses, higher (0.8-1.5) for creative tasks." + validation={ + !defaultTemp ? 'none' : + fieldErrors.defaultTemp ? 'invalid' : + validateTemperature(defaultTemp) === null ? 'valid' : 'none' + } + /> - + Load Config + + Test Connection + 0 || !hasUnsavedChanges} > - Save Model Settings + Save Settings diff --git a/src/components/settings/panels/components/ActionPanel.tsx b/src/components/settings/panels/components/ActionPanel.tsx index 8fcf8c8a3..7977c2159 100644 --- a/src/components/settings/panels/components/ActionPanel.tsx +++ b/src/components/settings/panels/components/ActionPanel.tsx @@ -4,7 +4,7 @@ import { CheckIcon, ExclamationTriangleIcon } from '@heroicons/react/24/outline' interface ActionPanelProps { children: React.ReactNode; hasChanges?: boolean; - success?: boolean; + success?: boolean | string; error?: string; className?: string; } @@ -31,7 +31,7 @@ const ActionPanel: React.FC = ({ {success && (
- Operation completed successfully + {typeof success === 'string' ? success : 'Operation completed successfully'}
)} @@ -71,14 +71,16 @@ const PrimaryButton: React.FC = ({ return ( ); }; diff --git a/src/components/settings/panels/components/SectionCard.tsx b/src/components/settings/panels/components/SectionCard.tsx index 37d655bb8..225414aad 100644 --- a/src/components/settings/panels/components/SectionCard.tsx +++ b/src/components/settings/panels/components/SectionCard.tsx @@ -51,8 +51,12 @@ const SectionCard: React.FC = ({ onClick={handleToggle} >
-
- {React.cloneElement(icon, { className: 'h-5 w-5' } as any)} +
+ {loading ? ( +
+ ) : ( + React.cloneElement(icon, { className: 'h-5 w-5' } as any) + )}

{title}

@@ -60,7 +64,7 @@ const SectionCard: React.FC = ({
)} {loading && ( -
+ Loading... )}
diff --git a/src/components/settings/panels/components/ValidatedField.tsx b/src/components/settings/panels/components/ValidatedField.tsx new file mode 100644 index 000000000..dffa5019b --- /dev/null +++ b/src/components/settings/panels/components/ValidatedField.tsx @@ -0,0 +1,192 @@ +import React from 'react'; +import { ExclamationTriangleIcon, CheckCircleIcon } from '@heroicons/react/24/outline'; + +interface ValidatedFieldProps { + label: string; + value: string; + onChange: (value: string) => void; + error?: string; + required?: boolean; + type?: 'text' | 'password' | 'url' | 'number'; + placeholder?: string; + helpText?: string; + className?: string; + fullWidth?: boolean; + validation?: 'valid' | 'invalid' | 'none'; + disabled?: boolean; +} + +const ValidatedField: React.FC = ({ + label, + value, + onChange, + error, + required = false, + type = 'text', + placeholder, + helpText, + className = '', + fullWidth = false, + validation = 'none', + disabled = false +}) => { + const hasError = !!error; + const isValid = validation === 'valid' && !hasError && value.trim() !== ''; + + const inputClasses = ` + w-full px-4 py-3 rounded-lg border transition-all duration-200 + focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-black + disabled:opacity-50 disabled:cursor-not-allowed + ${hasError + ? 'border-coral-500/60 bg-coral-500/10 text-coral-200 placeholder-coral-400/50 focus:border-coral-500 focus:ring-coral-500/30' + : isValid + ? 'border-sage-500/60 bg-sage-500/5 text-white placeholder-stone-400 focus:border-sage-500 focus:ring-sage-500/30' + : 'border-stone-800/60 bg-stone-900/40 text-white placeholder-stone-400 focus:border-primary-500/50 focus:ring-primary-500/30' + } + `; + + return ( + + ); +}; + +interface ValidatedSelectProps { + label: string; + value: string; + onChange: (value: string) => void; + options: Array<{ value: string; label: string; description?: string }>; + error?: string; + required?: boolean; + placeholder?: string; + helpText?: string; + className?: string; + fullWidth?: boolean; + disabled?: boolean; + validation?: 'valid' | 'invalid' | 'none'; +} + +const ValidatedSelect: React.FC = ({ + label, + value, + onChange, + options, + error, + required = false, + placeholder = 'Select option...', + helpText, + className = '', + fullWidth = false, + disabled = false, + validation = 'none' +}) => { + const hasError = !!error; + const isValid = validation === 'valid' && !hasError && value.trim() !== ''; + + const selectClasses = ` + w-full px-4 py-3 rounded-lg border transition-all duration-200 + focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-black + disabled:opacity-50 disabled:cursor-not-allowed + ${hasError + ? 'border-coral-500/60 bg-coral-500/10 text-coral-200 focus:border-coral-500 focus:ring-coral-500/30' + : isValid + ? 'border-sage-500/60 bg-sage-500/5 text-white focus:border-sage-500 focus:ring-sage-500/30' + : 'border-stone-800/60 bg-stone-900/40 text-white focus:border-primary-500/50 focus:ring-primary-500/30' + } + `; + + return ( + + ); +}; + +export default ValidatedField; +export { ValidatedSelect }; \ No newline at end of file From 694dcfe3ccf11ca5ab15cdcda8bc989eb7d40536 Mon Sep 17 00:00:00 2001 From: Mega Mind <146339422+M3gA-Mind@users.noreply.github.com> Date: Thu, 26 Feb 2026 21:28:21 +0530 Subject: [PATCH 6/9] Feat/openclaw work - #131 (#151) * feat: add initial project structure and documentation - Introduced the GNU General Public License (GPL) v3 in LICENSE file. - Added MCP configuration in .claude/mcp.json for server integration. - Created architecture documentation in docs/ARCHITECTURE.md outlining the platform's design and components. - Defined MVP specifications in docs/MVP.md for the Telegram-based Agent Assistant. - Established API reference for team management in docs/teams-api-reference.md. - Set up basic HTML structure in public/index.html and added logo image in public/logo.png. * feat: add initial project documentation and HTML structure - Introduced CODE_OF_CONDUCT.md to establish community guidelines and standards for behavior. - Created CONTRIBUTING.md to outline contribution process, development setup, and project conventions. - Added SECURITY.md to define the security policy, supported versions, and reporting procedures for vulnerabilities. - Established basic HTML structure in index.html for the application interface. * chore: remove hello-python skill files - Deleted skill.json and skill.py files for the Hello Python example runtime skill, as they are no longer needed in the project. * feat: port tinyhuman agent runtime from ZeroClaw into Tauri backend Port daemon supervisor, health registry, security (policy, secrets, audit, pairing), agent traits, and config modules from ZeroClaw (MIT) into a new tinyhuman/ module under src-tauri/src/. The daemon auto-starts on desktop and shuts down gracefully on app exit via CancellationToken. - health: global HealthRegistry with component tracking and JSON snapshots - security/policy: SecurityPolicy with command validation, risk levels, rate limiting - security/secrets: ChaCha20-Poly1305 SecretStore with legacy XOR migration - security/audit: AuditLogger with JSON-line events and log rotation - security/pairing: PairingGuard with brute-force protection and SHA-256 hashing - security/traits: Sandbox trait + NoopSandbox - config: minimal DaemonConfig with autonomy, reliability, secrets, audit sub-configs - daemon: supervisor with health state writer emitting Tauri events - agent/traits: Provider, Tool, Memory, Observer, RuntimeAdapter traits + Noop impls - commands/tinyhuman: Tauri commands for health, security policy, encrypt/decrypt - 185 inline unit tests across all modules - README updated with custom inference/tunneling/memory positioning Co-Authored-By: Claude Opus 4.6 * feat: update README to reflect AlphaHuman Mk1 branding and enhanced description - Changed project title to "AlphaHuman Mk1" for clarity. - Revised project description to emphasize user-friendly AI capabilities and the use of the Neocortex Mk1 model. - Removed outdated sections on custom inference, tunneling, and memory, streamlining the content for better readability. * update readme * Port zeroclaw runtime into tinyhuman * Replace CLI mentions with UI language * Split gateway module into smaller units * Split channels and config schema modules * Fix tinyhuman build, tests, and tunnel integration * feat(tinyhuman): add missing modules and ui-friendly services * refactor: rename tinyhuman to alphahuman * chore: remove bottom text from Welcome component * feat(settings): add tauri command console * feat(daemon): enhance daemon mode handling and integrate rustls with ring feature * feat(settings): implement comprehensive configuration management in TauriCommandsPanel * refactor(TauriCommandsPanel): streamline error handling and enhance async function usage * feat(settings): add skill management functionality to TauriCommandsPanel * style(TauriCommandsPanel): update input styles for improved readability and user experience * feat(settings): add Skills and Agent Chat panels with navigation and integration management * feat(settings): implement browser access management in SkillsPanel and enhance AgentChatPanel with local storage functionality * Implement inference API integration in Conversations component - Added inference API service to handle chat completions and model management. - Updated Conversations component to fetch available models on mount and allow model selection. - Enhanced message sending functionality to utilize the inference API for generating responses. - Introduced state management for loading models and handling errors during API calls. - Refactored optimistic message handling and send status management for improved user experience. --------- Co-authored-by: Steven Enamakel Co-authored-by: Claude Opus 4.6 Co-authored-by: Steven Enamakel <31011319+senamakel@users.noreply.github.com> --- .../settings/panels/TauriCommandsPanel.tsx | 646 ++++++------- src/pages/Conversations.tsx | 110 ++- src/services/api/inferenceApi.ts | 99 ++ src/services/api/tunnelsApi.ts | 70 ++ src/store/threadSlice.ts | 11 + yarn.lock | 886 +++++++++++------- 6 files changed, 1150 insertions(+), 672 deletions(-) create mode 100644 src/services/api/inferenceApi.ts create mode 100644 src/services/api/tunnelsApi.ts diff --git a/src/components/settings/panels/TauriCommandsPanel.tsx b/src/components/settings/panels/TauriCommandsPanel.tsx index 48cc4a7a7..a5abc02e0 100644 --- a/src/components/settings/panels/TauriCommandsPanel.tsx +++ b/src/components/settings/panels/TauriCommandsPanel.tsx @@ -1,13 +1,13 @@ -import { useMemo, useState } from 'react'; import { + ChatBubbleLeftRightIcon, CogIcon, CpuChipIcon, - ShieldCheckIcon, - ServerIcon, - WrenchScrewdriverIcon, - ChatBubbleLeftRightIcon, DocumentTextIcon, + ServerIcon, + ShieldCheckIcon, + WrenchScrewdriverIcon, } from '@heroicons/react/24/outline'; +import { useMemo, useState } from 'react'; import SettingsHeader from '../components/SettingsHeader'; import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; @@ -22,30 +22,35 @@ import { alphahumanDecryptSecret, alphahumanDoctorModels, alphahumanDoctorReport, - alphahumanGetIntegrationInfo, + alphahumanEncryptSecret, alphahumanGetConfig, + alphahumanGetIntegrationInfo, alphahumanHardwareDiscover, alphahumanHardwareIntrospect, alphahumanListIntegrations, alphahumanMigrateOpenclaw, alphahumanModelsRefresh, + alphahumanServiceInstall, + alphahumanServiceStatus, + alphahumanServiceUninstall, alphahumanUpdateGatewaySettings, alphahumanUpdateMemorySettings, alphahumanUpdateModelSettings, alphahumanUpdateRuntimeSettings, alphahumanUpdateTunnelSettings, - alphahumanServiceInstall, - alphahumanServiceStatus, - alphahumanServiceUninstall, - alphahumanEncryptSecret, isTauri, - TunnelConfig, runtimeDisableSkill, runtimeEnableSkill, runtimeIsSkillEnabled, runtimeListSkills, SkillSnapshot, + TunnelConfig, } from '../../../utils/tauriCommands'; +import SettingsHeader from '../components/SettingsHeader'; +import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; +import ActionPanel, { PrimaryButton } from './components/ActionPanel'; +import InputGroup, { CheckboxField, Field } from './components/InputGroup'; +import SectionCard from './components/SectionCard'; const formatJson = (value: unknown) => JSON.stringify(value, null, 2); @@ -55,7 +60,14 @@ const TauriCommandsPanel = () => { // View mode removed - always show all sections const [expandedSections] = useState>( - new Set(['system-configuration', 'runtime-execution', 'security-data', 'network-infrastructure', 'development-operations', 'interactive-tools']) + new Set([ + 'system-configuration', + 'runtime-execution', + 'security-data', + 'network-infrastructure', + 'development-operations', + 'interactive-tools', + ]) ); // Output and error states @@ -90,9 +102,7 @@ const TauriCommandsPanel = () => { const [embeddingDims, setEmbeddingDims] = useState('1536'); const [runtimeKind, setRuntimeKind] = useState('native'); const [reasoningEnabled, setReasoningEnabled] = useState(false); - const [skills, setSkills] = useState< - Array<{ snapshot: SkillSnapshot; enabled: boolean }> - >([]); + const [skills, setSkills] = useState>([]); const [skillsLoading, setSkillsLoading] = useState(false); const [chatInput, setChatInput] = useState(''); const [chatProvider, setChatProvider] = useState(''); @@ -275,7 +285,10 @@ const TauriCommandsPanel = () => { } }; - const runWithResult = async (fn: () => Promise, operationName?: string): Promise => { + const runWithResult = async ( + fn: () => Promise, + operationName?: string + ): Promise => { setError(''); if (operationName) setOperationLoading(operationName); try { @@ -331,7 +344,9 @@ const TauriCommandsPanel = () => { setTunnelProvider((tunnel.provider as string) ?? 'none'); setCloudflareToken(((tunnel.cloudflare as Record)?.token as string) ?? ''); setNgrokToken(((tunnel.ngrok as Record)?.auth_token as string) ?? ''); - setTailscaleHostname(((tunnel.tailscale as Record)?.hostname as string) ?? ''); + setTailscaleHostname( + ((tunnel.tailscale as Record)?.hostname as string) ?? '' + ); setCustomCommand(((tunnel.custom as Record)?.start_command as string) ?? ''); const gateway = (config.gateway as Record) ?? {}; @@ -361,28 +376,16 @@ const TauriCommandsPanel = () => { const buildTunnelConfig = (): TunnelConfig => { if (tunnelProvider === 'cloudflare') { - return { - provider: 'cloudflare', - cloudflare: { token: cloudflareToken }, - }; + return { provider: 'cloudflare', cloudflare: { token: cloudflareToken } }; } if (tunnelProvider === 'ngrok') { - return { - provider: 'ngrok', - ngrok: { auth_token: ngrokToken }, - }; + return { provider: 'ngrok', ngrok: { auth_token: ngrokToken } }; } if (tunnelProvider === 'tailscale') { - return { - provider: 'tailscale', - tailscale: { hostname: tailscaleHostname || null }, - }; + return { provider: 'tailscale', tailscale: { hostname: tailscaleHostname || null } }; } if (tunnelProvider === 'custom') { - return { - provider: 'custom', - custom: { start_command: customCommand }, - }; + return { provider: 'custom', custom: { start_command: customCommand } }; } return { provider: 'none' }; }; @@ -504,37 +507,41 @@ const TauriCommandsPanel = () => { } }; - const saveTunnelSettings = () => run(() => alphahumanUpdateTunnelSettings(buildTunnelConfig()), 'saveTunnelSettings'); + const saveTunnelSettings = () => + run(() => alphahumanUpdateTunnelSettings(buildTunnelConfig()), 'saveTunnelSettings'); const saveGatewaySettings = () => - run(() => - alphahumanUpdateGatewaySettings({ - host: gatewayHost.trim() ? gatewayHost : null, - port: parseOptionalNumber(gatewayPort), - require_pairing: gatewayPairing, - allow_public_bind: gatewayPublic, - }), + run( + () => + alphahumanUpdateGatewaySettings({ + host: gatewayHost.trim() ? gatewayHost : null, + port: parseOptionalNumber(gatewayPort), + require_pairing: gatewayPairing, + allow_public_bind: gatewayPublic, + }), 'saveGatewaySettings' ); const saveMemorySettings = () => - run(() => - alphahumanUpdateMemorySettings({ - backend: memoryBackend.trim() ? memoryBackend : null, - auto_save: memoryAutoSave, - embedding_provider: embeddingProvider.trim() ? embeddingProvider : null, - embedding_model: embeddingModel.trim() ? embeddingModel : null, - embedding_dimensions: parseOptionalNumber(embeddingDims), - }), + run( + () => + alphahumanUpdateMemorySettings({ + backend: memoryBackend.trim() ? memoryBackend : null, + auto_save: memoryAutoSave, + embedding_provider: embeddingProvider.trim() ? embeddingProvider : null, + embedding_model: embeddingModel.trim() ? embeddingModel : null, + embedding_dimensions: parseOptionalNumber(embeddingDims), + }), 'saveMemorySettings' ); const saveRuntimeSettings = () => - run(() => - alphahumanUpdateRuntimeSettings({ - kind: runtimeKind.trim() ? runtimeKind : null, - reasoning_enabled: reasoningEnabled, - }), + run( + () => + alphahumanUpdateRuntimeSettings({ + kind: runtimeKind.trim() ? runtimeKind : null, + reasoning_enabled: reasoningEnabled, + }), 'saveRuntimeSettings' ); @@ -543,7 +550,7 @@ const TauriCommandsPanel = () => { try { const snapshots = await runtimeListSkills(); const enriched = await Promise.all( - snapshots.map(async (snapshot) => ({ + snapshots.map(async snapshot => ({ snapshot, enabled: await runtimeIsSkillEnabled(snapshot.skill_id), })) @@ -564,11 +571,9 @@ const TauriCommandsPanel = () => { } else { await run(() => runtimeDisableSkill(skillId), 'disableSkill'); } - setSkills((prev) => - prev.map((item) => - item.snapshot.skill_id === skillId - ? { ...item, enabled: nextEnabled } - : item + setSkills(prev => + prev.map(item => + item.snapshot.skill_id === skillId ? { ...item, enabled: nextEnabled } : item ) ); }; @@ -578,19 +583,20 @@ const TauriCommandsPanel = () => { return; } const userMessage = chatInput.trim(); - setChatLog((prev) => [...prev, { role: 'user', text: userMessage }]); + setChatLog(prev => [...prev, { role: 'user', text: userMessage }]); setChatInput(''); - const response = await runWithResult(() => - alphahumanAgentChat( - userMessage, - chatProvider.trim() ? chatProvider : undefined, - chatModel.trim() ? chatModel : undefined, - parseOptionalNumber(chatTemperature) ?? undefined - ), + const response = await runWithResult( + () => + alphahumanAgentChat( + userMessage, + chatProvider.trim() ? chatProvider : undefined, + chatModel.trim() ? chatModel : undefined, + parseOptionalNumber(chatTemperature) ?? undefined + ), 'sendChat' ); if (response) { - setChatLog((prev) => [...prev, { role: 'agent', text: response.result }]); + setChatLog(prev => [...prev, { role: 'agent', text: response.result }]); } }; @@ -616,11 +622,7 @@ const TauriCommandsPanel = () => { return (
- +
{!tauriAvailable && ( @@ -807,75 +809,64 @@ const TauriCommandsPanel = () => { collapsible={true} defaultExpanded={!isCollapsed('runtime-execution')} hasChanges={false} - loading={operationLoading === 'saveRuntimeSettings' || skillsLoading} - > - - - setRuntimeKind(event.target.value)} + loading={operationLoading === 'saveRuntimeSettings' || skillsLoading}> + + + setRuntimeKind(event.target.value)} + /> + + - - - + - - - Save Runtime Settings - - - {skillsLoading ? 'Loading Skills…' : 'Load Skills'} - - + + + Save Runtime Settings + + + {skillsLoading ? 'Loading Skills…' : 'Load Skills'} + + - {skills.length > 0 && ( -
-
Skills
-
- {skills.map((item) => ( -
-
-
{item.snapshot.name}
-
- {item.snapshot.skill_id} + {skills.length > 0 && ( +
+
Skills
+
+ {skills.map(item => ( +
+
+
{item.snapshot.name}
+
+ {item.snapshot.skill_id} +
+ toggleSkill(item.snapshot.skill_id, checked)} + className="text-xs ml-4 flex-shrink-0" + />
- - toggleSkill(item.snapshot.skill_id, checked) - } - className="text-xs ml-4 flex-shrink-0" - /> -
- ))} + ))} +
-
- )} + )}
@@ -1006,117 +997,121 @@ const TauriCommandsPanel = () => { collapsible={true} defaultExpanded={!isCollapsed('security-data')} hasChanges={false} - loading={operationLoading?.includes('Secret') || operationLoading?.includes('Models') || operationLoading?.includes('Integration')} - > -
- - -