diff --git a/.gitignore b/.gitignore index bb66f93d6..4d6c60b6a 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,5 @@ src-tauri/runtime-skill-* .ruff_cache .kotlin .cargo + +CLAUDE.local.md diff --git a/CLAUDE.md b/CLAUDE.md index 2bf8b6e7d..459ee7209 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -352,6 +352,7 @@ Key updates from recent commits (cd9ebcd to current): ## Key Patterns - **Code Quality**: ESLint and Prettier enforce code standards with Husky hooks. Use type-only imports (`import type`) and consolidate imports from same modules. +- **No dynamic imports**: All imports must be static `import` statements at the top of the file. Do not use `await import()` or `import().then()` inside functions or code blocks. Use try/catch around Tauri API calls for non-Tauri environments instead. - **No localStorage**: Avoid `localStorage` and `sessionStorage`; use Redux (and persist) for app state. Remove any direct usage when working on affected code. - **AI System Integration**: Use `src/lib/ai/` for memory management, constitution loading, entity queries, and session capture. AI providers abstracted through interface pattern. - **V8 Skills Runtime**: Skills execute in V8 JavaScript engine on desktop platforms. Use `SkillProvider` for GitHub sync, `SkillsGrid` for management interface, and Rust runtime commands for lifecycle management. Platform filtering ensures skills only run on supported platforms. @@ -377,7 +378,7 @@ Key updates from recent commits (cd9ebcd to current): - **macOS deep links**: Require `.app` bundle (not `tauri dev`). Clear WebKit caches when debugging stale content: `rm -rf ~/Library/WebKit/com.alphahuman.app ~/Library/Caches/com.alphahuman.app` - **Cargo caching**: May serve stale frontend assets on incremental builds. Run `cargo clean --manifest-path src-tauri/Cargo.toml` if the app shows outdated UI. -- **`window.__TAURI__`**: Not available at module load time. Use dynamic `import()` and try/catch for Tauri plugin calls. +- **`window.__TAURI__`**: Not available at module load time. Use static imports and try/catch around Tauri API calls (not around imports). - **Android background services**: RuntimeService requires notification permissions (API 33+) and foreground service type specification (API 34+). Use Android logging (`android_logger`) for debug output in logcat. - **V8 runtime limitations**: V8 engine is desktop-only. Android skills should use lightweight alternatives or server-side execution patterns. - **Socket connections**: Persistent Socket.io connections via Rust backend work better than WebView-based connections on mobile platforms. diff --git a/TODO.md b/TODO.md index 4e74fe9aa..20a9e9785 100644 --- a/TODO.md +++ b/TODO.md @@ -7,3 +7,8 @@ todo - integrate the payments flow properly skip the connect account page and goto the home page + +[] - get android version out +[] - get background proceeses done +[] - get ai to summarize messages in the device and upload to the cloud +[] - get all the remaining skills working diff --git a/skills b/skills index 311273808..9dc185554 160000 --- a/skills +++ b/skills @@ -1 +1 @@ -Subproject commit 31127380830e2452226655fc30a64ae708586b55 +Subproject commit 9dc1855541dcf952e1a19bd8b67033c51d4ce40a diff --git a/src-tauri/src/commands/model.rs b/src-tauri/src/commands/model.rs index 336e02961..5ad806833 100644 --- a/src-tauri/src/commands/model.rs +++ b/src-tauri/src/commands/model.rs @@ -21,6 +21,8 @@ pub struct ModelStatusResponse { pub loaded: bool, /// Whether the model is currently being loaded or downloaded. pub loading: bool, + /// Whether the model file has been downloaded to disk. + pub downloaded: bool, /// Download progress (0.0 to 1.0) if downloading. pub download_progress: Option, /// Error message if loading failed. @@ -135,6 +137,7 @@ pub fn model_get_status() -> ModelStatusResponse { available: status.available, loaded: status.loaded, loading: status.loading, + downloaded: status.downloaded, download_progress: status.download_progress, error: status.error, model_path: status.model_path, @@ -149,6 +152,7 @@ pub fn model_get_status() -> ModelStatusResponse { available: true, loaded: false, loading: false, + downloaded: false, download_progress: None, error: Some("MediaPipe LLM: Download a model to get started".to_string()), model_path: None, @@ -161,6 +165,7 @@ pub fn model_get_status() -> ModelStatusResponse { available: false, loaded: false, loading: false, + downloaded: false, download_progress: None, error: Some("Model not available on iOS".to_string()), model_path: None, @@ -248,6 +253,28 @@ pub async fn model_summarize(text: String, max_tokens: Option) -> Result Result<(), String> { + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + crate::services::llama::LLAMA_MANAGER + .download_only() + .await + } + + #[cfg(target_os = "android")] + { + Err("Use Android model manager to download models".to_string()) + } + + #[cfg(target_os = "ios")] + { + Err("Model not available on iOS".to_string()) + } +} + /// Unload the model from memory to free resources. #[tauri::command] pub fn model_unload() -> Result<(), String> { diff --git a/src-tauri/src/commands/runtime.rs b/src-tauri/src/commands/runtime.rs index a92190e48..98482c287 100644 --- a/src-tauri/src/commands/runtime.rs +++ b/src-tauri/src/commands/runtime.rs @@ -65,10 +65,16 @@ mod desktop { "autoStart": m.auto_start, "version": m.version, "description": m.description, - "setup": m.setup.as_ref().map(|s| serde_json::json!({ - "required": s.required, - "label": s.label, - })), + "setup": m.setup.as_ref().map(|s| { + let mut obj = serde_json::json!({ + "required": s.required, + "label": s.label, + }); + if let Some(oauth) = &s.oauth { + obj["oauth"] = oauth.clone(); + } + obj + }), "platforms": m.platforms, }) }) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a57696dc0..2e5f8fcfb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -461,6 +461,7 @@ pub fn run() { model_is_available, model_get_status, model_ensure_loaded, + model_start_download, model_generate, model_summarize, model_unload, @@ -553,6 +554,7 @@ pub fn run() { model_is_available, model_get_status, model_ensure_loaded, + model_start_download, model_generate, model_summarize, model_unload, diff --git a/src-tauri/src/runtime/manifest.rs b/src-tauri/src/runtime/manifest.rs index 9897cbb17..b3e3d7833 100644 --- a/src-tauri/src/runtime/manifest.rs +++ b/src-tauri/src/runtime/manifest.rs @@ -13,6 +13,8 @@ pub struct SkillSetup { #[serde(default)] pub required: bool, pub label: Option, + /// OAuth configuration (provider, scopes, apiBaseUrl). + pub oauth: Option, } /// Raw manifest as it appears on disk. diff --git a/src-tauri/src/runtime/qjs_skill_instance.rs b/src-tauri/src/runtime/qjs_skill_instance.rs index a000c6857..12f50d65f 100644 --- a/src-tauri/src/runtime/qjs_skill_instance.rs +++ b/src-tauri/src/runtime/qjs_skill_instance.rs @@ -193,8 +193,8 @@ impl QjsSkillInstance { // Load bootstrap let bootstrap_code = include_str!("../services/tdlib_v8/bootstrap.js"); if let Err(e) = js_ctx.eval::(bootstrap_code) { - let err_str = format!("Bootstrap failed: {e}"); - return Err(err_str); + let detail = format_js_exception(&js_ctx, &e); + return Err(format!("Bootstrap failed: {detail}")); } // Set skill ID @@ -203,12 +203,14 @@ impl QjsSkillInstance { skill_id.replace('"', r#"\""#) ); if let Err(e) = js_ctx.eval::(bridge_code.as_bytes()) { - return Err(format!("Skill init failed: {e}")); + let detail = format_js_exception(&js_ctx, &e); + return Err(format!("Skill init failed: {detail}")); } // Execute the skill's entry point if let Err(e) = js_ctx.eval::(js_source.as_bytes()) { - return Err(format!("Skill load failed: {e}")); + let detail = format_js_exception(&js_ctx, &e); + return Err(format!("Skill load failed: {detail}")); } // Extract tool definitions @@ -410,8 +412,34 @@ async fn handle_message( let _ = reply.send(result); } SkillMessage::Rpc { method, params, reply } => { - let args = serde_json::json!({ "method": method, "params": params }); - let result = handle_js_call(ctx, "onRpc", &args.to_string()).await; + let result = match method.as_str() { + "oauth/complete" => { + // Set credential on the oauth bridge, then call onOAuthComplete + let set_cred_code = format!( + "if (typeof globalThis.oauth !== 'undefined' && globalThis.oauth.__setCredential) {{ globalThis.oauth.__setCredential({}); }}", + serde_json::to_string(¶ms).unwrap_or_else(|_| "null".to_string()) + ); + ctx.with(|js_ctx| { + let _ = js_ctx.eval::(set_cred_code.as_bytes()); + }).await; + let params_str = serde_json::to_string(¶ms).unwrap_or_else(|_| "{}".to_string()); + handle_js_call(ctx, "onOAuthComplete", ¶ms_str).await + } + "oauth/revoked" => { + // Clear credential on the oauth bridge, then call onOAuthRevoked + let clear_code = "if (typeof globalThis.oauth !== 'undefined' && globalThis.oauth.__setCredential) { globalThis.oauth.__setCredential(null); }"; + ctx.with(|js_ctx| { + let _ = js_ctx.eval::(clear_code.as_bytes()); + }).await; + let params_str = serde_json::to_string(¶ms).unwrap_or_else(|_| "{}".to_string()); + handle_js_void_call(ctx, "onOAuthRevoked", ¶ms_str).await + .map(|()| serde_json::json!({ "ok": true })) + } + _ => { + let args = serde_json::json!({ "method": method, "params": params }); + handle_js_call(ctx, "onRpc", &args.to_string()).await + } + }; let _ = reply.send(result); } } @@ -422,6 +450,41 @@ async fn handle_message( // Helper Functions // ============================================================================ +/// Extract a human-readable error message from a QuickJS exception. +/// When rquickjs returns `Error::Exception`, the actual JS error value +/// is stored in the context and retrieved with `Ctx::catch()`. +fn format_js_exception(js_ctx: &rquickjs::Ctx<'_>, err: &rquickjs::Error) -> String { + if !err.is_exception() { + return format!("{err}"); + } + + let exception = js_ctx.catch(); + + // Try to get .message and .stack from the exception object + if let Some(obj) = exception.as_object() { + let message: String = obj + .get::<_, String>("message") + .unwrap_or_default(); + let stack: String = obj + .get::<_, String>("stack") + .unwrap_or_default(); + + if !message.is_empty() { + if !stack.is_empty() { + return format!("{message}\n{stack}"); + } + return message; + } + } + + // Fallback: try to stringify the exception value + if let Ok(s) = exception.get::() { + return s; + } + + format!("{err}") +} + /// Call a lifecycle function on the skill object. /// Looks for the skill at globalThis.__skill.default first, then falls back to globalThis. async fn call_lifecycle(ctx: &rquickjs::AsyncContext, name: &str) -> Result<(), String> { @@ -440,7 +503,10 @@ async fn call_lifecycle(ctx: &rquickjs::AsyncContext, name: &str) -> Result<(), }})()"# ); js_ctx.eval::(code.as_bytes()) - .map_err(|e| format!("{name}() failed: {e}"))?; + .map_err(|e| { + let detail = format_js_exception(&js_ctx, &e); + format!("{name}() failed: {detail}") + })?; Ok(()) }).await } @@ -517,7 +583,10 @@ async fn handle_tool_call( match js_ctx.eval::(code.as_bytes()) { Ok(s) => Ok(s), - Err(e) => Err(format!("Tool execution failed: {e}")) + Err(e) => { + let detail = format_js_exception(&js_ctx, &e); + Err(format!("Tool execution failed: {detail}")) + } } }).await?; @@ -611,7 +680,10 @@ async fn handle_js_call( match js_ctx.eval::(code.as_bytes()) { Ok(s) => Ok(s), - Err(e) => Err(format!("{fn_name}() failed: {e}")) + Err(e) => { + let detail = format_js_exception(&js_ctx, &e); + Err(format!("{fn_name}() failed: {detail}")) + } } }).await?; diff --git a/src-tauri/src/services/llama/manager.rs b/src-tauri/src/services/llama/manager.rs index 20f8de3bf..720e45035 100644 --- a/src-tauri/src/services/llama/manager.rs +++ b/src-tauri/src/services/llama/manager.rs @@ -3,6 +3,7 @@ //! Provides: //! - Lazy model loading on first use //! - Automatic model download if not present +//! - Resumable downloads with HTTP Range support //! - Thread-safe inference with dedicated thread pool //! - Generate and summarize API for skills @@ -33,6 +34,13 @@ const MODEL_URL: &str = "https://huggingface.co/bartowski/google_gemma-3n-E2B-it /// Expected SHA256 hash for model verification (first 16 chars for quick check) const MODEL_SHA256_PREFIX: &str = ""; // Will be verified on first download +/// Sidecar metadata for resumable downloads. +#[derive(Serialize, Deserialize)] +struct DownloadMeta { + total_size: u64, + url: String, +} + /// Status of the local model. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -43,6 +51,8 @@ pub struct ModelStatus { pub loaded: bool, /// Whether the model is currently being loaded or downloaded. pub loading: bool, + /// Whether the model file has been downloaded to disk. + pub downloaded: bool, /// Download progress (0.0 to 1.0) if downloading. pub download_progress: Option, /// Error message if loading failed. @@ -57,6 +67,7 @@ impl Default for ModelStatus { available: true, loaded: false, loading: false, + downloaded: false, download_progress: None, error: None, model_path: None, @@ -109,6 +120,8 @@ pub struct LlamaManager { status: RwLock, /// Lock to prevent concurrent loading. loading: AtomicBool, + /// Lock to prevent concurrent downloads. + downloading: AtomicBool, } impl LlamaManager { @@ -119,6 +132,7 @@ impl LlamaManager { model: RwLock::new(None), status: RwLock::new(ModelStatus::default()), loading: AtomicBool::new(false), + downloading: AtomicBool::new(false), } } @@ -127,14 +141,19 @@ impl LlamaManager { log::info!("[llama] Setting data dir: {:?}", dir); *self.data_dir.write() = dir.clone(); - // Update status with model path + // Update status with model path and downloaded flag let model_path = dir.join(MODEL_FILENAME); - self.status.write().model_path = Some(model_path.to_string_lossy().to_string()); + let mut status = self.status.write(); + status.model_path = Some(model_path.to_string_lossy().to_string()); + status.downloaded = model_path.exists(); } /// Get the current model status. pub fn get_status(&self) -> ModelStatus { - self.status.read().clone() + let mut status = self.status.read().clone(); + // Dynamically check downloaded state + status.downloaded = self.model_exists(); + status } /// Get the model file path. @@ -142,14 +161,87 @@ impl LlamaManager { self.data_dir.read().join(MODEL_FILENAME) } + /// Get the temp download file path. + fn download_path(&self) -> PathBuf { + self.model_path().with_extension("gguf.download") + } + + /// Get the download metadata sidecar file path. + fn meta_path(&self) -> PathBuf { + self.model_path().with_extension("gguf.download.meta") + } + /// Check if the model file exists. fn model_exists(&self) -> bool { self.model_path().exists() } - /// Download the model from HuggingFace. + /// Download the model file without loading into memory. + /// Safe to call from the Welcome page (pre-auth). + pub async fn download_only(&self) -> Result<(), String> { + // Already downloaded + if self.model_exists() { + log::info!("[llama] Model already downloaded, skipping"); + return Ok(()); + } + + self.download_model().await + } + + /// Download the model from HuggingFace with resume support. async fn download_model(&self) -> Result<(), String> { + // Already downloaded + if self.model_exists() { + return Ok(()); + } + + // Prevent concurrent downloads — second caller waits + if self.downloading.swap(true, Ordering::SeqCst) { + log::info!("[llama] Download already in progress, waiting..."); + while self.downloading.load(Ordering::SeqCst) { + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + } + // Check if the other download succeeded + if self.model_exists() { + return Ok(()); + } + return Err("Download failed (another attempt)".to_string()); + } + + // Update status + { + let mut status = self.status.write(); + status.loading = true; + status.error = None; + } + + let result = self.download_model_inner().await; + + // Update status based on result + { + let mut status = self.status.write(); + status.loading = false; + status.download_progress = None; + match &result { + Ok(_) => { + status.downloaded = true; + status.error = None; + } + Err(e) => { + status.error = Some(e.clone()); + } + } + } + + self.downloading.store(false, Ordering::SeqCst); + result + } + + /// Inner download logic with HTTP Range resume support. + async fn download_model_inner(&self) -> Result<(), String> { let model_path = self.model_path(); + let mut download_path = self.download_path(); + let mut meta_path = self.meta_path(); // Ensure parent directory exists if let Some(parent) = model_path.parent() { @@ -157,33 +249,120 @@ impl LlamaManager { .map_err(|e| format!("Failed to create model directory: {}", e))?; } - log::info!("[llama] Downloading model from {}", MODEL_URL); - self.status.write().download_progress = Some(0.0); + // Check for existing partial download + let (mut existing_size, mut resume) = + self.check_resume_state(&download_path, &meta_path); - // Use reqwest for download let client = reqwest::Client::new(); - let response = client - .get(MODEL_URL) + + // Build request — add Range header if resuming + let mut request = client.get(MODEL_URL); + if resume && existing_size > 0 { + log::info!( + "[llama] Resuming download from byte {}", + existing_size + ); + request = request.header("Range", format!("bytes={}-", existing_size)); + } + + let mut response = request .send() .await .map_err(|e| format!("Failed to start download: {}", e))?; - if !response.status().is_success() { - return Err(format!("Download failed with status: {}", response.status())); + let mut status_code = response.status().as_u16(); + + // If we got 206 but the file size changed, delete temp files and start fresh + if status_code == 206 { + let total = self.parse_content_range_total(&response, existing_size); + let meta_total = self.read_meta_total(&meta_path); + if let Some(mt) = meta_total { + if mt != total { + log::warn!( + "[llama] Remote file size changed ({} vs {}), restarting download", + mt, + total + ); + let _ = std::fs::remove_file(&download_path); + let _ = std::fs::remove_file(&meta_path); + + // Re-issue a fresh GET (no Range header) + response = client + .get(MODEL_URL) + .send() + .await + .map_err(|e| format!("Failed to restart download: {}", e))?; + status_code = response.status().as_u16(); + existing_size = 0; + let _ = resume; // no longer resuming + + // Re-read paths (unchanged, but reset state) + download_path = self.download_path(); + meta_path = self.meta_path(); + } + } } - let total_size = response.content_length().unwrap_or(0); - let mut downloaded: u64 = 0; + // Determine download mode based on response + let (mut file, start_offset, total_size) = match status_code { + 206 => { + // Partial Content — resume accepted + let total = self.parse_content_range_total(&response, existing_size); - // Create temp file for download - let temp_path = model_path.with_extension("download"); - let mut file = std::fs::File::create(&temp_path) - .map_err(|e| format!("Failed to create temp file: {}", e))?; + let file = std::fs::OpenOptions::new() + .append(true) + .open(&download_path) + .map_err(|e| format!("Failed to open temp file for append: {}", e))?; + + log::info!( + "[llama] Resuming download: {}/{} bytes", + existing_size, + total + ); + + (file, existing_size, total) + } + 200 => { + // OK — server ignored Range or fresh start + let total = response.content_length().unwrap_or(0); + + // Start fresh — truncate any partial download + let file = std::fs::File::create(&download_path) + .map_err(|e| format!("Failed to create temp file: {}", e))?; + + // Write meta for future resume + self.write_meta(&meta_path, total)?; + + log::info!("[llama] Starting fresh download: {} bytes", total); + + (file, 0u64, total) + } + 416 => { + // Range Not Satisfiable — file is already complete + log::info!("[llama] Download already complete (416), renaming"); + let _ = std::fs::remove_file(&meta_path); + std::fs::rename(&download_path, &model_path) + .map_err(|e| format!("Failed to rename temp file: {}", e))?; + return Ok(()); + } + _ => { + return Err(format!("Download failed with status: {}", status_code)); + } + }; + + // Set initial progress + if total_size > 0 { + let progress = start_offset as f32 / total_size as f32; + self.status.write().download_progress = Some(progress); + } else { + self.status.write().download_progress = Some(0.0); + } // Stream the download + use futures::StreamExt; use std::io::Write; let mut stream = response.bytes_stream(); - use futures::StreamExt; + let mut downloaded = start_offset; while let Some(chunk) = stream.next().await { let chunk = chunk.map_err(|e| format!("Download error: {}", e))?; @@ -197,7 +376,10 @@ impl LlamaManager { self.status.write().download_progress = Some(progress); // Log progress every 10% - if (progress * 10.0) as u32 > ((downloaded - chunk.len() as u64) as f32 / total_size as f32 * 10.0) as u32 { + let prev_pct = + ((downloaded - chunk.len() as u64) as f32 / total_size as f32 * 10.0) as u32; + let curr_pct = (progress * 10.0) as u32; + if curr_pct > prev_pct { log::info!("[llama] Download progress: {:.1}%", progress * 100.0); } } @@ -208,16 +390,136 @@ impl LlamaManager { .map_err(|e| format!("Failed to flush file: {}", e))?; drop(file); - // Rename temp file to final path - std::fs::rename(&temp_path, &model_path) + // Clean up meta file and rename temp → final + let _ = std::fs::remove_file(&meta_path); + std::fs::rename(&download_path, &model_path) .map_err(|e| format!("Failed to rename temp file: {}", e))?; - log::info!("[llama] Model downloaded successfully to {:?}", model_path); - self.status.write().download_progress = None; + log::info!( + "[llama] Model downloaded successfully to {:?}", + model_path + ); Ok(()) } + /// Check if we can resume a previous partial download. + /// Returns (existing_bytes, should_resume). + fn check_resume_state(&self, download_path: &PathBuf, meta_path: &PathBuf) -> (u64, bool) { + let file_exists = download_path.exists(); + let meta_exists = meta_path.exists(); + + if !file_exists { + // No partial download — start fresh + if meta_exists { + let _ = std::fs::remove_file(meta_path); + } + return (0, false); + } + + // Get existing file size + let existing_size = match std::fs::metadata(download_path) { + Ok(m) => m.len(), + Err(_) => { + let _ = std::fs::remove_file(download_path); + let _ = std::fs::remove_file(meta_path); + return (0, false); + } + }; + + if existing_size == 0 { + let _ = std::fs::remove_file(download_path); + let _ = std::fs::remove_file(meta_path); + return (0, false); + } + + // Validate meta file + if !meta_exists { + // No meta — can't verify, start fresh + log::warn!("[llama] Partial download exists but no meta file, restarting"); + let _ = std::fs::remove_file(download_path); + return (0, false); + } + + // Read and validate meta + match std::fs::read_to_string(meta_path) { + Ok(contents) => match serde_json::from_str::(&contents) { + Ok(meta) => { + if meta.url != MODEL_URL { + log::warn!("[llama] Meta URL mismatch, restarting download"); + let _ = std::fs::remove_file(download_path); + let _ = std::fs::remove_file(meta_path); + return (0, false); + } + if existing_size >= meta.total_size && meta.total_size > 0 { + // Download was complete but rename didn't happen — handle this + log::info!( + "[llama] Partial download appears complete ({}/{})", + existing_size, + meta.total_size + ); + } + (existing_size, true) + } + Err(_) => { + log::warn!("[llama] Corrupt meta file, restarting download"); + let _ = std::fs::remove_file(download_path); + let _ = std::fs::remove_file(meta_path); + (0, false) + } + }, + Err(_) => { + log::warn!("[llama] Cannot read meta file, restarting download"); + let _ = std::fs::remove_file(download_path); + let _ = std::fs::remove_file(meta_path); + (0, false) + } + } + } + + /// Parse the total size from a Content-Range header (e.g., "bytes 1000-2000/5000"). + fn parse_content_range_total( + &self, + response: &reqwest::Response, + fallback_offset: u64, + ) -> u64 { + if let Some(range) = response.headers().get("content-range") { + if let Ok(range_str) = range.to_str() { + // Format: "bytes START-END/TOTAL" + if let Some(slash_pos) = range_str.rfind('/') { + if let Ok(total) = range_str[slash_pos + 1..].trim().parse::() { + // Write meta if we don't have it yet + let _ = self.write_meta(&self.meta_path(), total); + return total; + } + } + } + } + // Fallback: content-length + offset + let content_len = response.content_length().unwrap_or(0); + fallback_offset + content_len + } + + /// Read the total_size from an existing meta file. + fn read_meta_total(&self, meta_path: &PathBuf) -> Option { + let contents = std::fs::read_to_string(meta_path).ok()?; + let meta: DownloadMeta = serde_json::from_str(&contents).ok()?; + Some(meta.total_size) + } + + /// Write download metadata sidecar file. + fn write_meta(&self, meta_path: &PathBuf, total_size: u64) -> Result<(), String> { + let meta = DownloadMeta { + total_size, + url: MODEL_URL.to_string(), + }; + let json = serde_json::to_string(&meta) + .map_err(|e| format!("Failed to serialize meta: {}", e))?; + std::fs::write(meta_path, json) + .map_err(|e| format!("Failed to write meta file: {}", e))?; + Ok(()) + } + /// Ensure the model is loaded into memory. pub async fn ensure_loaded(&self) -> Result<(), String> { // Already loaded? diff --git a/src-tauri/src/services/tdlib_v8/bootstrap.js b/src-tauri/src/services/tdlib_v8/bootstrap.js index 2662db39f..6bac1ddaa 100644 --- a/src-tauri/src/services/tdlib_v8/bootstrap.js +++ b/src-tauri/src/services/tdlib_v8/bootstrap.js @@ -823,6 +823,91 @@ globalThis.data = { }, }; +// ============================================================================ +// OAuth Bridge API (credential management and authenticated proxy) +// ============================================================================ +(function () { + var __oauthCredential = null; + + globalThis.oauth = { + /** Get the current OAuth credential, or null if not connected. */ + getCredential: function () { + return __oauthCredential; + }, + + /** + * Make an authenticated API request proxied through the backend. + * Path is relative to manifest's apiBaseUrl. + */ + fetch: function (path, options) { + if (!__oauthCredential) { + return { + status: 401, + headers: {}, + body: JSON.stringify({ error: 'No OAuth credential. Complete OAuth setup first.' }), + }; + } + var backendUrl = __platform.env('BACKEND_URL') || 'https://api.alphahuman.xyz'; + var jwtToken = __platform.env('JWT_TOKEN') || ''; + var cleanPath = path.charAt(0) === '/' ? path.slice(1) : path; + var proxyUrl = backendUrl + '/proxy/by-id/' + __oauthCredential.credentialId + '/' + cleanPath; + var method = (options && options.method) || 'GET'; + var headers = { 'Content-Type': 'application/json' }; + if (jwtToken) { + headers['Authorization'] = 'Bearer ' + jwtToken; + } + if (options && options.headers) { + for (var k in options.headers) { + headers[k] = options.headers[k]; + } + } + var fetchOpts = JSON.stringify({ + method: method, + headers: headers, + body: options ? options.body : undefined, + timeout: options ? options.timeout : undefined, + }); + var result = __net.fetch(proxyUrl, fetchOpts); + return JSON.parse(result); + }, + + /** Revoke the current OAuth credential server-side. */ + revoke: function () { + if (__oauthCredential) { + try { + var backendUrl = __platform.env('BACKEND_URL') || 'https://api.alphahuman.xyz'; + var jwtToken = __platform.env('JWT_TOKEN') || ''; + var revokeOpts = JSON.stringify({ + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer ' + jwtToken, + }, + }); + __net.fetch( + backendUrl + '/auth/integrations/' + __oauthCredential.credentialId, + revokeOpts + ); + } catch (e) { + /* best effort */ + } + } + __oauthCredential = null; + return true; + }, + + /** Internal: set credential (called by runtime on oauth/complete). */ + __setCredential: function (cred) { + __oauthCredential = cred; + }, + }; +})(); + +// ============================================================================ +// Tools array (skills assign to this global) +// ============================================================================ +globalThis.tools = []; + // ============================================================================ // Cron Bridge API (placeholder - requires integration with CronScheduler) // ============================================================================ diff --git a/src/App.tsx b/src/App.tsx index 2e8c1df8b..63751d9a5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import { PersistGate } from 'redux-persist/integration/react'; import AppRoutes from './AppRoutes'; import AIProvider from './providers/AIProvider'; +import ModelProvider from './providers/ModelProvider'; import SkillProvider from './providers/SkillProvider'; import SocketProvider from './providers/SocketProvider'; import UserProvider from './providers/UserProvider'; @@ -15,26 +16,28 @@ function App() { Something went wrong.}> - - - - - -
-
-
- AlphaHuman is in early beta. + + + + + + +
+
+
+ AlphaHuman is in early beta. +
+
+
+
-
- -
-
- - - - - + + + + + + diff --git a/src/AppRoutes.tsx b/src/AppRoutes.tsx index f0b58c1da..6cf59dc2a 100644 --- a/src/AppRoutes.tsx +++ b/src/AppRoutes.tsx @@ -15,13 +15,27 @@ import { isTauri } from './utils/tauriCommands'; const OnboardingRoute = () => { const isOnboarded = useAppSelector(selectIsOnboarded); - - // If the user has already completed onboarding, skip this page and go home. - // On first load, when onboarding status is unset/false, we allow showing onboarding. if (isOnboarded) return ; return ; }; +/** + * Home route wrapper: shows Home by default. + * Only redirects to onboarding when user profile is loaded and onboarding is not done. + */ +const HomeRoute = () => { + const user = useAppSelector(state => state.user.user); + const isOnboarded = useAppSelector(selectIsOnboarded); + + // While user profile is still loading, show Home (avoid flash to onboarding) + if (!user) return ; + + // User loaded but onboarding not done → redirect to onboarding + if (!isOnboarded) return ; + + return ; +}; + const AppRoutes = () => { const [isWeb, setIsWeb] = useState(false); @@ -65,8 +79,8 @@ const AppRoutes = () => { - + + } /> @@ -75,8 +89,8 @@ const AppRoutes = () => { - + + } /> diff --git a/src/components/DefaultRedirect.tsx b/src/components/DefaultRedirect.tsx index aa6109844..1ca5f7060 100644 --- a/src/components/DefaultRedirect.tsx +++ b/src/components/DefaultRedirect.tsx @@ -1,26 +1,19 @@ import { Navigate } from 'react-router-dom'; -import { selectIsOnboarded } from '../store/authSelectors'; import { useAppSelector } from '../store/hooks'; /** - * Default redirect component that routes users based on their auth and onboarding status + * Default redirect component that routes users based on their auth status. * - Not logged in → / (Welcome page) - * - Logged in but not onboarded → /onboarding - * - Logged in and onboarded → /home + * - Logged in → /home (Home handles onboarding redirect if needed) */ const DefaultRedirect = () => { const token = useAppSelector(state => state.auth.token); - const isOnboarded = useAppSelector(selectIsOnboarded); - if (token && isOnboarded) { + if (token) { return ; } - if (token && !isOnboarded) { - return ; - } - return ; }; diff --git a/src/components/ModelDownloadProgress.tsx b/src/components/ModelDownloadProgress.tsx index cea693b92..666948e34 100644 --- a/src/components/ModelDownloadProgress.tsx +++ b/src/components/ModelDownloadProgress.tsx @@ -1,3 +1,4 @@ +import { platform } from '@tauri-apps/plugin-os'; import { useEffect, useState } from 'react'; import { useModelStatus } from '../hooks/useModelStatus'; @@ -11,7 +12,7 @@ const ModelDownloadProgress = ({ className = '', showWhenLoaded = false, }: ModelDownloadProgressProps) => { - const { isAvailable, isLoaded, isLoading, downloadProgress, error, ensureLoaded } = + const { isAvailable, isLoaded, isLoading, isDownloaded, downloadProgress, error, ensureLoaded } = useModelStatus(); const [isMobile, setIsMobile] = useState(false); @@ -19,7 +20,6 @@ const ModelDownloadProgress = ({ // Detect mobile platform const detectMobile = async () => { try { - const { platform } = await import('@tauri-apps/plugin-os'); const currentPlatform = await platform(); setIsMobile(currentPlatform === 'android' || currentPlatform === 'ios'); } catch { @@ -63,8 +63,8 @@ const ModelDownloadProgress = ({ return null; } - // Don't render if loaded and showWhenLoaded is false - if (isLoaded && !showWhenLoaded && !isLoading) { + // Hide when downloaded or loaded (nothing for the user to do) + if ((isDownloaded || isLoaded) && !showWhenLoaded && !isLoading) { return null; } diff --git a/src/components/PublicRoute.tsx b/src/components/PublicRoute.tsx index 5bde4e3a5..61df14373 100644 --- a/src/components/PublicRoute.tsx +++ b/src/components/PublicRoute.tsx @@ -1,6 +1,5 @@ import { Navigate } from 'react-router-dom'; -import { selectIsOnboarded } from '../store/authSelectors'; import { useAppSelector } from '../store/hooks'; interface PublicRouteProps { @@ -9,24 +8,18 @@ interface PublicRouteProps { } /** - * Public route component that redirects authenticated users - * If logged in and onboarded -> redirect to /home - * If logged in but not onboarded -> redirect to /onboarding + * Public route component that redirects authenticated users to /home. + * Home handles the onboarding redirect once the user profile is loaded. */ const PublicRoute = ({ children, redirectTo }: PublicRouteProps) => { const token = useAppSelector(state => state.auth.token); - const isOnboarded = useAppSelector(selectIsOnboarded); - // If user is logged in and onboarded, redirect to home - if (token && isOnboarded) { + // If user is logged in, always go to home. + // Home itself will redirect to onboarding if needed. + if (token) { return ; } - // If user is logged in but not onboarded, redirect to onboarding - if (token && !isOnboarded) { - return ; - } - // User is not logged in, show public route return <>{children}; }; diff --git a/src/components/SkillsGrid.tsx b/src/components/SkillsGrid.tsx index 222ce669d..23b3aebdb 100644 --- a/src/components/SkillsGrid.tsx +++ b/src/components/SkillsGrid.tsx @@ -1,3 +1,5 @@ +import { invoke } from '@tauri-apps/api/core'; +import { platform } from '@tauri-apps/plugin-os'; import { useEffect, useMemo, useState } from 'react'; import GoogleIcon from '../assets/icons/GoogleIcon'; @@ -188,7 +190,7 @@ function SkillActionButton({ name: skill.name, version: '0.0.0', description: skill.description, - runtime: 'v8', + runtime: 'quickjs', }); // If skill has setup, the manager will set setup_required status // and the grid will re-render with the "Setup" button @@ -292,7 +294,6 @@ export default function SkillsGrid() { // Detect mobile platform const detectMobile = async () => { try { - const { platform } = await import('@tauri-apps/plugin-os'); const currentPlatform = await platform(); setIsMobile(currentPlatform === 'android' || currentPlatform === 'ios'); } catch { @@ -305,7 +306,6 @@ export default function SkillsGrid() { // Load skills from the V8 runtime engine. const loadSkills = async () => { try { - const { invoke } = await import('@tauri-apps/api/core'); const manifests = await invoke>>('runtime_discover_skills'); console.log('manifests', manifests); diff --git a/src/components/settings/SettingsHome.tsx b/src/components/settings/SettingsHome.tsx index fa52a7271..6b9cbffa6 100644 --- a/src/components/settings/SettingsHome.tsx +++ b/src/components/settings/SettingsHome.tsx @@ -13,10 +13,10 @@ const SettingsHome = () => { closeSettings(); }; - const handleViewEncryptionKey = () => { - // TODO: Show encryption key in a secure modal - console.log('View encryption key'); - }; + // const handleViewEncryptionKey = () => { + // // TODO: Show encryption key in a secure modal + // console.log('View encryption key'); + // }; const handleDeleteAllData = () => { // TODO: Show confirmation dialog and delete all data @@ -64,63 +64,63 @@ const SettingsHome = () => { onClick: () => navigateToSettings('privacy'), dangerous: false, }, - { - id: 'profile', - title: 'Profile', - description: 'Update your profile information and preferences', - icon: ( - - - - ), - onClick: () => navigateToSettings('profile'), - dangerous: false, - }, - { - id: 'advanced', - title: 'Advanced', - description: 'Advanced configuration and developer options', - icon: ( - - - - - ), - onClick: () => navigateToSettings('advanced'), - dangerous: false, - }, - { - id: 'encryption', - title: 'View Encryption Key', - description: 'Access your encryption key for backup purposes', - icon: ( - - - - ), - onClick: handleViewEncryptionKey, - dangerous: false, - }, + // { + // id: 'profile', + // title: 'Profile', + // description: 'Update your profile information and preferences', + // icon: ( + // + // + // + // ), + // onClick: () => navigateToSettings('profile'), + // dangerous: false, + // }, + // { + // id: 'advanced', + // title: 'Advanced', + // description: 'Advanced configuration and developer options', + // icon: ( + // + // + // + // + // ), + // onClick: () => navigateToSettings('advanced'), + // dangerous: false, + // }, + // { + // id: 'encryption', + // title: 'View Encryption Key', + // description: 'Access your encryption key for backup purposes', + // icon: ( + // + // + // + // ), + // onClick: handleViewEncryptionKey, + // dangerous: false, + // }, { id: 'team', title: 'Team', @@ -140,7 +140,7 @@ const SettingsHome = () => { }, { id: 'billing', - title: 'Billing', + title: 'Billing & Usage', description: 'Manage your subscription and payment methods', icon: ( diff --git a/src/components/settings/panels/BillingPanel.tsx b/src/components/settings/panels/BillingPanel.tsx index 300825f2e..2bedbae71 100644 --- a/src/components/settings/panels/BillingPanel.tsx +++ b/src/components/settings/panels/BillingPanel.tsx @@ -27,15 +27,11 @@ const BillingPanel = () => { const activeTeam = teams.find(t => t.team._id === activeTeamId); const teamName = activeTeam?.team.name; - // Derive plan from active team when available, fall back to user - const currentTier: PlanTier = - activeTeam?.team.subscription?.plan ?? user?.subscription?.plan ?? 'FREE'; - const hasActive = - activeTeam?.team.subscription?.hasActiveSubscription ?? - user?.subscription?.hasActiveSubscription ?? - false; - const planExpiry = activeTeam?.team.subscription?.planExpiry ?? user?.subscription?.planExpiry; - const usage = activeTeam?.team.usage ?? user?.usage; + // Derive plan from active team (team is source of truth) + const currentTier: PlanTier = activeTeam?.team.subscription?.plan ?? 'FREE'; + const hasActive = activeTeam?.team.subscription?.hasActiveSubscription ?? false; + const planExpiry = activeTeam?.team.subscription?.planExpiry; + const usage = activeTeam?.team.usage; // Local state const [billingInterval, setBillingInterval] = useState<'monthly' | 'annual'>('monthly'); diff --git a/src/components/settings/panels/PrivacyPanel.tsx b/src/components/settings/panels/PrivacyPanel.tsx index 9d2d145d9..8656b74dc 100644 --- a/src/components/settings/panels/PrivacyPanel.tsx +++ b/src/components/settings/panels/PrivacyPanel.tsx @@ -11,7 +11,7 @@ const PrivacyPanel = () => { const analyticsEnabled = useAppSelector(state => { const userId = state.user.user?._id; if (!userId) return false; - return state.auth.isAnalyticsEnabledByUser[userId] === true; + return state.auth.isAnalyticsEnabledByUser[userId] !== false; }); const handleToggleAnalytics = () => { diff --git a/src/components/settings/panels/TeamInvitesPanel.tsx b/src/components/settings/panels/TeamInvitesPanel.tsx index 03008c584..6ba5cf6b7 100644 --- a/src/components/settings/panels/TeamInvitesPanel.tsx +++ b/src/components/settings/panels/TeamInvitesPanel.tsx @@ -22,9 +22,7 @@ const TeamInvitesPanel = () => { const [error, setError] = useState(null); useEffect(() => { - if (activeTeamId) { - dispatch(fetchInvites(activeTeamId)); - } + if (activeTeamId) dispatch(fetchInvites(activeTeamId)); }, [activeTeamId, dispatch]); const handleGenerate = async () => { diff --git a/src/components/settings/panels/TeamMembersPanel.tsx b/src/components/settings/panels/TeamMembersPanel.tsx index 858d0f0f8..ce20e0485 100644 --- a/src/components/settings/panels/TeamMembersPanel.tsx +++ b/src/components/settings/panels/TeamMembersPanel.tsx @@ -24,9 +24,7 @@ const TeamMembersPanel = () => { const [error, setError] = useState(null); useEffect(() => { - if (activeTeamId) { - dispatch(fetchMembers(activeTeamId)); - } + if (activeTeamId) dispatch(fetchMembers(activeTeamId)); }, [activeTeamId, dispatch]); const handleChangeRole = async (member: TeamMember, newRole: TeamRole) => { diff --git a/src/components/skills/SkillSetupWizard.tsx b/src/components/skills/SkillSetupWizard.tsx index 577709f17..510d6d982 100644 --- a/src/components/skills/SkillSetupWizard.tsx +++ b/src/components/skills/SkillSetupWizard.tsx @@ -1,12 +1,17 @@ /** * Multi-step setup wizard for a single skill. - * Manages the state machine: start → render form → submit → next/error/complete. + * Manages the state machine: start -> render form -> submit -> next/error/complete. + * For OAuth skills, shows a login button instead of form steps. * Ensures the skill is running (starts it if needed) before starting the setup flow. */ import { useState, useEffect, useCallback } from "react"; import { store } from "../../store"; +import { useAppSelector } from "../../store/hooks"; import { skillManager } from "../../lib/skills/manager"; +import { setSkillSetupComplete } from "../../store/skillsSlice"; +import { apiClient } from "../../services/apiClient"; +import { openUrl } from "../../utils/openUrl"; import type { SetupStep, SetupFieldError } from "../../lib/skills/types"; import SetupFormRenderer from "./SetupFormRenderer"; @@ -16,8 +21,15 @@ interface SkillSetupWizardProps { onCancel: () => void; } +interface OAuthConfig { + provider: string; + scopes: string[]; +} + type WizardState = | { phase: "loading" } + | { phase: "oauth"; oauth: OAuthConfig } + | { phase: "oauth_waiting"; oauth: OAuthConfig } | { phase: "step"; step: SetupStep; errors?: SetupFieldError[] | null } | { phase: "submitting"; step: SetupStep } | { phase: "complete"; message?: string } @@ -30,6 +42,22 @@ export default function SkillSetupWizard({ }: SkillSetupWizardProps) { const [state, setState] = useState({ phase: "loading" }); + // Watch skill state for OAuth completion (skill pushes connected: true) + const skillState = useAppSelector( + (s) => s.skills.skillStates[skillId], + ); + + // When skill state changes to connected during OAuth waiting, mark complete + useEffect(() => { + if ( + (state.phase === "oauth" || state.phase === "oauth_waiting") && + skillState?.connected === true + ) { + store.dispatch(setSkillSetupComplete({ skillId, complete: true })); + setState({ phase: "complete", message: "Successfully connected!" }); + } + }, [skillState?.connected, state.phase, skillId]); + // Start the skill (if not running) then start the setup flow on mount useEffect(() => { let cancelled = false; @@ -68,11 +96,34 @@ export default function SkillSetupWizard({ throw new Error(errMsg); } + // If the skill has OAuth config, show OAuth login instead of form steps + if (manifest.setup?.oauth) { + if (!cancelled) { + setState({ + phase: "oauth", + oauth: { + provider: manifest.setup.oauth.provider, + scopes: manifest.setup.oauth.scopes, + }, + }); + } + return; + } + console.log("[SkillSetupWizard] starting setup", skillId); const firstStep = await skillManager.startSetup(skillId); - console.log("[SkillSetupWizard] setup started", skillId); + console.log("[SkillSetupWizard] setup started", skillId, firstStep); if (!cancelled) { - setState({ phase: "step", step: firstStep }); + if (!firstStep) { + // Skill doesn't implement setup steps — likely an OAuth-only skill + // whose manifest.setup.oauth wasn't populated yet + setState({ + phase: "error", + message: "This skill requires OAuth setup but no setup steps were returned. Try restarting the app.", + }); + } else { + setState({ phase: "step", step: firstStep }); + } } } catch (err) { if (!cancelled) { @@ -89,6 +140,32 @@ export default function SkillSetupWizard({ }; }, [skillId]); + const handleOAuthLogin = useCallback(async () => { + if (state.phase !== "oauth") return; + + const { oauth } = state; + + try { + // Call backend to get the real OAuth authorization URL + const data = await apiClient.get<{ oauthUrl?: string }>( + `/auth/${oauth.provider}/connect?responseType=json&skillId=${skillId}`, + ); + + if (!data.oauthUrl) { + console.error("[SkillSetupWizard] Backend did not return oauthUrl:", data); + setState({ phase: "error", message: "Failed to get OAuth URL from backend." }); + return; + } + + await openUrl(data.oauthUrl); + setState({ phase: "oauth_waiting", oauth }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error("[SkillSetupWizard] OAuth connect error:", err); + setState({ phase: "error", message: `OAuth connection failed: ${msg}` }); + } + }, [state, skillId]); + const handleSubmit = useCallback( async (values: Record) => { if (state.phase !== "step") return; @@ -132,13 +209,15 @@ export default function SkillSetupWizard({ ); const handleCancel = useCallback(async () => { - try { - await skillManager.cancelSetup(skillId); - } catch { - // Ignore cancel errors + if (state.phase !== "oauth" && state.phase !== "oauth_waiting") { + try { + await skillManager.cancelSetup(skillId); + } catch { + // Ignore cancel errors + } } onCancel(); - }, [skillId, onCancel]); + }, [skillId, onCancel, state.phase]); // Render based on current wizard state switch (state.phase) { @@ -171,6 +250,26 @@ export default function SkillSetupWizard({
); + case "oauth": + return ( + + ); + + case "oauth_waiting": + return ( + + ); + case "step": return ( = { + notion: "Notion", + google: "Google", + github: "GitHub", + slack: "Slack", + discord: "Discord", + twitter: "Twitter", + linear: "Linear", + }; + return names[provider] ?? provider.charAt(0).toUpperCase() + provider.slice(1); +} + +interface OAuthLoginViewProps { + provider: string; + onLogin: () => void; + onCancel: () => void; + waiting: boolean; +} + +function OAuthLoginView({ + provider, + onLogin, + onCancel, + waiting, +}: OAuthLoginViewProps) { + const providerName = formatProviderName(provider); + + return ( +
+ {/* Provider icon */} +
+
+ +
+
+ + {/* Title and description */} +
+

+ Connect to {providerName} +

+

+ {waiting + ? "Waiting for authorization. Complete the login in your browser..." + : `Sign in with your ${providerName} account to connect this skill.`} +

+
+ + {/* Login button or waiting state */} + {waiting ? ( +
+
+ + + + + + Waiting for {providerName} authorization... + +
+ + +
+ ) : ( + + )} + + {/* Cancel */} +
+ +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Provider Icons +// --------------------------------------------------------------------------- + +function ProviderIcon({ provider }: { provider: string }) { + switch (provider) { + case "notion": + return ( + + + + + ); + case "google": + return ( + + + + + + + ); + case "github": + return ( + + + + ); + default: + return ( + + + + ); + } +} diff --git a/src/hooks/useModelStatus.ts b/src/hooks/useModelStatus.ts index db407c949..4b661aa88 100644 --- a/src/hooks/useModelStatus.ts +++ b/src/hooks/useModelStatus.ts @@ -1,82 +1,62 @@ import { invoke } from '@tauri-apps/api/core'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback } from 'react'; + +import { useAppDispatch, useAppSelector } from '../store/hooks'; +import { + setDownloadTriggered, + setModelError, + setModelLoading, + setModelStatus, + type ModelStatus, +} from '../store/modelSlice'; /** - * Model status from Rust backend + * Hook to read model status from Redux and provide control actions. + * Status polling and auto-download are handled by ModelProvider. */ -export interface ModelStatus { - available: boolean; - loaded: boolean; - loading: boolean; - downloadProgress: number | null; - error: string | null; - modelPath: string | null; -} +export const useModelStatus = () => { + const dispatch = useAppDispatch(); + const model = useAppSelector(state => state.model); -const DEFAULT_STATUS: ModelStatus = { - available: false, - loaded: false, - loading: false, - downloadProgress: null, - error: null, - modelPath: null, -}; - -/** - * Hook to monitor and control local AI model status - */ -export const useModelStatus = (pollInterval = 1000) => { - const [status, setStatus] = useState(DEFAULT_STATUS); - const [isPolling, setIsPolling] = useState(false); - - // Fetch current status from backend const fetchStatus = useCallback(async () => { try { const result = await invoke('model_get_status'); - setStatus(result); + dispatch(setModelStatus(result)); return result; } catch (error) { console.error('[useModelStatus] Failed to fetch status:', error); - setStatus(prev => ({ - ...prev, - error: error instanceof Error ? error.message : 'Failed to fetch status', - })); + dispatch(setModelError(error instanceof Error ? error.message : 'Failed to fetch status')); return null; } - }, []); + }, [dispatch]); - // Check if model API is available - const checkAvailability = useCallback(async () => { + const startDownload = useCallback(async () => { try { - const available = await invoke('model_is_available'); - setStatus(prev => ({ ...prev, available })); - return available; + dispatch(setModelLoading(true)); + dispatch(setModelError(null)); + dispatch(setDownloadTriggered(true)); + await invoke('model_start_download'); + await fetchStatus(); } catch (error) { - console.error('[useModelStatus] Failed to check availability:', error); - return false; + console.error('[useModelStatus] Failed to start download:', error); + dispatch( + setModelError(error instanceof Error ? error.message : 'Failed to download model') + ); } - }, []); + }, [dispatch, fetchStatus]); - // Start loading/downloading the model const ensureLoaded = useCallback(async () => { try { - setStatus(prev => ({ ...prev, loading: true, error: null })); - setIsPolling(true); + dispatch(setModelLoading(true)); + dispatch(setModelError(null)); await invoke('model_ensure_loaded'); await fetchStatus(); - setIsPolling(false); } catch (error) { console.error('[useModelStatus] Failed to load model:', error); - setStatus(prev => ({ - ...prev, - loading: false, - error: error instanceof Error ? error.message : 'Failed to load model', - })); - setIsPolling(false); + dispatch(setModelError(error instanceof Error ? error.message : 'Failed to load model')); } - }, [fetchStatus]); + }, [dispatch, fetchStatus]); - // Unload the model from memory const unload = useCallback(async () => { try { await invoke('model_unload'); @@ -86,39 +66,19 @@ export const useModelStatus = (pollInterval = 1000) => { } }, [fetchStatus]); - // Initial check and polling setup - useEffect(() => { - // Initial fetch - fetchStatus(); - - // Check availability - checkAvailability(); - }, [fetchStatus, checkAvailability]); - - // Polling when loading/downloading - useEffect(() => { - if (!isPolling && !status.loading) return; - - const interval = setInterval(async () => { - const newStatus = await fetchStatus(); - // Stop polling when loading is done - if (newStatus && !newStatus.loading) { - setIsPolling(false); - } - }, pollInterval); - - return () => clearInterval(interval); - }, [isPolling, status.loading, pollInterval, fetchStatus]); - return { - status, - isAvailable: status.available, - isLoaded: status.loaded, - isLoading: status.loading, - downloadProgress: status.downloadProgress, - error: status.error, + status: model, + isAvailable: model.available, + isLoaded: model.loaded, + isLoading: model.loading, + isDownloaded: model.downloaded, + downloadProgress: model.downloadProgress, + error: model.error, + startDownload, ensureLoaded, unload, refresh: fetchStatus, }; }; + +export type { ModelStatus }; diff --git a/src/lib/skills/manager.ts b/src/lib/skills/manager.ts index e5935f4f9..925670681 100644 --- a/src/lib/skills/manager.ts +++ b/src/lib/skills/manager.ts @@ -5,6 +5,8 @@ * and tool invocation. Dispatches status changes to Redux. */ +import { invoke } from "@tauri-apps/api/core"; + import { SkillRuntime } from "./runtime"; import type { SkillManifest, @@ -125,9 +127,10 @@ class SkillManager { } /** - * Start the setup flow for a skill. Returns the first step. + * Start the setup flow for a skill. Returns the first step, or null if + * the skill doesn't implement setup/start (e.g. OAuth-only skills). */ - async startSetup(skillId: string): Promise { + async startSetup(skillId: string): Promise { console.log("[SkillManager] startSetup", skillId); const runtime = this.runtimes.get(skillId); if (!runtime) { @@ -231,6 +234,34 @@ class SkillManager { await this.activateSkill(skillId); } + /** + * Notify a skill that OAuth completed successfully. + * Called by the deep link handler after backend OAuth callback. + */ + async notifyOAuthComplete( + skillId: string, + integrationId: string, + provider?: string, + ): Promise { + const runtime = this.runtimes.get(skillId); + if (!runtime || !runtime.isRunning) { + console.warn(`[SkillManager] Cannot notify OAuth complete: skill ${skillId} not running`); + return; + } + + const manifest = store.getState().skills.skills[skillId]?.manifest; + + await runtime.oauthComplete({ + credentialId: integrationId, + provider: provider ?? manifest?.setup?.oauth?.provider ?? "unknown", + grantedScopes: manifest?.setup?.oauth?.scopes ?? [], + }); + + // Mark setup as complete and activate + store.dispatch(setSkillSetupComplete({ skillId, complete: true })); + await this.activateSkill(skillId); + } + /** * Forward session start to all ready skills. */ @@ -367,7 +398,6 @@ class SkillManager { case "data/read": { const filename = params.filename as string; try { - const { invoke } = await import("@tauri-apps/api/core"); const content = await invoke("runtime_skill_data_read", { skillId, filename, @@ -382,7 +412,6 @@ class SkillManager { const filename = params.filename as string; const content = params.content as string; try { - const { invoke } = await import("@tauri-apps/api/core"); await invoke("runtime_skill_data_write", { skillId, filename, diff --git a/src/lib/skills/runtime.ts b/src/lib/skills/runtime.ts index 83c3d2ee5..6fb8aeeaa 100644 --- a/src/lib/skills/runtime.ts +++ b/src/lib/skills/runtime.ts @@ -2,7 +2,7 @@ * Skill runtime — higher-level wrapper around SkillTransport * for managing a single skill's lifecycle. * - * With V8, skills are managed by the Rust runtime engine. + * Skills are managed by the Rust QuickJS runtime engine. * This class wraps the transport layer to provide the same API * that the SkillManager expects. */ @@ -29,7 +29,7 @@ export class SkillRuntime { /** * Set a handler for reverse RPC calls from the skill process. - * With V8, reverse RPC is handled by bridge globals, so this + * Reverse RPC is handled by bridge globals, so this * is kept for API compatibility. */ onReverseRpc(handler: ReverseRpcHandler): void { @@ -38,12 +38,12 @@ export class SkillRuntime { /** - * Start the skill in the V8 runtime engine. + * Start the skill in the QuickJS runtime engine. * The Rust engine handles process management, so we just tell it to start * and then initialize the transport for RPC routing. */ async start(): Promise { - // Start the skill in the Rust V8 runtime + // Start the skill in the Rust QuickJS runtime await invoke("runtime_start_skill", { skillId: this.manifest.id }); // Initialize the transport for RPC routing @@ -53,7 +53,7 @@ export class SkillRuntime { /** * Send skill/load with manifest + data dir. - * With V8, loading is handled by the Rust engine during start_skill, + * Loading is handled by the Rust engine during start_skill, * so this sends a no-op skill/load RPC for protocol compatibility. */ async load(additionalParams?: Record): Promise { @@ -69,13 +69,17 @@ export class SkillRuntime { /** * Start the setup flow. Returns the first SetupStep. + * Returns null if the skill does not implement setup/start (e.g. OAuth-only skills). */ - async setupStart(): Promise { + async setupStart(): Promise { console.log("[SkillRuntime] setupStart", this.skillId); - const result = await this.transport.request<{ step: SetupStep }>( + const result = await this.transport.request<{ step: SetupStep } | null>( "setup/start" ); console.log("[SkillRuntime] setupStart result", this.skillId, result); + if (!result || !result.step) { + return null; + } return result.step; } @@ -160,6 +164,29 @@ export class SkillRuntime { await this.transport.request("skill/sessionEnd", { sessionId }); } + /** + * Notify the skill that OAuth completed successfully. + * Sets the credential on the bridge and calls onOAuthComplete. + */ + async oauthComplete(args: { + credentialId: string; + provider: string; + grantedScopes?: string[]; + accountLabel?: string; + }): Promise { + await this.transport.request("oauth/complete", args as unknown as Record); + } + + /** + * Notify the skill that an OAuth credential was revoked. + */ + async oauthRevoked(args: { + credentialId: string; + reason: string; + }): Promise { + await this.transport.request("oauth/revoked", args as unknown as Record); + } + /** * Unload and stop the skill. */ diff --git a/src/lib/skills/transport.ts b/src/lib/skills/transport.ts index 0cdd7272e..4f8c1fad5 100644 --- a/src/lib/skills/transport.ts +++ b/src/lib/skills/transport.ts @@ -1,15 +1,14 @@ /** * JSON-RPC 2.0 transport over Tauri IPC commands. * - * Routes JSON-RPC requests to the Rust V8 runtime engine via - * `invoke('runtime_rpc', ...)`. Replaces the previous stdin/stdout - * subprocess transport while keeping the same public API. - * - * Reverse RPC (state/get, state/set, data/read, data/write) is now - * handled by bridge globals inside the V8 engine, so the transport - * no longer needs to handle reverse RPC from the skill. + * Routes JSON-RPC requests to the Rust QuickJS runtime engine via + * `invoke('runtime_rpc', ...)`. Reverse RPC (state/get, state/set, + * data/read, data/write) is handled by bridge globals inside the + * QuickJS engine. */ +import { invoke } from '@tauri-apps/api/core'; + export type ReverseRpcHandler = ( method: string, params: Record @@ -21,16 +20,16 @@ export class SkillTransport { /** * Set a handler for reverse RPC calls from the skill process. - * With V8, reverse RPC is handled by bridge globals, so this + * With QuickJS, reverse RPC is handled by bridge globals, so this * is kept for API compatibility but is a no-op. */ onReverseRpc(_handler: ReverseRpcHandler): void { - // No-op: V8 bridge globals handle state/data directly + // No-op: QuickJS bridge globals handle state/data directly } /** * Initialize the transport for a skill. - * With V8, the skill process is managed by the Rust runtime engine, + * With QuickJS, the skill process is managed by the Rust runtime engine, * so this just stores the skill ID for routing RPC calls. * * @param skillId - The skill ID to route requests to. @@ -58,7 +57,6 @@ export class SkillTransport { hasParams: params !== undefined, }); - const { invoke } = await import("@tauri-apps/api/core"); const result = await invoke("runtime_rpc", { skillId: this.skillId, method, @@ -89,25 +87,22 @@ export class SkillTransport { }); // Fire and forget - import("@tauri-apps/api/core").then(({ invoke }) => { - invoke("runtime_rpc", { - skillId: this.skillId, - method, - params: params ?? {}, - }).catch((err: unknown) => { - console.error("[skill-transport] Notification error:", err); - }); + invoke("runtime_rpc", { + skillId: this.skillId, + method, + params: params ?? {}, + }).catch((err: unknown) => { + console.error("[skill-transport] Notification error:", err); }); } /** - * Stop the transport. With V8, this is a no-op since the + * Stop the transport. With QuickJS, this is a no-op since the * Rust engine manages skill lifecycle. Use runtime_stop_skill instead. */ async kill(): Promise { if (this.skillId && this._started) { try { - const { invoke } = await import("@tauri-apps/api/core"); await invoke("runtime_stop_skill", { skillId: this.skillId }); } catch { // Skill may already be stopped diff --git a/src/lib/skills/types.ts b/src/lib/skills/types.ts index 936a35b6d..5ae8f418b 100644 --- a/src/lib/skills/types.ts +++ b/src/lib/skills/types.ts @@ -14,7 +14,7 @@ export interface SkillManifest { name: string; version: string; description: string; - runtime: "python" | "node" | "deno" | "v8"; + runtime: "quickjs"; entry?: string; tick_interval?: number; env?: string[]; @@ -22,10 +22,17 @@ export interface SkillManifest { setup?: { required: boolean; label?: string; + oauth?: { + provider: string; + scopes: string[]; + apiBaseUrl: string; + }; }; /** Platform filter. When present, only listed platforms load this skill. * When absent or empty, the skill is available on all platforms. */ platforms?: SkillPlatform[]; + /** When true, skill is hidden in production builds. */ + ignoreInProduction?: boolean; } // --------------------------------------------------------------------------- diff --git a/src/main.tsx b/src/main.tsx index 3c5a68649..30ea01b06 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -6,15 +6,14 @@ import App from './App'; import './index.css'; import './polyfills'; import { initSentry } from './services/analytics'; +import { setupDesktopDeepLinkListener } from './utils/desktopDeepLinkListener'; // Initialize Sentry early (before React renders) initSentry(); -// Deep link listener - lazy import to avoid running before Tauri IPC is ready -import('./utils/desktopDeepLinkListener').then(m => { - m.setupDesktopDeepLinkListener().catch(err => { - console.error('[DeepLink] setup error:', err); - }); +// Deep link listener — try/catch handles non-Tauri environments +setupDesktopDeepLinkListener().catch(err => { + console.error('[DeepLink] setup error:', err); }); ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index d22151e68..a2bd44149 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -37,9 +37,9 @@ const Home = () => { const showUpgradeCTA = currentPlan === 'FREE'; return ( -
+
{/* Content overlay */} -
+
{/* Main content */}
diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index 7abb58d48..1f0a95514 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -41,7 +41,7 @@ const Login = () => { if (consumeError) { return ( -
+

{consumeError}

@@ -55,7 +55,7 @@ const Login = () => { } return ( -
+
diff --git a/src/pages/Welcome.tsx b/src/pages/Welcome.tsx index 5665c2797..ae9901f35 100644 --- a/src/pages/Welcome.tsx +++ b/src/pages/Welcome.tsx @@ -1,6 +1,9 @@ +import { useCallback } from 'react'; + import DownloadScreen from '../components/DownloadScreen'; import TelegramLoginButton from '../components/TelegramLoginButton'; import TypewriterGreeting from '../components/TypewriterGreeting'; +import { useModelStatus } from '../hooks/useModelStatus'; interface WelcomeProps { isWeb: boolean; @@ -11,16 +14,24 @@ const Welcome = ({ isWeb }: WelcomeProps) => { 'Hello HAL9000! 👋', "Let's cook! 🔥", 'The A-Team is here! 👊', - // "Welcome to the exclusive club of crypto degenerates! 🎪🚀", - // "Let's get you richer than a Nigerian prince's email! 👑💸", - // "Ready to HODL like your life depends on it? 🤝💀", - // "Welcome, future crypto millionaire (results not guaranteed)! 🎰💎", - // "Time to make Wall Street bros jealous AF! 📈🔥", - // "Ready to go to the moon? Pack light! 🌙🚀" ]; + const { isAvailable, isDownloaded, isLoading, downloadProgress, error, startDownload } = + useModelStatus(); + + const handleRetry = useCallback(() => { + startDownload(); + }, [startDownload]); + + const progressPercent = downloadProgress !== null ? Math.round(downloadProgress * 100) : 0; + + // Determine what to show for download progress + const showProgress = !isWeb && isAvailable && !isDownloaded; + const isDownloading = isLoading && downloadProgress !== null; + const isPreparing = isLoading && downloadProgress === null; + return ( -
+
{/* Main content */}
{/* Welcome card */} @@ -28,8 +39,6 @@ const Welcome = ({ isWeb }: WelcomeProps) => { {/* Greeting */} - {/*
*/} -

Welcome to AlphaHuman. Your Telegram assistant here to get you 10x more done in your journey. @@ -37,6 +46,41 @@ const Welcome = ({ isWeb }: WelcomeProps) => {

Are you ready for this?

+ {/* Model download progress (desktop only) */} + {showProgress && ( +
+ {isDownloading && ( +
+
+
+
+

+ Downloading AI model... {progressPercent}% + (~1.2 GB) +

+
+ )} + + {isPreparing && ( +

Preparing AI model download...

+ )} + + {error && !isLoading && ( +
+

{error}

+ +
+ )} +
+ )} + {/* Show Telegram login button in Tauri app, download screen on web */} {!isWeb && (
diff --git a/src/pages/onboarding/Onboarding.tsx b/src/pages/onboarding/Onboarding.tsx index f916cf5fe..173faa12f 100644 --- a/src/pages/onboarding/Onboarding.tsx +++ b/src/pages/onboarding/Onboarding.tsx @@ -63,7 +63,7 @@ const Onboarding = () => { }; return ( -
+
diff --git a/src/providers/ModelProvider.tsx b/src/providers/ModelProvider.tsx new file mode 100644 index 000000000..1d39d7297 --- /dev/null +++ b/src/providers/ModelProvider.tsx @@ -0,0 +1,137 @@ +import { invoke } from '@tauri-apps/api/core'; +import { platform } from '@tauri-apps/plugin-os'; +import { useEffect } from 'react'; + +import { useAppDispatch, useAppSelector } from '../store/hooks'; +import { + setDownloadTriggered, + setModelError, + setModelLoading, + setModelStatus, + type ModelStatus, +} from '../store/modelSlice'; + +const POLL_INTERVAL = 1000; + +/** + * App-level provider that auto-starts model download on desktop + * and keeps Redux model state in sync with the Rust backend. + */ +const ModelProvider = ({ children }: { children: React.ReactNode }) => { + const dispatch = useAppDispatch(); + const loading = useAppSelector(state => state.model.loading); + const downloadTriggered = useAppSelector(state => state.model.downloadTriggered); + + // Single init effect: fetch status → check platform → auto-download if needed. + // No ref guard — safe to re-run; Rust backend prevents concurrent downloads. + useEffect(() => { + let cancelled = false; + + const init = async () => { + // 1. Fetch initial status + let status: ModelStatus; + try { + status = await invoke('model_get_status'); + console.log('[ModelProvider] Initial status:', JSON.stringify(status)); + if (cancelled) return; + dispatch(setModelStatus(status)); + } catch (err) { + console.log('[ModelProvider] Not in Tauri environment:', err); + return; + } + + // 2. Check availability + try { + const avail = await invoke('model_is_available'); + console.log('[ModelProvider] Available:', avail); + if (!avail || cancelled) return; + status = await invoke('model_get_status'); + if (cancelled) return; + dispatch(setModelStatus(status)); + } catch (err) { + console.log('[ModelProvider] Availability check failed:', err); + return; + } + + // 3. If already downloaded or already loading, nothing to do + if (status.downloaded) { + console.log('[ModelProvider] Already downloaded, skipping auto-download'); + return; + } + if (status.loading) { + console.log('[ModelProvider] Already loading, will poll'); + if (!cancelled) dispatch(setModelLoading(true)); + return; + } + + // 4. Check platform — only auto-download on desktop + try { + const currentPlatform = await platform(); + console.log('[ModelProvider] Platform:', currentPlatform); + if (currentPlatform === 'android' || currentPlatform === 'ios') { + console.log('[ModelProvider] Mobile platform, skipping'); + return; + } + } catch (err) { + console.log('[ModelProvider] Platform detection failed (web?), skipping:', err); + return; + } + + if (cancelled) return; + + // 5. Start download + console.log('[ModelProvider] Starting auto-download...'); + dispatch(setDownloadTriggered(true)); + dispatch(setModelLoading(true)); + dispatch(setModelError(null)); + + try { + await invoke('model_start_download'); + if (cancelled) return; + const finalStatus = await invoke('model_get_status'); + console.log('[ModelProvider] Download complete:', JSON.stringify(finalStatus)); + if (!cancelled) dispatch(setModelStatus(finalStatus)); + } catch (err) { + console.error('[ModelProvider] Download failed:', err); + if (!cancelled) + dispatch(setModelError(err instanceof Error ? err.message : String(err))); + } + }; + + // Only run if download hasn't been triggered yet (Redux state, survives StrictMode) + if (!downloadTriggered) { + init(); + } + + return () => { + cancelled = true; + }; + }, [dispatch, downloadTriggered]); + + // Poll status while loading/downloading + useEffect(() => { + if (!loading) return; + + console.log('[ModelProvider] Polling started'); + const interval = setInterval(async () => { + try { + const status = await invoke('model_get_status'); + dispatch(setModelStatus(status)); + if (!status.loading) { + console.log('[ModelProvider] Loading finished:', JSON.stringify(status)); + } + } catch { + // ignore + } + }, POLL_INTERVAL); + + return () => { + console.log('[ModelProvider] Polling stopped'); + clearInterval(interval); + }; + }, [dispatch, loading]); + + return <>{children}; +}; + +export default ModelProvider; diff --git a/src/providers/SkillProvider.tsx b/src/providers/SkillProvider.tsx index ee3123423..3e8ccfd97 100644 --- a/src/providers/SkillProvider.tsx +++ b/src/providers/SkillProvider.tsx @@ -1,41 +1,49 @@ /** * Skill Provider — discovers and manages skill lifecycles. * - * On mount (when authenticated): discovers skills from the V8 runtime + * On mount (when authenticated): discovers skills from the QuickJS runtime * engine, registers them in Redux, and auto-starts skills with completed setup. - * - * The Rust V8 engine handles skill discovery and auto-start independently. - * This provider bridges the Rust engine state with the frontend Redux store. */ +import { invoke } from '@tauri-apps/api/core'; import { type ReactNode, useEffect, useRef } from 'react'; import { skillManager } from '../lib/skills/manager'; import type { SkillManifest } from '../lib/skills/types'; import { useAppSelector } from '../store/hooks'; -import { DEV_AUTO_LOAD_SKILL } from '../utils/config'; +import { DEV_AUTO_LOAD_SKILL, IS_DEV } from '../utils/config'; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- async function discoverSkills(): Promise { - const { invoke } = await import('@tauri-apps/api/core'); const raw = await invoke>>('runtime_discover_skills'); - // Map the V8 manifest format to SkillManifest - return raw.map(m => ({ + + const manifests: SkillManifest[] = raw.map(m => ({ id: m.id as string, name: m.name as string, version: (m.version as string) || '0.0.0', description: (m.description as string) || '', - runtime: 'v8' as const, + runtime: 'quickjs' as const, entry: m.entry as string | undefined, + ignoreInProduction: (m.ignoreInProduction as boolean) ?? false, setup: m.setup ? { required: ((m.setup as Record).required as boolean) ?? false, label: (m.setup as Record).label as string | undefined, + oauth: (m.setup as Record).oauth as + | { provider: string; scopes: string[]; apiBaseUrl: string } + | undefined, } : undefined, })); + + // In production, filter out skills marked as dev-only + if (!IS_DEV) { + return manifests.filter(m => !m.ignoreInProduction); + } + + return manifests; } // --------------------------------------------------------------------------- @@ -89,7 +97,7 @@ export default function SkillProvider({ children }: { children: ReactNode }) { const init = async () => { try { - // Discover skills from the V8 runtime engine + // Discover skills from the QuickJS runtime engine const manifests = await discoverSkills(); await registerAndStart(manifests); } catch (err) { diff --git a/src/services/analytics.ts b/src/services/analytics.ts index 49cc0daae..c361de1bb 100644 --- a/src/services/analytics.ts +++ b/src/services/analytics.ts @@ -30,7 +30,7 @@ export function isAnalyticsEnabled(): boolean { const state = store.getState(); const userId = state.user?.user?._id; if (!userId) return false; - return state.auth.isAnalyticsEnabledByUser[userId] === true; + return state.auth.isAnalyticsEnabledByUser[userId] !== false; } // --------------------------------------------------------------------------- diff --git a/src/store/index.ts b/src/store/index.ts index 970490ad4..859210ad7 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -15,6 +15,7 @@ import storage from 'redux-persist/lib/storage'; import { IS_DEV } from '../utils/config'; import aiReducer from './aiSlice'; import authReducer, { setOnboardedForUser, setToken } from './authSlice'; +import modelReducer from './modelSlice'; import skillsReducer from './skillsSlice'; import socketReducer from './socketSlice'; import teamReducer from './teamSlice'; @@ -45,6 +46,7 @@ export const store = configureStore({ ai: persistedAiReducer, skills: persistedSkillsReducer, team: teamReducer, + model: modelReducer, }, middleware: getDefaultMiddleware => { const middleware = getDefaultMiddleware({ diff --git a/src/store/modelSlice.ts b/src/store/modelSlice.ts new file mode 100644 index 000000000..46dc9dc53 --- /dev/null +++ b/src/store/modelSlice.ts @@ -0,0 +1,60 @@ +import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; + +export interface ModelStatus { + available: boolean; + loaded: boolean; + loading: boolean; + downloaded: boolean; + downloadProgress: number | null; + error: string | null; + modelPath: string | null; +} + +interface ModelState extends ModelStatus { + /** Whether auto-download has been triggered this session */ + downloadTriggered: boolean; +} + +const initialState: ModelState = { + available: false, + loaded: false, + loading: false, + downloaded: false, + downloadProgress: null, + error: null, + modelPath: null, + downloadTriggered: false, +}; + +const modelSlice = createSlice({ + name: 'model', + initialState, + reducers: { + setModelStatus(state, action: PayloadAction) { + const s = action.payload; + state.available = s.available; + state.loaded = s.loaded; + state.loading = s.loading; + state.downloaded = s.downloaded; + state.downloadProgress = s.downloadProgress; + state.error = s.error; + state.modelPath = s.modelPath; + }, + setDownloadTriggered(state, action: PayloadAction) { + state.downloadTriggered = action.payload; + }, + setModelLoading(state, action: PayloadAction) { + state.loading = action.payload; + }, + setModelError(state, action: PayloadAction) { + state.error = action.payload; + if (action.payload) { + state.loading = false; + } + }, + }, +}); + +export const { setModelStatus, setDownloadTriggered, setModelLoading, setModelError } = + modelSlice.actions; +export default modelSlice.reducer; diff --git a/src/types/api.ts b/src/types/api.ts index 3b04da755..2fc00557c 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -64,7 +64,7 @@ export interface User { username?: string; languageCode?: string; waitlist?: string; - activeTeamId?: string; + activeTeamId: string; } // Billing types diff --git a/src/utils/desktopDeepLinkListener.ts b/src/utils/desktopDeepLinkListener.ts index 4fd811ed1..66d0e7f00 100644 --- a/src/utils/desktopDeepLinkListener.ts +++ b/src/utils/desktopDeepLinkListener.ts @@ -1,15 +1,80 @@ import { isTauri as coreIsTauri, invoke } from '@tauri-apps/api/core'; import { getCurrent, onOpenUrl } from '@tauri-apps/plugin-deep-link'; +import { skillManager } from '../lib/skills/manager'; import { consumeLoginToken } from '../services/api/authApi'; import { store } from '../store'; import { setToken } from '../store/authSlice'; import { IS_DEV } from './config'; +/** + * Handle an `alphahuman://auth?token=...` deep link for login. + */ +const handleAuthDeepLink = async (parsed: URL) => { + const token = parsed.searchParams.get('token'); + if (!token) { + console.warn('[DeepLink] URL did not contain a token query parameter'); + return; + } + + console.log('[DeepLink] Received auth token'); + + try { + await invoke('show_window'); + } catch (err) { + console.warn('[DeepLink] Failed to show window:', err); + } + + const jwtToken = await consumeLoginToken(token); + store.dispatch(setToken(jwtToken)); + window.location.hash = '/onboarding'; +}; + +/** + * Handle `alphahuman://oauth/success?integrationId=...&skillId=...` + * and `alphahuman://oauth/error?error=...&provider=...` deep links. + */ +const handleOAuthDeepLink = async (parsed: URL) => { + // pathname is "/success" or "/error" (hostname is "oauth") + const path = parsed.pathname.replace(/^\/+/, ''); + + try { + await invoke('show_window'); + } catch { + // Not fatal + } + + if (path === 'success') { + const integrationId = parsed.searchParams.get('integrationId'); + const skillId = parsed.searchParams.get('skillId'); + + if (!integrationId || !skillId) { + console.error('[DeepLink] OAuth success missing integrationId or skillId', parsed.href); + return; + } + + console.log(`[DeepLink] OAuth success for skill=${skillId} integration=${integrationId}`); + + try { + await skillManager.notifyOAuthComplete(skillId, integrationId); + } catch (err) { + console.error('[DeepLink] Failed to notify OAuth complete:', err); + } + } else if (path === 'error') { + const error = parsed.searchParams.get('error') ?? 'Unknown error'; + const provider = parsed.searchParams.get('provider') ?? 'unknown'; + console.error(`[DeepLink] OAuth error for provider=${provider}: ${error}`); + } else { + console.warn('[DeepLink] Unknown OAuth path:', path); + } +}; + /** * Handle a list of deep link URLs delivered by the Tauri deep-link plugin. - * Parses `alphahuman://auth?token=...` URLs and exchanges the token for a - * desktop session via the backend. + * Routes to the appropriate handler based on the URL hostname: + * - `alphahuman://auth?token=...` → login flow + * - `alphahuman://oauth/success?...` → OAuth completion + * - `alphahuman://oauth/error?...` → OAuth failure */ const handleDeepLinkUrls = async (urls: string[] | null | undefined) => { if (!urls || urls.length === 0) { @@ -23,33 +88,18 @@ const handleDeepLinkUrls = async (urls: string[] | null | undefined) => { if (parsed.protocol !== 'alphahuman:') { return; } - // Harden: ensure this deep link is intended for auth handoff - if (parsed.hostname !== 'auth') { - return; + + switch (parsed.hostname) { + case 'auth': + await handleAuthDeepLink(parsed); + break; + case 'oauth': + await handleOAuthDeepLink(parsed); + break; + default: + console.warn('[DeepLink] Unknown deep link hostname:', parsed.hostname); + break; } - - const token = parsed.searchParams.get('token'); - if (!token) { - console.warn('[DeepLink] URL did not contain a token query parameter'); - return; - } - - console.log('[DeepLink] Received token', token); - - try { - // Bring app window to foreground so macOS users actually see completion. - // (In this app, the window can start hidden and live in the tray.) - await invoke('show_window'); - } catch (err) { - // Not fatal; we still continue the auth flow. - console.warn('[DeepLink] Failed to show window:', err); - } - - const jwtToken = await consumeLoginToken(token); - store.dispatch(setToken(jwtToken)); - - // Navigate to post-login flow. We use HashRouter, so update the hash route. - window.location.hash = '/onboarding'; } catch (error) { console.error('[DeepLink] Failed to handle deep link URL:', url, error); }